Move.

Допис

Діліться своїми знаннями.

Michael Ace.
Aug 29, 2025
Питання та відповіді експертів

How to build a threshold multisig wallet

How to build a threshold multisig wallet in Move with on-chain policy-based invocation and safe key rotation?

  • Move
  • Smart Contract
  • Move Script
1
1
Поділитися
Коментарі
.

Відповіді

1
Kurosakisui.
Aug 29 2025, 22:27

Multisig wallets must allow N-of-M signatures to authorize actions, support on-chain policy changes (change threshold, add/remove signer), handle key rotation without losing access, and guarantee no double-spend or unauthorized execution.

Strategy

  1. Represent each signer by an SignerId resource stored in the wallet under a unique index; store a Policy resource with threshold and signer list.
  2. Use off-chain signatures (or on-chain approvals expressed as separate transactions) but verify them on-chain by collecting approvals as Approval objects. Alternatively (and simpler in Move), accept Approval objects that are linear resources created by each owner and submitted to the wallet; wallet consumes them to perform the action.
  3. Make policy-update a two-step operation: propose -> commit, where commit requires a threshold of approvals. This prevents a single admin from changing policy unilaterally.
  4. Support key rotation by allowing signer removal+addition in a single commit operation, but require old signers to sign the rotation (or use a trusted guardian).

Why this works

  • Approvals as linear resources mean an approval cannot be replayed or reused.
  • Two-step proposals and commit enforce explicit human consent.
  • Policy stored on-chain allows provable checks and easier auditing.

Working-style Move code

module 0xA::multisig {
    use std::signer;
    use std::vector;

    /// Each signer has an entry; address field points to owner account doing approval creation.
    struct SignerEntry has copy, drop, store { id: u8, owner: address }

    /// Policy: threshold + dynamic signer list
    struct Policy has key {
        threshold: u8,
        signers: vector<SignerEntry>,
        next_proposal_id: u64
    }

    /// A Proposal stores the action to be executed (call data is abstracted as bytes).
    struct Proposal has key {
        id: u64,
        proposer: address,
        action: vector<u8>,   // opaque action payload — in practice encode module + fn + args
        approvals: vector<address>, // addresses who approved
        executed: bool
    }

    public fun init(admin: &signer, initial_signers: vector<address>, threshold: u8) {
        assert!(vector::length(&initial_signers) as u8 >= threshold, 1);
        let signer_list = vector::empty<SignerEntry>();
        let mut i: u8 = 0;
        while (i < (vector::length(&initial_signers) as u8)) {
            vector::push_back(&mut signer_list, SignerEntry { id: i, owner: *vector::borrow(&initial_signers, (i as u64)) });
            i = i + 1;
        }
        move_to(admin, Policy { threshold, signers: signer_list, next_proposal_id: 0 });
    }

    // Anyone (usually signer) creates a proposal to run an action.
    public fun propose(caller: &signer, action: vector<u8>) acquires Policy {
        let p = borrow_global_mut<Policy>(signer::address_of(caller));
        let pid = p.next_proposal_id;
        p.next_proposal_id = pid + 1;
        let prop = Proposal { id: pid, proposer: signer::address_of(caller), action, approvals: vector::empty(), executed: false };
        // store proposal under proposer account for simplicity
        move_to(caller, prop);
    }

    // Approver calls approve by creating a signed Approval object (we represent as adding address)
    public fun approve(approver: &signer, proposer_addr: address, proposal_id: u64) acquires Proposal, Policy {
        // fetch policy to verify approver is an authorized signer
        let pol_owner = signer::address_of(approver); // adjust: policy stored at a canonical admin; simplified here
        let pol = borrow_global<Policy>(pol_owner);
        assert!(is_authorized(&pol.signers, signer::address_of(approver)), 2);

        // load proposal from proposer address (simplified)
        let prop = borrow_global_mut<Proposal>(proposer_addr);
        assert!(prop.id == proposal_id, 3);
        assert!(!prop.executed, 4);

        // Ensure not already approved by this approver
        assert!(!addr_in_vec(&prop.approvals, signer::address_of(approver)), 5);
        vector::push_back(&mut prop.approvals, signer::address_of(approver));
    }

    // Execute proposal if approvals >= threshold
    public fun execute(proposer: &signer, proposal_id: u64) acquires Proposal, Policy {
        let pol = borrow_global<Policy>(signer::address_of(proposer));
        let prop = borrow_global_mut<Proposal>(signer::address_of(proposer));
        assert!(prop.id == proposal_id && !prop.executed, 6);
        let num = vector::length(&prop.approvals) as u8;
        assert!(num >= pol.threshold, 7);

        // Here, interpret prop.action and call the intended module/function.
        // For demo we mark executed true and drop approvals
        prop.executed = true;
        // In production: decode action, call targeted module via pre-agreed interface, or have modules pull authorized actions.
    }

    fun is_authorized(signers: &vector<SignerEntry>, addr: address): bool {
        let i = 0;
        while (i < vector::length(signers)) {
            if ((*vector::borrow(signers, i)).owner == addr) { return true }
            i = i + 1;
        }
        false
    }

    fun addr_in_vec(vec: &vector<address>, a: address): bool {
        let i = 0;
        while (i < vector::length(vec)) {
            if (*vector::borrow(vec, i) == a) { return true }
            i = i + 1;
        }
        false
    }
}

Key properties & rotation

  • To rotate keys, propose an action whose action payload is a policy_update that includes add/remove signer ops. That proposal itself is subject to the same threshold, so rotation requires signers’ consent.
  • For emergency recovery: include a guardian role stored separately that can trigger a special recovery flow requiring a stronger threshold + time delay.
  • For auditability: emit events (off-chain) for proposals, approvals, and executions.

2
Коментарі
.

Ви знаєте відповідь?

Будь ласка, увійдіть та поділіться нею.

Move is an executable bytecode language used to implement custom transactions and smart contracts.

242Пости541Відповіді
Sui.X.Peera.

Зароби свою частку з 1000 Sui

Заробляй бали репутації та отримуй винагороди за допомогу в розвитку спільноти Sui.

Кампанія винагородСерпень