Skip to content

Add multi-sig governance guard for contract upgrades and storage migration (#16) - #26

Merged
meshackyaro merged 9 commits into
workman-labs:developmentfrom
benfoster-dev:feat/upgrade-governance-guard
Jul 22, 2026
Merged

Add multi-sig governance guard for contract upgrades and storage migration (#16)#26
meshackyaro merged 9 commits into
workman-labs:developmentfrom
benfoster-dev:feat/upgrade-governance-guard

Conversation

@benfoster-dev

@benfoster-dev benfoster-dev commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Closes #16

Adds a shared governance-guard crate (a library, not its own deployed contract) that gates upgrading a contract's code behind a multi-sig proposal/approval flow, plus a version-gated migrate step for after. All four contracts take signers and a threshold at initialize now and get propose_upgrade/approve_upgrade/cancel_upgrade/migrate. Doesn't touch existing admin auth anywhere, it only ever gates code swaps.

Caught a real bug of my own while writing tests: with a single signer, their own proposal already meets the threshold, so propose_upgrade has to report readiness itself instead of waiting on a second approval that can never come. Also handled replay/expiry (can't approve twice, approvals don't carry over if the hash changes, proposals expire after about a week, any one signer can cancel).

Signer rotation is deliberately not in this PR, the set is fixed at initialize forever, that's its own governance problem and I didn't want to double the trust surface in one pass.

migrate is real and version-gated but there's no actual migration to run yet, every contract is still on version 1.

One honest limitation: crossing the threshold calls update_current_contract_wasm, which needs the target wasm already uploaded on-chain, nothing cargo test can do. Governance tests through real contracts stop one approval short on purpose, the threshold/replay logic itself is fully covered in governance-guard's own 20 tests.

Also added the missing cargo fmt --check to CI and fixed clippy silently skipping test files.

107 tests passing, fmt/clippy clean, release wasm build succeeds.

New library crate, not a deployed contract itself. Provides propose,
approve, cancel for a versioned upgrade proposal gated by an M-of-N
signer threshold, plus storage-version tracking for a follow-up
migrate step. Deliberately does not touch each contract's existing
single-admin auth for its normal operations, this only gates the
ability to swap a contract's own wasm and run its migration, not the
day to day admin powers each contract already has.

Signer set and threshold are immutable after init in this version,
there is no signer-rotation flow yet, that is its own governance
problem and shipping it here would roughly double the attack surface
for a capability this issue did not ask for.

propose_upgrade and approve_upgrade both report when their approval is
the one that crosses the threshold, since a 1-of-N governance setup
reaches threshold on the proposer's own approval and has no second
signer left to place a distinct approve call. This crate cannot call
update_current_contract_wasm itself, since that always replaces
whichever contract is currently executing, so it returns readiness and
leaves the actual swap to the host contract that embeds it.

20 tests cover threshold math, duplicate and non-signer rejection,
approval replay across a changed proposal, proposal expiry, and
migration version gating including the backwards case.

Part of workman-labs#16.
initialize now takes signers and threshold, wired straight into
guildworkman-governance-guard. Adds propose_upgrade, approve_upgrade,
cancel_upgrade, and migrate as new entrypoints, alongside read-only
get_signers, get_upgrade_threshold, get_pending_upgrade, and
get_storage_version.

The existing admin field and every action gated on it (update_config,
set_stake, recalculate_score) are untouched, governance only reaches
the ability to swap this contract's code and run its migration.

migrate is version gated against CURRENT_STORAGE_VERSION and currently
a no-op body since storage hasn't changed shape yet, it exists so the
version-checked path is real and a future migration has a place to
land its actual field transformations.

10 new tests cover the governance wiring through the real contract
entrypoints, stopping one approval short of the configured threshold
everywhere. Crossing it calls update_current_contract_wasm, which
needs Wasm actually uploaded on the ledger, not available inside a
plain cargo test run, guildworkman-governance-guard's own 20 tests
already cover the threshold/replay/expiry logic this builds on in
isolation.

Part of workman-labs#16.
Same wiring as the reputation contract, initialize takes signers and
threshold, propose_upgrade/approve_upgrade/cancel_upgrade/migrate
added alongside their read-only getters. The dispute-arbiter admin is
untouched.

6 new tests cover the wiring through the real contract entrypoints,
including one that gets the proposal to exactly one approval and
stops there rather than crossing the threshold, for the same reason
documented in reputation's test file.

Part of workman-labs#16.
…truct

Adding two loose params to loyalty-token's initialize took it to 8
arguments and clippy's too_many_arguments lint failed the build (limit
is 7). Rather than suppress the lint, bundled signers and threshold
into one GovernanceInit struct and applied it everywhere for
consistency, not just where it was strictly required, so all four
contracts take the same shape.

Also applies cargo fmt across every file this touched, the repo had
formatting drift that predates this work and fmt --check was never
actually run in CI.

Part of workman-labs#16.
Same wiring as the other three contracts, initialize now takes a
GovernanceInit alongside its existing admin/minter/metadata params.
The admin's existing power to rotate the minter is untouched.

6 new tests cover the wiring through the real contract entrypoints.

Part of workman-labs#16.
…guard

Same wiring as the other three, plus this contract's own test suite
deploys a real loyalty-token internally, so its fixture needed the
same GovernanceInit argument added to that inner initialize call, on
top of emissions' own.

6 new tests cover the wiring through the real contract entrypoints.

Part of workman-labs#16.
fmt --check was listed as a task on the issue but never actually
wired up, added it as its own fast-failing step before clippy.
Also switched clippy to --all-targets so it covers #[cfg(test)] code,
not just the lib target, it was silently skipping every test.rs file
before this.

Part of workman-labs#16.
New Upgrade governance section covers the shared pattern once: the
propose/approve/cancel/migrate flow, why cancel only needs one signer
while approving needs the full threshold, and why signer rotation is
explicitly out of scope for this pass. Each contract's initialize
signature, method list, error table, and CLI example are updated to
match, except reputation's section, which already described a
different, older interface than what's in src/lib.rs before this PR —
flagged that drift inline rather than folding an unrelated rewrite
into this change.

Also updated the two security-considerations bullets that this work
made outdated: the 'no pause/upgrade mechanism' line and the
single-key-admin line, plus added a note on what cargo test does and
doesn't actually exercise for the upgrade path (the real Wasm swap
needs an uploaded artifact no unit test run has).

