Docs Técnicas
Multisig Vault
A treasury that requires M-of-N signer approvals before any transfer executes. Standalone contract — no composition.
A treasury that requires M-of-N signer approvals before any transfer executes. Standalone contract — no composition.
Use this for shared custody of funds where no single party should be able to unilaterally move money: joint accounts, DAO treasuries, institutional escrows.
Source
contract MultisigVault:
storage:
signers: Vec[Address, 8]
signer_count: u64 = 0
threshold: u64 = 2
vault_balance: u128 = 0
# nonce-based double-approval guard
proposal_nonce: u64 = 0
last_approved_nonce: Map[Address, u64, 8]
pending_active: bool = False
pending_to: Address
pending_amount: u128 = 0
pending_approval_count: u64 = 0
pending_executed: bool = False
@construct
def initialize(threshold_count: u64):
require(threshold_count > 0, "threshold_must_be_positive")
self.signers = vec_push(self.signers, ctx.caller)
self.signer_count = 1
self.threshold = threshold_count
@tx
def add_signer(new_signer: Address):
require(vec_contains(self.signers, ctx.caller), "not_a_signer")
require(vec_contains(self.signers, new_signer) == False, "already_a_signer")
require(self.signer_count < 8, "max_signers_reached")
self.signers = vec_push(self.signers, new_signer)
self.signer_count = self.signer_count + 1
@tx
def deposit(amount: u128):
require(amount > 0, "amount_must_be_positive")
self.vault_balance = self.vault_balance + amount
@tx
def propose(to: Address, amount: u128):
require(vec_contains(self.signers, ctx.caller), "not_a_signer")
require(self.pending_active == False, "proposal_already_pending")
require(amount > 0, "amount_must_be_positive")
require(amount <= self.vault_balance, "insufficient_vault_balance")
self.proposal_nonce = self.proposal_nonce + 1
self.pending_to = to
self.pending_amount = amount
self.pending_active = True
self.pending_executed = False
# proposer auto-approves
self.last_approved_nonce = map_set(self.last_approved_nonce, ctx.caller, self.proposal_nonce)
self.pending_approval_count = 1
@tx
def approve():
require(vec_contains(self.signers, ctx.caller), "not_a_signer")
require(self.pending_active, "no_pending_proposal")
require(self.pending_executed == False, "proposal_already_executed")
require(map_get(self.last_approved_nonce, ctx.caller) != self.proposal_nonce, "already_approved")
self.last_approved_nonce = map_set(self.last_approved_nonce, ctx.caller, self.proposal_nonce)
self.pending_approval_count = self.pending_approval_count + 1
@tx
def execute():
require(vec_contains(self.signers, ctx.caller), "not_a_signer")
require(self.pending_active, "no_pending_proposal")
require(self.pending_executed == False, "proposal_already_executed")
require(self.pending_approval_count >= self.threshold, "threshold_not_reached")
require(self.pending_amount <= self.vault_balance, "insufficient_vault_balance")
self.vault_balance = self.vault_balance - self.pending_amount
self.pending_executed = True
self.pending_active = False
@tx
def revoke():
require(vec_contains(self.signers, ctx.caller), "not_a_signer")
require(self.pending_active, "no_pending_proposal")
require(self.pending_executed == False, "proposal_already_executed")
self.pending_active = False
self.pending_amount = 0
self.pending_approval_count = 0
@view
def vault_balance_of() -> u128:
return self.vault_balance
@view
def proposal_active() -> bool:
return self.pending_active
@view
def approval_count() -> u64:
return self.pending_approval_count
@view
def has_approved(signer: Address) -> bool:
return map_get(self.last_approved_nonce, signer) == self.proposal_nonce
@view
def is_signer(account: Address) -> bool:
return vec_contains(self.signers, account)Lifecycle
# 1 — setup (alice calls initialize, then adds bob)
initialize(2) # threshold = 2; alice becomes signer #1
add_signer(bob) # alice adds bob as signer #2
# 2 — deposit
deposit(1_000_000) # any caller can deposit
# 3 — proposal + approvals
propose(recipient, 300_000) # alice proposes; auto-approves (count = 1)
approve() # bob approves (count = 2 ≥ threshold)
# 4 — execute
execute() # any signer executes; vault_balance -= 300_000
# 5 — new proposal resets approval tracking automatically
propose(other, 100_000) # nonce increments; previous approvals are staleNonce-Based Double-Approval Guard
TREA collections are functional — there is no clear or reset operation on a Map. A naïve approval flag per signer would carry over from one proposal to the next.
The solution uses a monotonically incrementing proposal_nonce:
last_approved_nonce[signer]records which nonce the signer last approved.Mapfields default to0; the nonce starts at1on the firstproposecall.- Check:
map_get(last_approved_nonce, caller) != proposal_nonce— true only if the signer has not yet approved the *current* proposal. - After approval:
map_set(last_approved_nonce, caller, proposal_nonce)marks the signer as having approved.
When a new proposal is created, proposal_nonce increments and all previous last_approved_nonce entries become stale — no reset needed.
Limits
| Parameter | Value | |-----------|-------| | Max signers | 8 | | Max pending proposals | 1 (serial) | | Min threshold | 1 |
Only one proposal can be active at a time. A second propose call while one is pending rejects with proposal_already_pending. Use revoke to cancel the active proposal before submitting a new one.