Designing the Contract: Multisignature Wallet
This chapter walks through the design of a real-world smart contract: a Multisignature Wallet for TON blockchain. We'll analyze the original Solidity implementation and understand its architecture before implementing it in Ursus.
What is a Multisignature Wallet?
A multisignature (multisig) wallet is a smart contract that requires multiple parties to approve a transaction before it can be executed. This provides enhanced security compared to single-signature wallets.
Key Concepts
Custodians: Authorized parties who can propose and approve transactions. Each custodian has a unique public key.
Confirmations: Number of custodian approvals required to execute a transaction. Configurable (e.g., 2-of-3, 3-of-5).
Transactions: Proposed transfers that wait for confirmations before execution.
Expiration: Transactions have a lifetime (1 hour) after which they are automatically removed.
Requirements Analysis
Functional Requirements
-
Multi-party Authorization
- Support up to 32 custodians
- Configurable confirmation threshold (M-of-N)
- Each custodian can propose transactions
- Each custodian can confirm pending transactions
-
Transaction Management
- Create new transactions
- Confirm pending transactions
- Automatic execution when threshold reached
- Automatic cleanup of expired transactions
-
Security Features
- Prevent double-confirmation by same custodian
- Limit pending transactions per custodian (max 5)
- Minimum transfer value check
- Public key verification
-
Special Features
- Support for ECC tokens (TON-specific)
- Direct send for single-custodian wallets
- Configurable message flags
- Payload support for complex messages
Security Requirements
-
Access Control
- Only custodians can propose transactions
- Only custodians can confirm transactions
- Constructor requires deployer's public key
-
State Integrity
- Prevent replay attacks (unique transaction IDs)
- Prevent race conditions (atomic confirmations)
- Prevent resource exhaustion (limits on pending transactions)
-
Error Handling
- Clear error codes for all failure cases
- Require statements for all preconditions
- Safe arithmetic (no overflows)
Performance Requirements
-
Gas Optimization
- Efficient storage layout
- Batch cleanup of expired transactions
- Inline helper functions
- Minimal storage writes
-
Scalability
- Support up to 32 custodians
- Handle up to 5 pending transactions per custodian
- Configurable cleanup batch size
Architecture Design
Contract Structure
MultisigWallet
├── State Variables
│ ├── m_ownerKey (deployer's public key)
│ ├── m_custodians (mapping: pubkey → index)
│ ├── m_custodianCount (total custodians)
│ ├── m_defaultRequiredConfirmations (M in M-of-N)
│ ├── m_transactions (mapping: id → Transaction)
│ ├── m_requestsMask (pending tx count per custodian)
│ ├── _min_value (minimum transfer amount)
│ └── _max_cleanup_txns (cleanup batch size)
│
├── Public Functions
│ ├── constructor (initialize custodians)
│ ├── submitTransaction (create new transaction)
│ ├── confirmTransaction (approve pending transaction)
│ ├── sendTransaction (direct send for 1 custodian)
│ ├── acceptTransfer (receive incoming funds)
│ ├── setMinValue (configure minimum)
│ └── setMaxCleanupTxns (configure cleanup)
│
└── Internal Functions
├── _initialize (setup custodians)
├── _findCustodian (verify custodian)
├── _confirmTransaction (process confirmation)
├── _removeExpiredTransactions (cleanup)
├── _generateId (create unique ID)
├── _getExpirationBound (calculate expiration)
├── _getSendFlags (determine message flags)
└── Bit manipulation helpers
Data Structures
Transaction Record
struct Transaction {
uint64 id; // Unique identifier
uint32 confirmationsMask; // Bitfield of confirmations
uint8 signsRequired; // Threshold (M in M-of-N)
uint8 signsReceived; // Current confirmations
uint256 creator; // Proposer's public key
uint8 index; // Proposer's custodian index
address dest; // Destination address
uint128 value; // Transfer amount
mapping(uint32 => varuint32) cc; // ECC tokens
uint16 sendFlags; // Message flags
TvmCell payload; // Message body
bool bounce; // Bounce flag
}
Design Rationale:
id: Combines timestamp and logical time for uniquenessconfirmationsMask: Efficient storage using bitfield (32 bits for 32 custodians)signsRequired/signsReceived: Track progress toward thresholdcreator/index: Track who proposed the transactiondest/value/payload: Standard transfer parameterscc: TON-specific ECC token supportsendFlags/bounce: Message delivery options
Contract State
uint256 m_ownerKey; // Deployer's key
uint256 m_requestsMask; // Pending tx count (8 bits per custodian)
mapping(uint64 => Transaction) m_transactions; // Active transactions
mapping(uint256 => uint8) m_custodians; // Custodian registry
uint8 m_custodianCount; // Total custodians
uint8 m_defaultRequiredConfirmations; // Default threshold
uint128 _min_value; // Minimum transfer
uint _max_cleanup_txns; // Cleanup batch size
Design Rationale:
m_requestsMask: Compact storage - 8 bits per custodian in single uint256m_transactions: Mapping for O(1) access by IDm_custodians: Mapping for O(1) custodian lookup- Separate count and threshold for flexibility
Constants
uint8 constant MAX_QUEUED_REQUESTS = 5; // Per-custodian limit
uint64 constant EXPIRATION_TIME = 3601; // 1 hour + 1 second
uint8 constant MAX_CUSTODIAN_COUNT = 32; // Maximum custodians
uint8 constant FLAG_PAY_FWD_FEE_FROM_BALANCE = 1;
uint8 constant FLAG_SEND_ALL_REMAINING = 128;
Design Rationale:
MAX_QUEUED_REQUESTS: Prevent spam from single custodianEXPIRATION_TIME: Balance between usability and cleanupMAX_CUSTODIAN_COUNT: Limited by confirmationsMask (32 bits)- Flags: TON-specific message delivery options
State Machine Design
Transaction Lifecycle
[Created] ──────────────────────────────────────────────┐
│ │
│ submitTransaction() │
↓ │
[Pending] ←──────────────────────────────┐ │
│ │ │
│ confirmTransaction() │ │
↓ │ │
[Confirmed] (signsReceived++) │ │
│ │ │
│ signsReceived < signsRequired │ │
├────────────────────────────────────┘ │
│ │
│ signsReceived >= signsRequired │
↓ │
[Executed] ──> Transfer sent │
│ │
↓ │
[Deleted] │
│
↑ │
│ Time > EXPIRATION_TIME │
└────────────────────────────────────────────────────┘
State Transitions
-
Submit: Custodian creates new transaction
- Preconditions: Valid custodian, value >= min_value, pending count < max
- Effects: Create transaction, increment request mask
- Special case: If M=1, execute immediately
-
Confirm: Custodian approves pending transaction
- Preconditions: Transaction exists, not expired, not already confirmed by this custodian
- Effects: Set confirmation bit, increment signsReceived
- Special case: If threshold reached, execute and delete
-
Execute: Transaction reaches threshold
- Preconditions: signsReceived >= signsRequired
- Effects: Send transfer, decrement request mask, delete transaction
-
Expire: Transaction exceeds lifetime
- Preconditions: Current time - creation time > EXPIRATION_TIME
- Effects: Delete transaction, decrement request mask
Workflow Examples
Example 1: 2-of-3 Multisig
Setup: 3 custodians (Alice, Bob, Carol), threshold = 2
Scenario: Alice wants to send 100 tokens to Dave
-
Alice calls submitTransaction(Dave, 100, ...)
- Transaction created with ID = 12345
- confirmationsMask = 0b001 (Alice's bit set)
- signsReceived = 1
- Transaction stored in m_transactions[12345]
-
Bob calls confirmTransaction(12345)
- confirmationsMask = 0b011 (Alice + Bob)
- signsReceived = 2
- Threshold reached (2 >= 2)
- Transfer executed to Dave
- Transaction deleted
-
Carol's confirmation not needed (threshold already reached)
Example 2: Single Custodian (Fast Path)
Setup: 1 custodian (Alice), threshold = 1
Scenario: Alice wants to send 100 tokens to Dave
- Alice calls submitTransaction(Dave, 100, ...)
- Detects m_defaultRequiredConfirmations == 1
- Executes transfer immediately
- Returns transactionId = 0 (no transaction stored)
- No confirmation needed
Example 3: Transaction Expiration
Setup: 3 custodians, threshold = 2
Scenario: Transaction expires before reaching threshold
-
Alice submits transaction at time T
- Transaction ID = (T << 32) | logicaltime
- Stored in m_transactions
-
Time passes: T + 3601 seconds
- Transaction now expired
-
Bob calls confirmTransaction() or submitTransaction()
- _removeExpiredTransactions() called
- Expired transaction detected and deleted
- Bob's operation proceeds normally
Security Considerations
Attack Vectors and Mitigations
-
Replay Attacks
- Risk: Reuse old transaction signatures
- Mitigation: Unique IDs based on timestamp + logical time
-
Double Confirmation
- Risk: Custodian confirms twice
- Mitigation: Check confirmationsMask before setting bit
-
Resource Exhaustion
- Risk: Spam with many pending transactions
- Mitigation: MAX_QUEUED_REQUESTS limit per custodian
-
Unauthorized Access
- Risk: Non-custodian creates transactions
- Mitigation: _findCustodian() requires valid public key
-
Integer Overflow
- Risk: Arithmetic overflow in calculations
- Mitigation:
pragma ignoreIntOverflow+ safe operations
Error Codes
100 - Message sender is not a custodian
101 - Zero owner (invalid public key)
102 - Transaction does not exist
103 - Already confirmed by this custodian
107 - Input value too low (< _min_value)
108 - Wallet should have only one custodian (for sendTransaction)
110 - Too many custodians (> MAX_CUSTODIAN_COUNT)
113 - Too many requests for one custodian (> MAX_QUEUED_REQUESTS)
117 - Invalid number of custodians (0 or > MAX)
123 - Need at least 1 required confirmation
Design Decisions and Trade-offs
1. Bitfield vs Array for Confirmations
Decision: Use uint32 bitfield for confirmationsMask
Rationale:
- ✅ Compact storage (32 bits vs 32 bytes)
- ✅ O(1) check and set operations
- ✅ Atomic updates
- ❌ Limited to 32 custodians
- ❌ Requires bit manipulation
Alternative: Array of confirmed custodian indices
- ✅ Unlimited custodians
- ❌ Higher gas costs
- ❌ More complex logic
2. Automatic Cleanup vs Manual Cleanup
Decision: Automatic cleanup on submit/confirm
Rationale:
- ✅ No expired transactions accumulate
- ✅ No separate cleanup transaction needed
- ❌ Adds gas cost to submit/confirm
- ❌ Unpredictable gas usage
Mitigation: Configurable _max_cleanup_txns to limit gas
3. Immediate Execution vs Always Queue
Decision: Immediate execution for M=1 case
Rationale:
- ✅ Lower gas for single-custodian wallets
- ✅ Simpler UX (no confirmation step)
- ❌ Different code paths
- ❌ More complex testing
4. Request Mask Encoding
Decision: 8 bits per custodian in single uint256
Rationale:
- ✅ Compact storage (32 custodians in 256 bits)
- ✅ Single storage slot
- ❌ Complex bit manipulation
- ❌ Limited to 255 pending per custodian
Next Steps
Now that we understand the contract design, we can:
- Specification - Write formal specifications for contract behavior
- Direct Proofs - Prove correctness properties
- Implementation - Implement in Ursus (when code generation is updated)
See Also
- Contract Structure - Ursus contract syntax
- Types and Structures - Defining records
- Functions - Function syntax
- Verification Principles - Verification approach