Part of workman-labs#16.
@meshackyaro

Copy link
Copy Markdown
Contributor

Closes #16

Adds a shared governance-guard crate (a library, not its own deployed contract) that gates upgrading a contract's code behind a multi-sig proposal/approval flow, plus a version-gated migrate step for after. All four contracts take signers and a threshold at initialize now and get propose_upgrade/approve_upgrade/cancel_upgrade/migrate. Doesn't touch existing admin auth anywhere, it only ever gates code swaps.

Caught a real bug of my own while writing tests: with a single signer, their own proposal already meets the threshold, so propose_upgrade has to report readiness itself instead of waiting on a second approval that can never come. Also handled replay/expiry (can't approve twice, approvals don't carry over if the hash changes, proposals expire after about a week, any one signer can cancel).

Signer rotation is deliberately not in this PR, the set is fixed at initialize forever, that's its own governance problem and I didn't want to double the trust surface in one pass.

migrate is real and version-gated but there's no actual migration to run yet, every contract is still on version 1.

One honest limitation: crossing the threshold calls update_current_contract_wasm, which needs the target wasm already uploaded on-chain, nothing cargo test can do. Governance tests through real contracts stop one approval short on purpose, the threshold/replay logic itself is fully covered in governance-guard's own 20 tests.

Also added the missing cargo fmt --check to CI and fixed clippy silently skipping test files.

107 tests passing, fmt/clippy clean, release wasm build succeeds.

This is a security-critical change (it gates code upgrades across all four contracts), so I read through it carefully. Overall the scoping discipline here is good — you've been explicit about what's in and out, which makes this much easier to review than a PR that tried to solve governance end-to-end.

On the core design

Multi-sig gating only on code swaps, leaving existing admin auth untouched, is the right cut for this PR — upgrade authority and operational authority are genuinely different concerns and conflating them would've made this harder to reason about.
Good catch on the single-signer threshold bug. That's the kind of off-by-one-in-spirit bug that's obvious in hindsight but easy to ship, so glad it surfaced in tests rather than in prod with a real single-signer deployment stuck unable to upgrade.
Replay/expiry handling (no double-approval, approvals invalidated on hash change, ~week expiry, any signer can cancel) covers the cases I'd have asked about. Two questions:
Is the ~week expiry a constant we can tune per-contract later, or hardcoded into governance-guard itself? If a future contract wants a shorter/longer window, want to know if that's a config knob or a code change.
"Approvals don't carry over if the hash changes" — does that mean a changed proposal requires fresh approval from everyone including signers who'd already approved the old hash, or just that the old approvals are discarded and it starts fresh at zero? Worth stating explicitly in the doc comments if it isn't already.

On signer rotation being out of scope

