cw721-roles + dao-voting-cw721-roles: enforce soulbound, complete two… - #930
Open
JakeHartnell wants to merge 2 commits into
Open
cw721-roles + dao-voting-cw721-roles: enforce soulbound, complete two…#930JakeHartnell wants to merge 2 commits into
JakeHartnell wants to merge 2 commits into
Conversation
…-phase ownership handover, add migrate
These contracts had four deploy-blocking bugs discovered during a pre-mainnet
audit for the Juno Agents NFT DAO (juno-1). All four are fixed here.
## What was broken
1. **`cw721-roles` soulbound was a docstring, not code.** The Cargo.toml
description claimed "Non-transferable cw721 NFT contract," but
`execute_transfer` and `execute_send` only checked `assert_owner(sender)`.
With the typical DAO-as-owner deployment, this meant a passed governance
proposal could transfer any member NFT to any address. The existing test
`test_minting_and_transfer_permissions` literally asserted this transfer
succeeded.
2. **`execute_transfer`/`execute_send` desynced `MEMBERS`.** Even if you'd
wanted to allow DAO-initiated transfers, they mutated the `tokens` map
but never touched the `MEMBERS` weight map or fired `MemberChangedHookMsg`.
The old owner retained voting weight without holding the NFT; the new
owner held the NFT with no weight. The cw4 view returned by the voting
module became a permanent lie.
3. **`dao-voting-cw721-roles` ownership handover never completed.** The
reply handler called `cw_ownable::Action::TransferOwnership` to hand
cw721-roles ownership to the DAO core, but cw-ownable 0.5.x is two-phase
and the new owner must explicitly `AcceptOwnership`. The voting module's
own `execute()` returns `NoExecute`, so the voting module couldn't accept
on its own behalf. Result: after instantiate, cw721-roles ownership
remained with the voting module — which refuses all execute calls — and
no future `Mint`/`Burn`/`UpdateTokenWeight` proposal could ever succeed.
The DAO would be bricked at the membership layer from block 1.
4. **Neither contract had a `migrate()` entry point.** Standard remediation
for any of the above post-deploy would be a wasm migration. Without
`migrate()`, the only recovery path was rebuilding the DAO from scratch.
## The fixes
**`cw721-roles/src/contract.rs`:**
- New explicit `ExecuteMsg::UpdateOwnership(_)` match arm runs *before* the
`assert_owner` gate and delegates to `cw_ownable::update_ownership` via
`cw721-base`. cw-ownable's per-Action auth checks the right sender for
each variant: current owner for `TransferOwnership`/`RenounceOwnership`,
pending owner for `AcceptOwnership`. Without this carve-out, the outer
`assert_owner(sender)` rejected `AcceptOwnership` calls (because the DAO
is the pending owner, not the current owner), making the bootstrap
handover fundamentally impossible.
- `execute_transfer` and `execute_send` now unconditionally return
`ContractError::Soulbound{}`. This subsumes the MEMBERS-desync bug —
no transfer path means no desync path.
- `Approve`, `ApproveAll`, `Revoke`, `RevokeAll` now also return `Soulbound{}`
for design hygiene (granting approval implies transferability, which
doesn't apply to soulbound NFTs).
- New no-op `migrate()` entry point bumps the cw2 version.
**`cw721-roles/src/error.rs`:** New `Soulbound{}` variant.
**`cw721-roles/src/msg.rs`:** New `MigrateMsg` struct.
**`cw721-roles/src/tests.rs`:** Inverted the transfer/send permission tests
to assert `Soulbound{}` rejection (including when called by the DAO/minter),
and added MEMBERS-unchanged assertions to catch any future regression
attempting to slip the desync bug back in.
**`dao-voting-cw721-roles/src/contract.rs`:**
- Reply handler now emits a `dao_interface::state::ModuleInstantiateCallback`
via `.set_data(...)` containing an `AcceptOwnership` execute message
targeted at the new cw721-roles. The dao-dao-core's
`VOTE_MODULE_INSTANTIATE_REPLY_ID` handler decodes this callback and
dispatches its msgs as itself, so `AcceptOwnership` lands with
`info.sender == dao_core == pending_owner` and the handover completes
atomically in the original instantiate transaction.
- New no-op `migrate()` entry point bumps the cw2 version.
**`dao-voting-cw721-roles/src/msg.rs`:** New `MigrateMsg` struct.
**`dao-voting-cw721-roles/src/testing/mod.rs`:** `setup_test` now performs
the `AcceptOwnership` step explicitly (mocking what dao-dao-core does
through the callback channel in production, which `cw_multi_test` doesn't
chain automatically). Critically, the call is `.expect()`ed — earlier
drafts used `let _ = …` which silently swallowed errors and would have
hidden the bootstrap regression.
**`dao-voting-cw721-roles/src/testing/tests.rs`:** Updated
`test_voting_queries` to expect cw721-roles ownership belongs to
CREATOR_ADDR (the DAO) after handover — previously it asserted ownership
remained with the voting module, which is exactly the bug.
## Verification
Three rounds of adversarial multi-agent audit (90+ agents per round) against
the patched code. Final round: 0 confirmed code findings across 8 review
dimensions (soulbound enforcement, access control, reply handler atomicity,
voting power math, hook safety, state/migration, dao-dao-core integration,
test coverage). End-to-end smoke tests (out-of-tree to avoid the
dao-testing → osmosis-test-tube → libclang dep chain in some CI envs)
instantiate the full DAO → voting module → cw721-roles chain, fire
AcceptOwnership from the DAO, and verify: ownership lands on DAO, DAO can
mint, voting module loses mint rights, RenounceOwnership-by-DAO still
possible (governance retains flexibility).
## Operational note
`cw721-roles` hook dispatch uses `SubMsg::new` (fire-and-forget,
`ReplyOn::Never`), so any registered hook contract that panics or runs
out of gas reverts the parent `Mint`/`Burn`/`UpdateTokenWeight` tx. The
voting module does NOT need hooks (it live-queries cw721-roles on every
call), so the recommended operational policy is to NEVER pass an `AddHook`
proposal without dedicated audit of the hook contract. This is not changed
in this PR — only documented.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The inverted soulbound assertions in `test_minting_and_transfer_permissions`
and `test_send_permissions` used `.unwrap_err().downcast()` to assert the
exact `RolesContractError` variant. The downcast always returned `None`
under `cargo test` and the tests panicked in CI.
Root cause: `cw721-roles` has a dev-dep on `dao-testing`, which depends on
`cw721-roles`. That diamond makes the contract's error type compiled twice
in the unit-test build — same source, distinct TypeIds — so an anyhow
downcast inside `src/tests.rs` to the local `RolesContractError` never
matches the error returned through `dao_testing::contracts::cw721_roles_contract()`.
The same downcast works from a true integration test in `tests/` where
only one build of `cw721-roles` is linked.
Switch to asserting against the error chain's Display strings ("not the
contract's current owner" / "soulbound"), which is robust against the
diamond and still pins the exact rejection.
Also: one rustfmt fix in `dao-voting-cw721-roles/src/testing/mod.rs`
(import ordering) caught by `cargo fmt --all -- --check`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## development #930 +/- ##
===============================================
- Coverage 96.56% 92.05% -4.52%
===============================================
Files 199 159 -40
Lines 67407 28797 -38610
===============================================
- Hits 65094 26510 -38584
+ Misses 2313 2287 -26 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Four deploy-blocking issues in
cw721-rolesanddao-voting-cw721-roles, surfaced while reviewing the contracts for a mainnet membership-NFT DAO. All four are fixed in a single commit. The fixes are surgical: 8 files, +197 / -83.dao-voting-cw721-roles/src/contract.rs(reply handler)cw_ownablehandover never completes. Reply handler initiatesTransferOwnershipto the DAO core but never arranges forAcceptOwnership, so cw721-roles ownership permanently sticks with the voting module — which returnsNoExecute{}from its ownexecute()entry point. Result: after instantiate, noMint/Burn/UpdateTokenWeightcall on cw721-roles will ever succeed again. The DAO is bricked at the membership layer from block 1.cw721-roles/src/contract.rsexecute_transfer/execute_sendtokensmap but never touch theMEMBERSweight map or fireMemberChangedHookMsg. Any successful transfer leaves the old owner with voting weight they don't have NFT backing for, and the new owner with the NFT but no weight. The cw4 view returned bydao-voting-cw721-rolesbecomes permanently incorrect.cw721-roles/src/contract.rsassert_owner(sender). Under the standard DAO-as-owner deployment, a passed proposal can transfer any member NFT to any address.test_minting_and_transfer_permissionsandtest_send_permissionsliterally asserted these transfers succeeded — so the bug was locked in by CI.migrate()entry point. The standard remediation for any of the above post-deploy is a wasm migration; withoutmigrate()there is no recovery path.These are correlated: #1 + #4 are why a deployed instance can't recover; #2 + #3 are symptoms of the same gap in transfer-handler completeness.
Why #1 is non-obvious
cw_ownable::Action::TransferOwnershipis two-phase since 0.4: the current owner initiates (setspending_owner), and the new owner must explicitly callAcceptOwnershipto finalize. The reply handler atcontracts/voting/dao-voting-cw721-roles/src/contract.rs(prior to this PR) emitsTransferOwnershipas aWasmMsg::Execute, but the new owner — the dao-dao-core that instantiated this voting module — never gets told to accept.The natural place to chain
AcceptOwnershipis theModuleInstantiateCallbackchannel: dao-dao-core'sVOTE_MODULE_INSTANTIATE_REPLY_IDhandler already decodesres.dataasModuleInstantiateCallbackand dispatches itsmsgsas the dao-dao-core itself. This PR puts anAcceptOwnershipmessage into that callback, completing the handover atomically inside the original instantiate transaction.A subtle second bug only surfaces once
AcceptOwnershipis wired up: the outercw_ownable::assert_owner(sender)gate at the top ofcw721-roles::executerejectsAcceptOwnershipcalls.AcceptOwnershipmust be called by the pending owner, not the current owner, butassert_ownerrequiressender == current_owner. This PR carves outUpdateOwnership(_)to a match arm that runs before theassert_ownergate and delegates tocw_ownable::update_ownershipvia cw721-base, which auth-checks eachActionvariant correctly (TransferOwnership/RenounceOwnershiprequire current owner;AcceptOwnershiprequires pending owner).Changes
cw721-roles/src/contract.rsExecuteMsg::UpdateOwnership(_)match arm beforeassert_owner. Delegates to cw721-base /cw_ownable::update_ownership, which checks the right sender for each action. Without this, the legitimateAcceptOwnershipcall from the pending owner fails withNotOwnerand the entire two-phase handover is unreachable.execute_transfer/execute_sendnow unconditionally returnContractError::Soulbound{}. This subsumes theMEMBERS-desync bug: no transfer path means no desync path. The contract is now soulbound as a code property, matching the Cargo.toml description.Approve/ApproveAll/Revoke/RevokeAllalso rejected withSoulbound{}. Granting approval implies transferability and is meaningless under soulbound semantics; rejecting explicitly removes a misleading code path.migrate()entry point. No-op state migration; bumps the cw2 version. Standard pattern.cw721-roles/src/error.rsSoulbound{}variant.cw721-roles/src/msg.rsMigrateMsgstruct.cw721-roles/src/tests.rstest_minting_and_transfer_permissionsandtest_send_permissionsinverted: now assert that even the minter / contract owner is rejected withContractError::Soulbound{}. Both tests also checkMEMBERSand ownership remain unchanged after rejection, catching any future regression that attempts to slip the desync bug back in.dao-voting-cw721-roles/src/contract.rsdao_interface::state::ModuleInstantiateCallbackvia.set_data(...)containing anAcceptOwnershipexecute message targeted at the new cw721-roles. The parent dao-dao-core'sVOTE_MODULE_INSTANTIATE_REPLY_IDhandler decodes this and dispatches the message, completing the two-phase ownership handover atomically inside the original instantiate transaction.TransferOwnership(setspending_owner = dao_core) → all voting-module-issued messages drain → dao-dao-core's reply handler fires → dispatchesAcceptOwnershipas itself → cw721-roles ownership flips to the DAO.migrate()entry point.dao-voting-cw721-roles/src/msg.rsMigrateMsgstruct.dao-voting-cw721-roles/src/testing/mod.rssetup_testnow performs theAcceptOwnershipstep explicitly, mocking what dao-dao-core does through the callback channel in production (cw_multi_test does not chain reply data into parent reply handlers the same way). The call is.expect()ed — critical, because the obvious alternative (let _ = app.execute_contract(...)) silently swallows errors and would have hidden the very regression this PR fixes.dao-voting-cw721-roles/src/testing/tests.rstest_voting_queriesupdated to expect cw721-roles ownership belongs toCREATOR_ADDR(the DAO) after handover. Previously it asserted ownership remained with the voting module, which is the bug.Backwards compatibility
cw721-roles: anyone currently relying onTransferNft/SendNft/Approve*/Revoke*to succeed under any caller will see those calls fail withSoulbound{}. The crate has always been documented as non-transferable; this PR makes the documentation true. If a downstream deployment was depending on the bug, the right fix downstream is to stop relying on it.dao-voting-cw721-roles: any existing test setup that worked around the missing handover by mocking ownership differently will need to either remove that workaround or update assertions to expect the DAO as owner. Thesetup_testchange here demonstrates the pattern. Production deployments instantiated through dao-dao-core will start working correctly with no caller-side change.migrate()entry points with no state migration. The cw2 version bumps to the current crate version. Already-deployed instances cannot be migrated to this code without an admin update, but new instantiates pick up the fixes without ceremony.Test plan
cargo check -p cw721-roles -p dao-voting-cw721-roles --lib— clean againstdevelopmentSoulbound{}rejectiondao-testingdep) instantiate the full dao-voting-cw721-roles → cw721-roles chain via cw_multi_test, fireAcceptOwnershipfrom the simulated DAO, and verify:RenounceOwnershipby the DAO still works (governance flexibility)Soulbound{}even when called by the minterMEMBERSweight map stays consistent after rejected transfersmigrate()succeeds and bumps cw2 versioncargo test -p cw721-roles -p dao-voting-cw721-rolesin CI to confirm no regressions in the test environment that hasdao-testing's full dependency chain availableOperational note (no code change in this PR)
cw721-roleshook dispatch usesSubMsg::new(i.e.ReplyOn::Never), so any registered hook contract that panics or runs out of gas will revert the parentMint/Burn/UpdateTokenWeighttransaction.dao-voting-cw721-rolesdoes not need hooks — it live-queriescw721-roleson every voting-power request and holds no cached membership state. Recommend documenting this in the cw721-roles README or treatingAddHookas a power that requires dedicated audit of the hook contract. Not changed here — flagging for a follow-up.