Publicación
Comparte tu conocimiento.
Aug 29, 2025
P&R expertos
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
Cuota
Comentarios
Respuestas
1Kurosakisui400
Aug 29 2025, 22:27Multisig 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
- Represent each signer by an
SignerId
resource stored in the wallet under a unique index; store aPolicy
resource with threshold and signer list. - 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), acceptApproval
objects that are linear resources created by each owner and submitted to the wallet; wallet consumes them to perform the action. - 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.
- 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 apolicy_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
Comentarios
Sabes la respuesta?
Inicie sesión y compártalo.
Move is an executable bytecode language used to implement custom transactions and smart contracts.
242Publicaciones541Respuestas
Gana tu parte de 1000 Sui
Gana puntos de reputación y obtén recompensas por ayudar a crecer a la comunidad de Sui.

Campaña de RecompensasAgosto
- ... SUIMatthardy+2095
- ... SUIacher+1666
- ... SUIjakodelarin+1092
- ... SUIChubbycheeks +1081
- ... SUITucker+1047
- ... SUIKurosakisui+1034
- ... SUIzerus+890
Publicaciones de recompensas