Agreed this is the right call to defer rather than fold in here. Fixed-forever signers is a real limitation worth flagging loudly though — can we get an issue opened now (even if it's not prioritized) so this doesn't get lost, and maybe a note in the README/module docs saying rotation is explicitly not supported yet, so it's not a surprise to whoever deploys this?

On migrate

Makes sense to land the version-gated scaffolding now with no-op migrations, rather than waiting until there's an actual migration to design the interface around. Just confirming: is migrate itself also gated behind the same multi-sig flow, or only the code swap? Want to make sure there's no path where migrate can be called post-upgrade without the same approval bar.

On the testing gap

The update_current_contract_wasm limitation is a real constraint of the test environment, not a shortcut you're taking — makes sense that governance-guard's own 20 tests carry the threshold/replay logic coverage while the through-contract tests stop one approval short. Is there a plan (even informal/manual) for verifying the full path against a real uploaded wasm before this goes to a live network, or does that happen naturally in a testnet deployment step later?

On CI

Adding cargo fmt --check and fixing clippy silently skipping test files are both good catches independent of the main change — glad these didn't slip through.

107 passing tests and clean fmt/clippy is a good signal. Nothing here blocks approval for me, but I'd like answers on the expiry/hash-change and migrate-gating questions before merge, and a tracking issue for signer rotation opened alongside this.

…on hash change

Answers a review question on workman-labs#26. The doc comment implied replacement
only happened for a different or expired proposal, the actual
behavior is unconditional: even re-proposing the identical hash resets
the approval count, so every signer has to approve again regardless of
whether they'd already approved the same hash under the prior
proposal.

Part of workman-labs#16.
@benfoster-dev

Copy link
Copy Markdown
Contributor Author

answers, checked against the actual code rather than from memory:

expiry is a hardcoded constant in governance-guard (PROPOSAL_TTL_LEDGERS, ~7 days), same for every contract right now, not part of GovernanceInit. making it tunable per contract would mean adding it to that struct and threading it through, happy to do that here or as a follow-up if you want it configurable.

hash change resets to zero, and it's actually broader than that. propose_upgrade unconditionally wipes whatever was pending and starts a fresh approvals list with just the new proposer, even if you call it again with the identical hash. so yes, everyone has to approve again, including someone who'd already approved the exact same hash under the proposal that got replaced. pushed a doc comment making that explicit since you were right that it wasn't stated clearly enough before.

migrate only requires one signer, not the full threshold. that's deliberate, not an oversight, reasoning is in the code comment: the risky decision (which code to trust) already went through the full multi-sig when the upgrade itself was approved, migrate just runs whatever that already-approved code ships with. but I get why you'd want to double check this one specifically, if you'd rather it require the full threshold too, that's a small change, let me know.

no automated plan for the real wasm swap, would be a manual testnet deploy-propose-approve-confirm dry run before this goes near a live network with real funds. can write that up as a checklist if useful.

opened #27 for signer rotation like you asked.

@meshackyaro meshackyaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well done! This is clear, specific, and each answer is anchored to something checkable (constant name, code comment, issue number) rather than vague reassurance. I like that.

Thanks for contributing to Guildworkman

@meshackyaro
meshackyaro merged commit 149f842 into workman-labs:development Jul 22, 2026
1 check passed
meshackyaro pushed a commit that referenced this pull request Jul 24, 2026
The governance-guard multi-sig previously froze its signer set and
threshold at initialize, with no way to change them short of a full
contract upgrade. This adds a timelocked, threshold-gated signer
rotation flow (propose -> approve -> execute) to the shared guard and
wires it into every host contract that consumes it.

Security model:
- Authorized by the current signer set at the current (upgrade)
  threshold -- rotation grants the set no power it lacked via upgrade.
- Mandatory timelock between reaching threshold and execution gives a
  minority about to be removed a visible on-chain window: no silent,
  instant lockout (minority protection).
- No unilateral veto; only one live rotation at a time, so a lone
  signer can neither reset a gathering rotation's approvals nor displace
  a scheduled one -- a compromised signer cannot block its own removal
  (majority protection).
- Executing a rotation clears any pending upgrade, whose approvals came
  from the old signer set.

Adds PendingRotation storage, RotationProposed/Approved/Scheduled/
Executed events, five rotation error variants, and 37 guard tests
(now covering the full rotation lifecycle and both protection
properties). Host contracts (escrow, reputation, loyalty-token,
loyalty-emissions) gain propose/approve/execute_signer_rotation and
get_pending_rotation entrypoints plus the mapped error variants.

Follow-up from #26 / #16.

Co-authored-by: jayteemoney <mac@Macs-MacBook-Pro.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Upgradeable Contract Architecture with Storage Migration & Governance Guard

2 participants