Skip to content

Commit bc09f2f

Browse files
committed
standards: harden multisig controller invariants
1 parent 70f0c96 commit bc09f2f

8 files changed

Lines changed: 737 additions & 161 deletions

File tree

docs/dusk-contract-standards-multisig-controller-review.md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ Moonlight/Phoenix/contract owners approve controller operations.
3232
bytes, and salt.
3333
- A ready operation must be removed from pending proposals and tombstoned before
3434
the wrapper attempts target execution.
35-
- Expired proposals are pruned and cannot become ready.
35+
- Expired proposals cannot become ready. Direct confirmation returns the
36+
explicit expired error without mutating the pending operation; later
37+
proposal/cancel lifecycle calls prune expired pending operations.
3638
- Authority changes that remove owners or change threshold clear stale pending
3739
operations.
3840

@@ -60,7 +62,8 @@ Phoenix owners and threshold `2`, then deploy `proxy_counter` with
6062
The native property test exercises the raw controller state machine across
6163
random owners, ids, targets, TTLs, tombstone windows, and timings. It checks
6264
atomicity for non-owner proposals, non-owner confirmations, duplicate
63-
confirmations, one-of-three pending behavior, expiry pruning, ready-state
65+
confirmations, same-id/different-target rejection, one-of-three pending
66+
behavior, expired confirmation rejection without mutation, ready-state
6467
tombstoning, tombstone replay rejection, and successful re-use only after the
6568
tombstone has expired.
6669

@@ -71,11 +74,14 @@ Phoenix. Phoenix owners approve by Schnorr-signed `AuthorizedAction`.
7174
Moonlight and contract owners may be accepted through observed runtime context
7275
when the host exposes it.
7376

74-
The Forge wrapper verifies signatures before consuming nonce state, but only
75-
consumes after the controller accepts the proposal/confirmation/maintenance
76-
operation. This avoids burning a valid signature on duplicate confirmations,
77-
wrong threshold membership, invalid targets, expired proposals, or invalid
78-
maintenance payloads.
77+
The Forge wrapper verifies signatures before consuming nonce state. Threshold
78+
maintenance calls receive a `MultisigQuorum` witness from that verification
79+
path rather than a caller-supplied list of principals, so composing contracts
80+
cannot accidentally satisfy owner management by naming owners without proving
81+
authorization. The wrapper consumes nonce state only after the controller
82+
accepts the proposal/confirmation/maintenance operation. This avoids burning a
83+
valid signature on duplicate confirmations, wrong threshold membership,
84+
invalid targets, expired proposals, or invalid maintenance payloads.
7985

8086
Execution is automatic at threshold. There is no separate `execute` step. This
8187
is simpler and avoids a ready-operation queue, but systems that require a final
@@ -87,10 +93,10 @@ A target execution failure emits `multisig/operation_executed` with
8793
same logical call requires a new salt. This avoids accidental replay, but
8894
clients must surface failed execution clearly.
8995

90-
`propose` can count as a confirmation for an existing operation with the same
91-
operation id. This is acceptable because the signed payload is the exact
92-
operation id, but client UX should prefer the explicit `confirm` method after a
93-
proposal already exists.
96+
`propose` can count as a confirmation for an existing operation only when the
97+
supplied target matches the stored target for the operation id. Client UX
98+
should still prefer the explicit `confirm` method after a proposal already
99+
exists.
94100

95101
## Residual Risks
96102

docs/dusk-contract-standards-security.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ Multisig approvals should be bound to the exact operation through
8787
minting, pausing, and role administration should use separate domains or action
8888
ids so approvals cannot be replayed across policy surfaces.
8989

90+
Quorum-gated mutators take a `MultisigQuorum` witness rather than raw principal
91+
arrays. The witness is produced by the multisig verification path and has a
92+
private signer list, so callers can inspect who signed but cannot forge a
93+
quorum by manually passing known owner principals.
94+
9095
Use the standalone `MultisigController` when a ported contract wants a single
9196
owner/admin principal like an Ethereum contract owned by a Safe. The target
9297
contract should assign ownership or the relevant role to the controller's

