Voltar para Documentação

Docs Técnicas

Multisig Vault

A treasury that requires M-of-N signer approvals before any transfer executes. Standalone contract — no composition.

O conteúdo abaixo vem das fontes técnicas do repositório e é prerenderizado no site para leitura direta por pessoas, crawlers e agentes.

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

python
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 stale

Nonce-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.
  • Map fields default to 0; the nonce starts at 1 on the first propose call.
  • 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.