This document describes the implementation of threshold-based multi-signature (M-of-N) admin approval for the Invoice Liquidity Network contract. This feature enables secure governance by requiring multiple authorized signers to approve critical operations such as pausing the contract.
1. Multisig Module (multisig.rs)
The module defines the data structures and helper functions for multisig operations:
Key Types:
-
MultisigAdmin: Configuration holding the list of authorized signers and the required thresholdpub struct MultisigAdmin { pub signers: Vec<Address>, pub threshold: u32, }
-
AdminAction: Enumeration of actions requiring multisig approvalPause: Emergency stop of contractUnpause: Resume operationsRemoveToken(Address): Remove token from approved listSetFeeRate(u32): Change fee rateSetMaxDiscount(u32): Set maximum discount rateUpdateMultisig { ... }: Update multisig configuration itself
-
MultisigProposal: Represents a pending or executed proposalpub struct MultisigProposal { pub id: u64, // Unique proposal ID pub action: AdminAction, // The proposed action pub signers_approved: Vec<Address>, // Signers who approved pub state: ProposalState, // Pending/Executed/Expired pub expires_at: u64, // Ledger sequence expiration }
-
ProposalState: Three-state lifecyclePending: Awaiting signaturesExecuted: Successfully executedExpired: Exceeded the execution window
Constants:
MULTISIG_WINDOW_LEDGERS = 17_280: Approximately 24 hours. Proposals expire if not executed within this window.
Helper Functions:
is_signer(): Check if address is in signer listhas_signed(): Check if signer already approved a proposalthreshold_reached(): Check if approval threshold is metis_expired(): Check if proposal has expired
2. Contract Functions (lib.rs)
Public contract functions for multisig operations:
Initialize multi-signature admin functionality.
-
Parameters:
signers: Vec of addresses authorized to signthreshold: Number of signatures required (must be ≤signers.len())
-
Returns:
Ok(())on successErr(InvalidMultisigConfig)if validation fails
-
Access: Admin only
-
Example:
let signers = [addr1, addr2, addr3] initialize_multisig_admin(env, signers, 2) // 2-of-3 multisig
Create a new pause/unpause proposal.
-
Parameters:
proposer: Must be an authorized signer
-
Returns:
Ok(proposal_id)on successErr(NotAuthorizedSigner)if proposer is not in signer list
-
Access: Multi-sig authorized signer
Add signer's approval to an existing proposal.
-
Parameters:
signer: Must be an authorized signerproposal_id: ID of the proposal to sign
-
Returns:
Ok(())on successErr(NotAuthorizedSigner)if not an authorized signerErr(AlreadySigned)if signer already approved this proposalErr(ProposalNotFound)if proposal doesn't exist
-
Access: Multi-sig authorized signer
Execute a proposal that has reached the threshold.
-
Parameters:
executor: Must be an authorized signer (triggers execution but doesn't need to be new signer)proposal_id: ID of the proposal to execute
-
Returns:
Ok(())on successErr(ThresholdNotReached)if not enough signaturesErr(ProposalNotFound)if proposal doesn't existErr(ProposalAlreadyExecuted)if already executedErr(ProposalExpired)if outside execution window
-
Access: Multi-sig authorized signer
3. Storage Layer (storage.rs)
Helper functions for persistent storage of multisig data:
pub fn get_multisig_admin(env: &Env) -> Option<MultisigAdmin>
pub fn set_multisig_admin(env: &Env, admin: &MultisigAdmin)
pub fn get_multisig_proposal(env: &Env, proposal_id: u64) -> Option<MultisigProposal>
pub fn save_multisig_proposal(env: &Env, proposal: &MultisigProposal)
pub fn get_next_proposal_id(env: &Env) -> u64
pub fn increment_proposal_id(env: &Env)Uses the DataKey enum for type-safe storage:
DataKey::MultisigAdmin: Instance storage for admin configDataKey::MultisigProposalCounter: Instance storage for proposal ID counterDataKey::MultisigProposal(u64): Persistent storage for proposals by ID
// Step 1: Initialize with 3 signers, require 2 approvals
let signers = vec![alice, bob, carol];
contract.initialize_multisig_admin(&signers, 2);
// Step 2: Alice proposes a pause
let proposal_id = contract.propose_pause(&alice)?;
// Step 3: Bob signs the proposal
contract.sign_proposal(&bob, proposal_id)?;
// Step 4: Once threshold (2) is reached, anyone can execute
contract.execute_proposal(&carol, proposal_id)?;
// Contract is now paused| Error | Code | Condition |
|---|---|---|
NotAuthorizedSigner |
40 | Caller is not in the signer list |
ProposalNotFound |
41 | Proposal doesn't exist |
AlreadySigned |
42 | Signer has already approved this proposal |
ProposalExpired |
43 | Outside the execution window (17,280 ledgers) |
ThresholdNotReached |
44 | Not enough signatures collected |
ProposalAlreadyExecuted |
45 | Proposal was already executed |
InvalidMultisigConfig |
46 | Threshold > signer count or threshold is 0 |
Comprehensive test suite in tests_multisig_admin.rs:
Test Coverage:
- ✅ Initialization: 2-of-3 and 3-of-3 threshold setup
- ✅ Proposal Creation: Creating pause/unpause proposals
- ✅ Signing: Adding signatures, preventing duplicates
- ✅ Threshold Validation: Ensuring threshold is enforced
- ✅ Execution: Executing proposals when threshold is met
- ✅ Authorization: Non-signers cannot participate
- ✅ Idempotency: No re-execution of completed proposals
- ✅ Signature Order: Signatures can arrive in any order
- ✅ Config Validation: Invalid threshold configurations rejected
Sample Test:
#[test]
fn test_sign_and_execute_threshold_met() {
let t = setup_multisig();
// Setup 2-of-3 multisig
let signers = vec![t.admin1, t.admin2, t.admin3];
t.contract.initialize_multisig_admin(&signers, 2).unwrap();
// Propose pause
let proposal_id = t.contract.propose_pause(&t.admin1).unwrap();
// Collect signatures
t.contract.sign_proposal(&t.admin1, &proposal_id).unwrap();
t.contract.sign_proposal(&t.admin2, &proposal_id).unwrap();
// Execute (threshold reached)
t.contract.execute_proposal(&t.admin1, &proposal_id).unwrap();
// Verify pause is active
assert!(t.contract.is_paused());
}- Threshold Safety: Requires
threshold ≤ signer_count, preventing impossible configurations - Duplicate Prevention: Each signer can only sign a proposal once
- Expiration Window: Proposals expire after 17,280 ledgers (~24 hours) to prevent stale proposal execution
- State Transitions: Prevents re-execution and modifies state atomically
- Authorization: Only authorized signers can propose and sign
- Order Independence: Signatures can arrive in any order
- Multi-Action Batching: Support batching multiple actions in a single proposal
- Weighted Voting: Different signers with different voting weights
- Time Locks: Additional delay between execution threshold and actual execution
- Conditional Execution: Execute actions based on contract state conditions
- Signature Revocation: Allow signers to revoke their approval before execution
- Multisig Upgrades: Change signers/threshold via multisig proposal itself
The multisig admin system integrates with:
- Pause/Unpause: Critical contract state management
- Token Management: Future support for removing approved tokens
- Fee Updates: Future support for changing fee rates
- Contract Upgrades: Future support for governance-approved upgrades
- Issue #124: Multi-sig Admin (this implementation)
- Issue #48: Contract Upgrades (future multisig integration)
- Issue #95: Emergency Controls (pause/unpause via multisig)
- ✅ contracts/invoice_liquidity/src/multisig.rs - Already existed
- ✅ contracts/invoice_liquidity/src/lib.rs - Added contract functions
- ✅ contracts/invoice_liquidity/src/storage.rs - Already had helper functions
- ✅ contracts/invoice_liquidity/src/errors.rs - Already had error codes
- ✅ contracts/invoice_liquidity/src/tests_multisig_admin.rs - NEW: Comprehensive test suite
To compile the contract with multisig support:
cd contracts/invoice_liquidity
cargo build --releaseTo run tests:
cargo test tests_multisig_admin --lib- After contract deployment, call
initialize_multisig_admin()to activate multisig governance - Store the initial signer list securely
- Test the proposal workflow on testnet before mainnet deployment
- Document the multisig configuration for operational teams