Skip to content

Latest commit

 

History

History
431 lines (298 loc) · 15.2 KB

File metadata and controls

431 lines (298 loc) · 15.2 KB

Design Decisions

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

Decided

D-001: Asynchronous Multisig Over Synchronous

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.


D-002: Unified Code Path (No Propose/Approve Split)

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."


D-003: Two Policy Modes (Threshold vs Role-Based)

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.


D-004: Two Signer Lists

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.


D-005: Multicall Tainting

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.


D-006: Replay Protection via Nonce

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.


D-007: Event Emission for Pending Transactions

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.


D-008: On-Chain vs Off-Chain Signature Coordination

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:

Option A: On-Chain Pending (original intent, adapted)

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

Option B: Off-Chain Coordination (normal AA pattern)

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.


Superseded Decisions

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_id instead of tx_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

Undecided

D-009: Escape Hatch (Signer Loss / Compromise)

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.


D-010: Calldata Amount Inspection

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.


D-011: Daily / Rate Limits

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.


D-012: Rule Expiry

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").


D-013: Threshold Configuration (Mode 0)

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.


D-014: Pending Transaction Expiry / Cancellation

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.


D-015: Admin Operation Authorization

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.


Deferred

D-016: Session Keys

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.


D-017: SRC-5 / SRC-6 Compliance

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.


D-018: Upgradeability

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.