This document tracks every design decision — made or pending — for the Seamless 2FA Wallet. It serves as a log for the team: why things are the way they are, and what still needs to be resolved.
Status key:
- DECIDED — resolved, rationale documented
- UNDECIDED — needs team input before implementation
- DEFERRED — intentionally postponed to a later phase
Status: DECIDED | Date: 2026-02-17
Decision: The contract uses an asynchronous multisig model. Each signer sends the transaction independently. The contract accumulates signatures over separate transactions rather than requiring all signatures packed into a single submission.
Context: Two approaches were considered:
| Approach | How it works |
|---|---|
| Synchronous | All required signatures are packed into the signature field of a single transaction. Signers must coordinate before submission. |
| Asynchronous | Each signer sends the same transaction independently. The contract stores signatures and executes once the threshold is met. |
Rationale: The asynchronous model is a better fit for the cold-wallet use case. The cold wallet holder does not need to be online at the moment the hot wallet submits. They can review and sign at their own pace. The event emission creates a natural notification channel.
Trade-off: Two on-chain transactions instead of one for non-whitelisted operations. Accepted as worthwhile for the UX improvement.
Status: DECIDED | Date: 2026-02-17
Decision: There is no separate "propose" or "approve" function. Every signer
sends the exact same transaction through the same entry point (__execute__).
The contract does not distinguish between the first signer and subsequent signers.
Rationale: This keeps the contract simple and the interface minimal. The
contract just counts valid signatures per tx_hash. Whichever signer pushes the
count over the threshold triggers execution. No special roles for "proposer" vs
"approver."
Status: DECIDED | Date: 2026-02-17
Decision: The contract supports two policy modes, chosen at deployment:
-
Mode 0 (Threshold): Whitelisted transactions execute immediately with one signature from
signers_whitelist. Non-whitelisted transactions accumulate signatures from any signer in either list until a threshold is reached. -
Mode 1 (Role-Based):
signers_whitelist("easy signers") can only execute whitelisted transactions.signers_full("hard signers") can execute any transaction with a single signature. No pending state needed.
Rationale: Mode 0 is the general-purpose 2FA model. Mode 1 is a simpler alternative for users who fully trust their cold wallets and want a clean permission split without asynchronous flows.
Status: DECIDED | Date: 2026-02-17
Decision: The contract maintains two signer lists:
| List | Name | Can do |
|---|---|---|
signers_whitelist |
Whitelist signers / easy signers | Execute whitelisted Txs immediately |
signers_full |
Full signers / hard signers | Depends on policy mode |
A key may appear in both lists.
Rationale: The two-list model supports both policy modes cleanly and maps directly to the real-world roles: daily-use hot keys vs. secure cold keys.
Status: DECIDED | Date: 2026-02-17
Decision: If a transaction contains multiple calls and any single call does not match a whitelist rule, the entire transaction is treated as non-whitelisted.
Rationale: Anything else would allow an attacker to bundle a malicious call alongside whitelisted ones to bypass the multisig requirement.
Status: DECIDED | Date: 2026-02-17
Decision: Replay protection relies on the Starknet protocol-level nonce. No additional replay-prevention mechanism is needed in the contract.
Rationale: The nonce is part of the transaction hash, enforced by the
protocol, and cannot be reused. This makes each tx_hash (used as the key in
the pending signature map) unique to a specific transaction instance.
Status: DECIDED | Date: 2026-02-17
Decision: When a non-whitelisted transaction does not have enough signers,
the contract emits a TransactionPending event containing the full calldata.
Rationale: Other signers need to know (a) that a transaction is waiting for their signature and (b) exactly what the transaction does, so they can review it before signing. The event carries the full calldata so signers (or their client software) can reconstruct and send the exact same transaction.
Status: DECIDED | Date: 2026-02-17
Decision: REOPENED — the original decisions D-001, D-002, D-006, and D-007 are superseded by this decision. The coordination model is under revision.
Problem discovered: The original asynchronous on-chain multisig design (D-001) conflicts with how Starknet account abstraction actually works. Three constraints make the on-chain pending approach problematic:
Constraint 1: __validate__ is read-only.
The Starknet sequencer enforces that __validate__ cannot write to storage or
emit events. It can only return valid/invalid. This means all routing logic
(whitelist check, signature storage, event emission) must move to __execute__.
The contract's __execute__ would then sometimes execute calls, and sometimes
just store a signature and return — which is an unusual and gas-costly pattern.
Constraint 2: Nonce is consumed per transaction. Each signer's submission is a separate Starknet transaction that consumes its own protocol-level nonce. Signer A sends at nonce N → nonce becomes N+1. Signer B must send at nonce N+1. The two transactions have different nonces, different signatures, and therefore different transaction hashes. They are not "the same transaction" at the protocol level.
Constraint 3: tx_hash cannot be a shared key.
Since each signer produces a different tx_hash, the pending signature map
cannot be keyed by tx_hash as originally designed. The contract would need a
contract-internal identifier (e.g. a hash of just the calls array) to link
the two submissions. This introduces replay-protection challenges: the same calls
submitted at different times would produce the same internal ID, requiring
additional sequencing or expiry mechanisms.
Two approaches under consideration:
Signer A sends Tx (nonce N)
│
▼
__validate__: verify A is a known signer → VALID
__execute__:
├── check whitelist → not whitelisted
├── compute pending_id = hash(calls) ← NOT tx_hash
├── store pending_sigs[pending_id][A] = true
├── is_enough_signers(pending_id)? → no
└── emit TransactionPending { pending_id, calls, ... }
Signer B sees event, sends Tx with same calls (nonce N+1)
│
▼
__validate__: verify B is a known signer → VALID
__execute__:
├── check whitelist → not whitelisted
├── compute pending_id = hash(calls) ← same ID as above
├── store pending_sigs[pending_id][B] = true
├── is_enough_signers(pending_id)? → yes
└── execute the actual calls
| Pros | Cons |
|---|---|
| Fully on-chain, trustless | 2 on-chain transactions (double gas) |
| No off-chain infrastructure needed | __execute__ has dual behavior (store or execute) |
| Event-driven notification built in | Needs internal pending_id + replay protection |
__validate__ is underutilized (can only check "known signer?") |
|
| Contract complexity is high |
Signer A signs tx_hash off-chain (does NOT submit)
│
▼
Off-chain relay / service / P2P
(holds A's signature, notifies B)
│
▼
Signer B reviews, signs the SAME tx_hash off-chain
│
▼
Either signer submits Tx with signature = [A_r, A_s, B_r, B_s]
│
▼
__validate__:
├── check whitelist
├── whitelisted? → verify 1 sig (from signers_whitelist)
└── not whitelisted? → verify 2 sigs (both must be known signers)
__execute__:
└── execute the actual calls (standard behavior)
| Pros | Cons |
|---|---|
| 1 on-chain transaction (half the gas) | Requires off-chain coordination layer |
__validate__ does the real work (normal AA) |
Off-chain service is a dependency |
__execute__ is simple (just executes calls) |
Notification is off-chain, not an event |
| No pending state in contract | Signers must coordinate before submission |
| No internal replay issues | Off-chain layer must be built/maintained |
| Low contract complexity |
Key difference: In Option A, the coordination is trustless and on-chain but expensive and complex. In Option B, the coordination is off-chain but the contract is simpler, cheaper, and follows standard AA patterns. The off-chain relay has no privilege — it only passes messages. If it goes down, signers can coordinate manually (e.g. share signatures via any channel).
Impact: This decision affects the entire contract architecture — whether
__validate__ or __execute__ is the brain, whether pending state exists in
storage, whether events are the notification mechanism, and whether an off-chain
service is required.
Blocked by: Team discussion. Both options are viable. The team should weigh trustlessness vs. simplicity and gas cost.
The following decisions were made under the original on-chain async model. They are preserved for context but are not in effect until D-008 is resolved. If Option A is chosen, they may be reinstated (with modifications for
pending_idinstead oftx_hash). If Option B is chosen, D-001, D-006, and D-007 are replaced entirely and D-002 becomes irrelevant.
| ID | Title | Why superseded |
|---|---|---|
| D-001 | Asynchronous Multisig Over Synchronous | Depends on D-008 outcome |
| D-002 | Unified Code Path (No Propose/Approve Split) | Only relevant if Option A is chosen |
| D-006 | Replay Protection via Nonce | tx_hash can't be used as pending key; needs redesign if Option A |
| D-007 | Event Emission for Pending Transactions | Only relevant if Option A is chosen |
Status: UNDECIDED
Question: What happens if a signer's key is lost or compromised?
Options under consideration:
| Option | Description | Pros | Cons |
|---|---|---|---|
| Time-locked escape | A signer triggers an escape. The other signer can cancel within N days. After timeout, the initiator can act alone. | Recoverable | Compromised key gets a window to act |
| Social recovery | A separate set of recovery guardians can replace keys | Flexible | Adds a whole new trust layer |
| No escape | Loss is permanent; user can still use whitelisted ops | Simplest, most secure | Unrecoverable if no whitelist covers needed ops |
Blocked by: Team discussion needed on acceptable risk trade-offs.
Status: UNDECIDED
Question: How should the max_amount field in WhitelistRule interpret
transaction calldata?
Options:
- Fixed position: Always read amount from a specific calldata index (e.g. index 2). Simple but breaks for many functions.
- Per-selector schema: Store the calldata offset per selector. Accurate but complex.
- Defer to v2: Ship v1 with
(to, selector)matching only. Add amount inspection later.
Impact: Affects the WhitelistRule struct and the matching logic.
Status: UNDECIDED
Question: Should whitelist rules support daily spending limits or rate limits on single-signature operations?
Impact: Requires per-rule cumulative tracking in storage, timestamp comparisons, and adds gas cost to every whitelisted transaction.
Status: UNDECIDED
Question: Should WhitelistRule include an expiry: u64 timestamp field so
rules automatically become inactive after a set time?
Impact: One additional storage field per rule, one extra comparison in the matching loop. Enables time-bounded whitelisting (e.g. "allow this dapp for 30 days").
Status: UNDECIDED
Question: What is the signer threshold for is_enough_signers() in
threshold mode?
Options:
- Fixed at 2-of-N
- Configurable at deployment (K-of-N)
- Configurable per-rule (different thresholds for different transaction types)
Impact: Affects storage, constructor parameters, and is_enough_signers()
logic.
Status: UNDECIDED
Question: Can pending transactions expire or be cancelled?
Sub-questions:
- Should there be a TTL (time-to-live) after which a pending Tx is discarded?
- Can the original signer cancel a pending Tx?
- Can any signer cancel?
- What happens to the stored signatures when a Tx expires?
Impact: Affects storage cleanup, gas costs, and the pending Tx lifecycle.
Status: UNDECIDED
Question: How are the most sensitive operations authorized?
Operations in scope:
- Adding / removing signers from either list
- Adding / removing whitelist rules
- Changing the policy mode
Sub-questions:
- Should admin operations always require full multisig (all signers)?
- Should admin selectors be explicitly excluded from whitelisting (so they can never be single-sig)?
- Who can modify the signer lists — only existing full signers?
Impact: Critical for security. A compromised easy-signer must not be able to escalate their own permissions.
Status: DEFERRED | Target: v2
Description: Temporary scoped keys that can execute a limited set of operations for a limited time (e.g. "this dapp key can call swap() for 1 hour").
Why deferred: The WhitelistRule struct may be reusable as a permission
template for session keys, but the feature adds significant complexity. The core
2FA mechanism should be solid first.
Status: DEFERRED | Target: v2
Description: Implement isValidSignature (SRC-6 / SNIP-6) for off-chain
signature verification by dapps, and SRC-5 introspection.
Why deferred: Not required for core functionality. Important for dapp ecosystem compatibility.
Status: DEFERRED | Target: TBD
Description: Should the contract be upgradeable (via a proxy pattern)?
Considerations:
- If yes: upgrade operations must require the highest authorization level
- If no: the contract is immutable and users must migrate to a new deployment for bug fixes or new features
Why deferred: Requires a deliberate stance on trust assumptions. Should be decided before mainnet deployment but does not block initial development.