docs/dusk-contract-standards.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,10 @@ The native test suite covers positive and negative paths for:
220220
- observed-or-signed owner, role, and upgrade-admin authorization;
221221
- threshold multisig authorization, duplicate signer rejection, observed
222222
Moonlight/contract approval, Phoenix signed approval, and threshold-gated
223-
owner/threshold maintenance;
223+
owner/threshold maintenance through verified quorum witnesses;
224224
- standalone multisig controller proposal, confirmation, duplicate-confirmation
225-
rejection, non-owner rejection, expiry cleanup, cancellation, tombstoning,
225+
rejection, non-owner rejection, same-id/different-target rejection, explicit
226+
expired-confirmation rejection, expiry cleanup, cancellation, tombstoning,
226227
authority updates, 2-of-3 property coverage, and proxy-as-owner VM execution;
227228
- timelock scheduling, cancellation, execution, and invalid states;
228229
- role-gated timelock controller flows, including self-governed delay updates;

standards/dusk-contract-standards/src/governance/multisig.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ impl ThresholdMultisig {
137137
approvals: &[SignedAuthorization],
138138
envelope: ActionEnvelope,
139139
now: u64,
140-
) -> Vec<Principal> {
140+
) -> MultisigQuorum {
141141
let (quorum, verified) = self.verify_action_with_witnesses(
142142
authorizations,
143143
context,
@@ -148,7 +148,7 @@ impl ThresholdMultisig {
148148
for approval in verified {
149149
authorizations.consume_verified(approval);
150150
}
151-
quorum.signers
151+
quorum
152152
}
153153

154154
/// Verifies that the observed caller plus supplied signed approvals satisfy

standards/dusk-contract-standards/src/governance/multisig_controller.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ impl MultisigController {
353353
approvals: &[SignedAuthorization],
354354
envelope: ActionEnvelope,
355355
now: u64,
356-
) -> Vec<Principal> {
356+
) -> MultisigQuorum {
357357
self.policy.authorize_action(
358358
authorizations,
359359
context,
@@ -457,7 +457,7 @@ impl MultisigController {
457457
) -> MultisigControllerOutcome {
458458
self.assert_initialized();
459459
self.assert_owner(authorizer);
460-
self.prune(now);
460+
self.prune_tombstones(now);
461461
self.assert_not_tombstoned(id);
462462
self.confirm_pending(id, authorizer, now)
463463
}
@@ -557,6 +557,9 @@ impl MultisigController {
557557
.proposals
558558
.get_mut(&id)
559559
.unwrap_or_else(|| panic!("{}", error::OPERATION_UNKNOWN));
560+
if now > operation.deadline {
561+
panic!("{}", error::EXPIRED);
562+
}
560563
if operation.confirmed_by(authorizer) {
561564
panic!("{}", error::UNAUTHORIZED);
562565
}
@@ -637,6 +640,10 @@ impl MultisigController {
637640
fn prune(&mut self, now: u64) {
638641
self.proposals
639642
.retain(|_, operation| now <= operation.deadline);
643+
self.prune_tombstones(now);
644+
}
645+
646+
fn prune_tombstones(&mut self, now: u64) {
640647
self.tombstones.retain(|_, expiry| now <= *expiry);
641648
}
642649

standards/dusk-contract-standards/tests/access_multisig.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -713,16 +713,16 @@ fn threshold_multisig_consumes_approvals_only_after_quorum_success() {
713713
});
714714
assert_eq!(authorizations.nonce(outsider, DOMAIN), 0);
715715

716-
let signers = multisig.authorize_action(
716+
let quorum = multisig.authorize_action(
717717
&mut authorizations,
718718
CallContext::from_principal(owner_a),
719719
&[signed_b.clone()],
720720
envelope(),
721721
100,
722722
);
723-
assert_eq!(signers.len(), 2);
724-
assert!(signers.contains(&owner_a));
725-
assert!(signers.contains(&owner_b));
723+
assert_eq!(quorum.signers().len(), 2);
724+
assert!(quorum.signers().contains(&owner_a));
725+
assert!(quorum.signers().contains(&owner_b));
726726
assert_eq!(authorizations.nonce(owner_b, DOMAIN), 1);
727727
assert_panics(|| {
728728
multisig.authorize_action(
@@ -809,7 +809,7 @@ fn multisig_controller_lifecycle_replay_and_authority_changes_are_pinned() {
809809
let expired_id = [36u8; 32];
810810
controller.propose(expired_id, target(36), owner_a, 3);
811811
assert_panics(|| controller.confirm(expired_id, owner_b, 14));
812-
assert!(controller.proposal(expired_id).is_none());
812+
assert!(controller.proposal(expired_id).is_some());
813813
let replacement_id = [37u8; 32];
814814
controller.propose(replacement_id, target(37), owner_a, 14);
815815
assert!(controller.proposal(expired_id).is_none());

0 commit comments

Comments
 (0)