This document describes the multisig smart contract added for issue #202.
The multisig contract acts as a governance and safety layer in front of critical operations:
- contract upgrade approvals
- large outbound token payments from a shared wallet
- approvals for dispute resolution flows
The contract focuses on threshold-based approvals, clear event logs for off-chain automation, and a break-glass emergency guardian restricted to explicitly emergency-eligible operation kinds.
- Contract:
onchain/contracts/multisig/src/lib.rs - Tests:
onchain/contracts/multisig/tests/test_multisig.rs - Edge case tests:
onchain/contracts/multisig/tests/test_multisig_edge_cases.rs
initializeis one-time only and must be called by the designated owner.- A fixed signer set and default threshold are stored on-chain.
- Each operation type can have an optional threshold override. Types without an override continue to use the default threshold.
- Only configured signers can:
- propose new operations
- approve existing operations
- Operations auto-execute once
approvals >= threshold. - An optional emergency guardian can execute pending operational actions without satisfying the threshold (break-glass override), but cannot execute threshold-override changes below their current threshold.
- Large token payments are executed directly from the multisig contract balance using the Soroban token client.
Core types:
OperationKindContractUpgrade(Address, BytesN<32>)LargePayment(Address, Address, i128)as(token, to, amount)DisputeResolution(Address, u128, i128, i128)as(payroll_contract, agreement_id, pay_employee, refund_employer)SetThresholdOverride(OperationType, Option<u32>)as(operation_type, threshold);Noneremoves the override
OperationTypeContractUpgradeLargePaymentDisputeResolution
OperationStatusPending,Executed,Cancelled
Operationid,kind,creator,status,created_at,executed_at
Storage keys:
Owner: configuration ownerSigners: vector of signer addressesThreshold: required signatures countEmergencyGuardian: optional guardian addressOperationCounter: auto-incrementing idOperation(id): stored operationApprovals(id): vector of signer addresses that approvedThresholdOverride(operation_type): optional required signature count for one operation type
initialize(owner, signers, threshold, emergency_guardian)propose_operation(proposer, kind) -> operation_idapprove_operation(signer, operation_id)cancel_operation(caller, operation_id)emergency_execute(guardian, operation_id)execute_operation(caller, operation_id, expected_hash)get_operation(operation_id) -> Option<Operation>get_signers() -> Vec<Address>get_threshold() -> u32get_threshold_override(operation_type) -> Option<u32>get_effective_threshold(operation_type) -> u32get_approvals(operation_id) -> Vec<Address>
- Owner calls
initializewith signer set, threshold, and optional guardian. - Any signer can call
propose_operationto create a new operation (auto-approving as creator). - Additional signers call
approve_operationuntil the approval count meets the threshold. - When
approvals >= threshold, the contract:- executes
LargePaymentoperations by transferring tokens from its balance - marks
ContractUpgradeandDisputeResolutionoperations as executed for off-chain tooling to act on
- executes
- Alternatively, for
ContractUpgradeoperations, the final signing party can callexecute_operation(caller, operation_id, expected_hash)instead ofapprove_operation. This records their approval, re-validates the WASM hash, and triggers execution only if the hash matches and the resulting approval count meets the threshold (see ContractUpgrade Hash Validation). - Creator or owner can cancel a pending operation via
cancel_operation. - The emergency guardian can call
emergency_executeto force execution of a pending emergency-eligible operation (currently onlyDisputeResolution) in break-glass scenarios. Routine operations (LargePayment), governance changes (ContractUpgrade), and threshold-override changes (SetThresholdOverride) are rejected even when called by the guardian.
propose_operation validates the payload of certain OperationKind variants
before any state is written and before the proposer's auto-approval is
recorded. A rejected proposal leaves the contract in exactly the state it was
in before the call — no operation is stored, no event is emitted, and no
approval accumulates.
This matters because an unchecked invalid payload could accumulate approvals from multiple signers before anyone notices the mistake, resulting in wasted gas and a stale pending operation that must be manually cancelled.
OperationKind |
Field | Rule | Error |
|---|---|---|---|
LargePayment |
amount |
Must be strictly positive (amount > 0) |
MultisigError::InvalidAmount (code 2) |
LargePayment |
to |
Must not equal env.current_contract_address() |
MultisigError::SelfReferentialRecipient (code 3) |
ContractUpgrade |
target |
Must not equal env.current_contract_address() |
MultisigError::SelfReferentialRecipient (code 3) |
All other OperationKind variants (DisputeResolution, SetThresholdOverride)
have no payload constraints at proposal time.
LargePayment — zero or negative amount (InvalidAmount)
A zero-amount token transfer is a no-op; a negative amount would be a reversed transfer, which the Soroban token client rejects anyway at execution. Allowing either value to proceed to proposal means signers would approve an operation that can never execute successfully or that does nothing meaningful. Detecting this at proposal time avoids wasting approval flows on a doomed operation.
LargePayment — self-referential recipient (SelfReferentialRecipient)
A payment where the multisig contract sends tokens to itself leaves the balance unchanged and achieves nothing. It is almost certainly a configuration error (e.g., the caller confused the multisig contract address with the intended beneficiary). Catching this early prevents approvals accumulating for an operation whose execution would be a silent no-op.
ContractUpgrade — self-referential target (SelfReferentialRecipient)
A ContractUpgrade proposal naming the multisig contract itself as the upgrade
target could permanently brick the multisig instance if a malformed WASM hash
is approved and the resulting upgrade fails. An off-chain orchestrator that
consumes the operation_executed event would attempt to apply the upgrade to
the multisig contract itself — an extremely dangerous misfire. Rejecting this
at proposal time makes the misconfiguration visible immediately.
| Error | Code | When raised |
|---|---|---|
MultisigError::ContractUpgradeHashMismatch |
1 |
execute_operation hash mismatch at execution time |
MultisigError::InvalidAmount |
2 |
LargePayment with amount <= 0 at proposal time |
MultisigError::SelfReferentialRecipient |
3 |
LargePayment or ContractUpgrade self-address at proposal time |
The validation is performed by the private validate_operation_kind helper in
lib.rs, called unconditionally from propose_operation after the threshold
override check:
fn validate_operation_kind(env: &Env, kind: &OperationKind) {
match kind {
OperationKind::LargePayment(_token, to, amount) => {
if *amount <= 0 {
panic_with_error!(env, MultisigError::InvalidAmount);
}
if to == &env.current_contract_address() {
panic_with_error!(env, MultisigError::SelfReferentialRecipient);
}
}
OperationKind::ContractUpgrade(target, _hash) => {
if target == &env.current_contract_address() {
panic_with_error!(env, MultisigError::SelfReferentialRecipient);
}
}
OperationKind::DisputeResolution(_, _, _, _)
| OperationKind::SetThresholdOverride(_, _) => {}
}
}ContractUpgrade Hash ValidationContractUpgrade operations carry a WASM hash (BytesN<32>) that is embedded
in the proposal at creation time and co-signed by all approving signers. Because the off-chain orchestrator that actually deploys the new WASM consumes the on-chain execution event, it is critical that the hash confirmed on-chain matches the WASM binary that will be deployed.
The standard approve_operation path auto-executes as soon as the approval
count reaches the threshold. This means the final signer's approve_operation
call triggers execution without any opportunity to re-assert the intended WASM
hash at execution time.
execute_operation(caller, operation_id, expected_hash) is a hash-gated
alternative to approve_operation for the final approval that triggers
execution:
- Hash check first — for
ContractUpgradeoperations,expected_hashis validated against the hash stored in the proposal before any approval is recorded. If the hashes differ (orexpected_hashisNone), the call panics withMultisigError::ContractUpgradeHashMismatchand no state is modified. - Approval recorded — if the hash check passes, the caller's approval is written to storage (idempotent if already approved).
- Threshold check —
execute_if_threshold_metis called. If the approval count now meets the effective threshold,perform_executemarks the operation asExecuted.
- The hash check fires before any state write. A mismatch leaves the operation
in its current
Pendingstate, approval count unchanged. expected_hashmust be aSome(BytesN<32>)containing a value that equals the stored hash exactly. PassingNoneis treated as a mismatch and rejected.- For non-
ContractUpgradeoperation kinds,expected_hashis ignored. Callers may passNonefor those kinds. - The function requires
callerto be a configured signer and to authenticate viarequire_auth(). - An operation that is not
Pending(alreadyExecutedorCancelled) is rejected by the standard"Operation not pending"assertion before the hash check.
See Payload Validation at Proposal Time — Error codes for the
full error code table. For execute_operation specifically:
| Error | Value | Meaning |
|---|---|---|
MultisigError::ContractUpgradeHashMismatch |
1 |
expected_hash is absent or does not equal the hash stored in the ContractUpgrade proposal. |
// S1 proposes; S2 approves normally (N-1 approvals).
// S3 (final signer) calls execute_operation to confirm the hash and trigger execution:
multisig.execute_operation(
&s3,
&operation_id,
&Some(expected_wasm_hash), // must match the hash from the proposal
);
Signers configure an override by proposing a SetThresholdOverride operation.
The proposal is auto-approved by its creator and other signers approve it using
the normal workflow. The configuration write occurs only after the approval
count reaches the target operation type's currently active, pre-change
threshold. For example, lowering ContractUpgrade from 3-of-3 to 2-of-3 still
requires three approvals. The emergency guardian cannot bypass this check.
Override values must be between 1 and the number of configured signers. To
restore the default threshold, propose SetThresholdOverride(type, None); that
removal must also meet the type's current override. Pending operations are
evaluated against the effective threshold at approval time, so an approved
configuration change applies consistently to subsequent approvals.
| Config | Use Case |
|---|---|
| 1-of-1 | Single signer, auto-execute on propose |
| 2-of-3 | Standard multisig (balanced safety/ops) |
| 3-of-3 | Maximum security, all must agree |
| 1-of-N with guardian | Operational with break-glass safety net |
Each operation has a monotonically increasing ID (see OperationCounter). Once
an operation reaches a terminal status (Executed or Cancelled), its status is
immutable:
approve_operationpanics when called against a non-pending (executed or cancelled) operation.cancel_operationpanics when called against a non-pending operation.emergency_executepanics when called against a non-pending operation.perform_executesilently returns for non-pending operations, preventing any code path from accidentally executing a cancelled or already-executed op.
Cancelled operations are terminal and non-resurrectable. Once cancelled,
no function can revive the operation toward execution. The operation's ID is
never reused; every propose_operation call atomically increments the global
OperationCounter, guaranteeing that a new proposal always receives a strictly
higher ID than any previously cancelled operation.
The has_approved check ensures each signer can only contribute one approval per operation, regardless of how many times approve_operation is called.
The effective threshold is checked at execution time. An operation-type override takes precedence over the default, while approvals are stored independently of threshold changes. Override changes are themselves operations and use the target type's pre-change effective threshold, preventing a signer or emergency guardian from unilaterally weakening the approval requirement.
All state-changing functions require require_auth() on the caller. The Soroban host enforces cryptographic signature verification.
- Guardian address should be a cold wallet or hardware-secured key
- Guardian actions are logged via events for audit trails
- Guardian cannot execute already-executed or cancelled operations
- Guardian cannot bypass the active threshold for an override change
- Guardian is restricted to emergency-eligible operation kinds only (see Emergency Eligibility)
Not all operation kinds can be executed via emergency_execute. Only
time-sensitive, break-glass operations are eligible:
| OperationKind | Emergency-Eligible | Rationale |
|---|---|---|
DisputeResolution |
Yes | Prevents fund lockup during disputes; time-critical |
ContractUpgrade |
No | Governance change; requires full multi-signer consensus |
LargePayment |
No | Routine operation; must use standard approval flow |
SetThresholdOverride |
No | Already blocked in perform_execute; governance change |
The is_emergency_eligible function in the contract enforces this restriction.
Any attempt to call emergency_execute with a non-eligible operation kind is
rejected with "Operation kind not eligible for emergency execution".
| Event | Fields | When Emitted |
|---|---|---|
operation_proposed |
operation_id, creator |
On propose |
operation_approved |
operation_id, signer, approvals, threshold |
On each approval |
operation_executed |
operation_id |
On execution |
operation_cancelled |
operation_id |
On cancellation |
Run the test suite:
cd onchain
cargo test -p multisigThe test suite covers:
- Initialization validation (invalid threshold, duplicate signers, re-init)
- 1-of-1 auto-execution
- 3-of-3 all-approvals-required
- 2-of-3 standard threshold flow
- Duplicate approval prevention
- Non-signer rejection (propose and approve)
- Already-executed rejection (approve and cancel)
- Cancel by creator or owner (signers who are neither the proposer nor the owner cannot cancel)
- Cancelled operation replay protection:
- approve against cancelled operation is rejected (panic)
- cancel against already-cancelled operation is rejected
- cancelled operation ID is never reused (monotonic counter increases)
- Guardian-only rescue
- Guardian cannot execute executed/cancelled ops
- Multiple independent operations
- Zero-amount payment rejection
- ContractUpgrade and DisputeResolution flows
- Payload validation at proposal time (issue enforcement):
LargePaymentwithamount = 0is rejected atpropose_operationwithMultisigError::InvalidAmount; no operation is stored, no approval is recordedLargePaymentwithamount < 0is rejected atpropose_operationwithMultisigError::InvalidAmountLargePaymentwhosetoequals the multisig contract address is rejected atpropose_operationwithMultisigError::SelfReferentialRecipientContractUpgradewhosetargetequals the multisig contract address is rejected atpropose_operationwithMultisigError::SelfReferentialRecipient- Valid
LargePayment(positive amount, distinct recipient) is still accepted - Valid
ContractUpgrade(distinct target) is still accepted
- Query function correctness
- Per-operation override enforcement and default fallback
- Adversarial threshold lowering and guardian-bypass prevention
- Override removal and invalid override rejection
- ContractUpgrade hash validation via
execute_operation:- Negative:
execute_operationwith a mismatched hash is rejected; operation staysPendingand approval count is unchanged - Negative:
execute_operationwithNonehash is rejected forContractUpgradeoperations - Positive:
execute_operationwith the correct hash records the approval and transitions the operation toExecutedonce threshold is reached
- Negative:
- Emergency execute eligibility (issue #898):
emergency_executerejectsLargePayment(routine; not emergency-eligible)emergency_executerejectsContractUpgrade(governance; not emergency-eligible)emergency_executerejectsSetThresholdOverride(governance; not emergency-eligible)emergency_executesucceeds forDisputeResolution(emergency-eligible)- Emergency-eligible operations still require the guardian's authentication (not zero approvals)
The test suite ensures the contract cannot be left with fewer signers than the configured threshold:
test_reject_signer_reduction_below_threshold— Verifies that removing signers down to 1 while keeping the threshold at 2 is rejected, because the new threshold (2) would exceed the number of signers (1).test_reject_update_signers_threshold_exceeding_count— Verifies that callingupdate_signerswith a new signer count of 2 and a threshold of 3 is rejected, because 3 > 2.test_signer_reduction_with_threshold_adjustment_succeeds— Verifies that reducing from 3-of-3 to 2-of-2 (the intended recovery path) succeeds and the reduced signer set can still reach quorum and execute operations.
The onchain/contracts/stello_pay_contract/tests/test_multisig_integration.rs
test suite covers:
- 2-of-3 approval at threshold: Propose + 2 signers approve → claim succeeds
- 1-of-2 below threshold: Propose only, op stays Pending → claim rejected with
PayrollError::MultisigApprovalRequired - Direct path blocked:
claim_payroll(non-multisig) returnsMultisigApprovalRequiredwhen amount ≥ threshold - Below-threshold bypass:
claim_payrollsucceeds when amount < threshold - 3-of-3 approval at threshold: All 3 signers must approve → claim succeeds (regression: ensures maximum-restrictive configuration works)
- 2-of-3 below threshold of 3: Even a majority (2/3) is insufficient when
the threshold is 3 → claim rejected with
MultisigApprovalRequired - Wrong employee rejection: LargePayment op with mismatched
tofield → claim rejected withMultisigApprovalRequired - Wrong op kind rejection: DisputeResolution op used for
claim_payroll_multisig→ claim rejected withMultisigApprovalRequired
The stello_pay_contract payroll contract gates large payments and dispute
resolutions behind multisig approval, using two thresholds configured via
set_multisig_config(owner, multisig_contract, large_payment_threshold, dispute_resolution_threshold).
Because changing these thresholds alters the contract's security posture, every
successful set_multisig_config call now:
- emits a
MultisigConfigChangedevent (seedocs/events-schema.json) carrying thecaller, themultisig_contract, and the old vs new values for both thresholds, so off-chain monitors can detect approval-requirement changes mid-lifecycle; and - records a tamper-evident audit entry through the contract's existing audit
path (
AuditEvent::MultisigConfigChanged, actionmultisig_config_changed, contract-levelagreement_id = 0).
The event exposes only public configuration; it never includes multisig signer
secrets. Emission and audit recording are covered by
onchain/contracts/stello_pay_contract/tests/test_event_emissions.rs
(test_multisig_config_changed_event*).