standards: add DRC20 primitive and reference#41
Conversation
f0d65b5 to
a0fd27a
Compare
| votes: VotingUnits::new(), | ||
| } | ||
| } | ||
| pub fn init(&mut self, args: Init) { |
There was a problem hiding this comment.
WARNING: Reference contract's composed behavior has no behavioral test — only WASM build verification.
Context: The primitive is well covered in tests/drc20.rs, but the composition this PR showcases is unasserted: pause gating on transfer/transfer_from/mint/burn, cap enforcement in mint, votes-hook wiring, signed-approval envelope binding in approve_by_authorization, and role seeding via init_admin_with_roles — exactly the PR's stated review-focus items. The example is #![cfg(target_family = "wasm")], so host unit tests can't reach it and nothing deploys it to a VM. This matches the untested multisig_controller example, so whether the build-only convention should hold for security-sensitive composition is a maintainer judgment call.
Fix: Add a dusk-vm integration test exercising at least the pause→reject, cap→reject, and signed-approve binding paths, or confirm the build-only convention is intended for these examples.
| /// Writes a checkpoint. | ||
| pub fn push(&mut self, key: u64, value: u64) { | ||
| if let Some(last) = self.checkpoints.last_mut() { | ||
| if key < last.key { | ||
| panic!("Checkpoints: non-monotonic key"); | ||
| } | ||
| if key == last.key { | ||
| last.value = value; | ||
| return; | ||
| } | ||
| } | ||
| self.checkpoints.push(Checkpoint { key, value }); | ||
| } |
There was a problem hiding this comment.
NOTE: The equal-key overwrite branch (key == last.key) has no unit test.
Context: tests/drc20.rs only pushes distinct timepoints (10/11/12/13), so the overwrite path is never asserted, though same-timepoint writes hit it at runtime — multi-holder genesis seeding pushes total_supply repeatedly at one now(), and same-block transfers touching one account do too. The branch collapses those into one checkpoint per timepoint. It is not a correctness guard: get_at/latest return the last-written value whether equal keys overwrite or append, so a regression bloats the checkpoint vec (unbounded per-timepoint growth) rather than corrupting totals. Cheap to cover without a VM.
Fix: Add a Checkpoints unit test pushing two values at the same key, asserting latest/get_at return the second and entries() keeps a single entry.
| pub fn transfer(&mut self, args: TransferCall) { | ||
| self.pausable.assert_not_paused(); | ||
| let caller = caller(); | ||
| let event = { | ||
| let guard = &mut self.guard; | ||
| let token = &mut self.token; | ||
| let votes = &mut self.votes; | ||
| guard.run(|| { |
There was a problem hiding this comment.
NOTE: ReentrancyGuard wraps only transfer/transfer_from, not mint/burn, and none of these make outbound calls — the guard protects no real re-entry vector.
Context: As a reference others copy, a guard applied to two of four balance-changing methods that guards nothing is misleading.
Fix: Drop the guard from this example, or apply it uniformly across all balance-changing entry points with a comment on what re-entry it defends.
| [target.'cfg(target_family = "wasm")'.dependencies] | ||
| dusk-core = "=1.6.0" | ||
| dusk-data-driver = { version = "=0.3.2-alpha.1", optional = true } | ||
| dusk-forge = "=0.3.0" |
There was a problem hiding this comment.
NOTE: dusk-forge is pinned directly here while the workspace defines it and the sibling multisig_controller example uses { workspace = true }.
Fix: Use dusk-forge = { workspace = true } to avoid drift when the workspace pin is bumped.
moCello
left a comment
There was a problem hiding this comment.
COMMENT — Primitive accounting and auth binding are correct and the primitive is well tested; no blockers. One gap worth closing: the reference contract's composed behavior (pause, cap, votes, signed-auth binding, role seeding) — the PR's stated review focus — is build-verified only, with no test deploying it.
This PR adds a substantial new public surface — the token::drc20 standard and AccessControl::init_admin_with_roles (src/lib.rs:33, src/access/access_control.rs) — without a tracking issue. A new token standard others will implement against carries design and downstream-coordination weight that a PR description doesn't capture: please open an issue for the DRC20 standard and link it here. The detailed body and the stacked-PR context help, but a tracking issue is what gives the new surface visibility beyond this PR.
Minor notes: an untested equal-key branch in Checkpoints::push (benign — storage cleanliness, not correctness), an inert reentrancy guard applied to only 2 of 4 mutators in the example, and a direct dusk-forge pin that should use the workspace dep. One design note: Phoenix holders have no direct self-transfer path (only signed-approve + a Moonlight spender via transfer_from) — funds aren't locked, but worth stating in the example's docs.
458a178 to
11fbd51
Compare
|
Adjusted #42 into the actual DRC20 standard tracking issue requested in review. It now tracks the public DRC20 standard surface, Dusk-specific principal/auth semantics, reference contract expectations, v1 acceptance criteria, and follow-up split points instead of being a generic cleanup bucket. |
| #[ignore = "requires release DRC20 reference Wasm; run after the wasm build"] | ||
| fn drc20_reference_contract_enforces_composed_security_paths() { |
There was a problem hiding this comment.
WARNING: This test is #[ignore]d for a reason that's cheap to fix — it needs the example wasm built first — yet the workaround leaves a real, asserting security test (cap→reject, mint/approve replay→reject, pause→reject on transfer/transfer_from/burn/mint, Phoenix+Moonlight signed-approve binding) permanently unexecuted in CI.
Context: The ignore exists only because the test deploys the release example wasm into a VM and nothing guarantees that wasm is built before the test runs. But CI already builds that example wasm anyway, so the stated reason for the ignore is one step away from being satisfied — the test isn't gated on anything expensive or unavailable.
Fix: Build the example wasm before the standards test runs, then remove #[ignore] so the test executes as part of standards-ci.
There was a problem hiding this comment.
Fixed in 3bda524. standards-ci now builds standards Wasm before running standards tests, and the DRC20 reference VM test is no longer ignored, so it executes as part of the normal standards test pass.
Validated locally with:
make -C standards/examples/drc20_roles_pausable wasmcargo +nightly-2026-02-27 test -p dusk-contract-standards --test drc20_reference_vm -- --nocapturemake standards-ci
|
|
||
| #[test] | ||
| #[ignore = "requires release DRC20 reference Wasm; run after the wasm build"] | ||
| fn drc20_reference_contract_enforces_composed_security_paths() { |
There was a problem hiding this comment.
NOTE: Even once it runs, the only composed-runtime test omits two review-focus surfaces: it never queries latest_votes/past_votes/latest_total_votes and never calls grant_role/revoke_role — so the example's votes wiring and the witnessed grant/revoke paths stay unverified.
Context: The body covers cap→reject, mint/approve replay→reject, pause→reject across transfer/transfer_from/burn/mint, and Phoenix+Moonlight signed-approve binding, but skips the checkpoint/vote endpoints and the role-management paths entirely. Distinct from the wiring WARNING above (which is about the test not running at all) and related to the votes↔balances NOTE below (which asks specifically for the mirror invariant): this is the broader gap that the sole composed test exercises neither vote nor role surface.
Fix: Extend the test to assert a votes trajectory across transfer+mint+burn (latest_votes == balance_of, latest_total_votes == total_supply) and a witnessed grant_role/revoke_role pair.
| authorizations: AuthorizationManager, | ||
| pausable: Pausable, | ||
| cap: Option<SupplyCap>, | ||
| votes: VotingUnits, |
There was a problem hiding this comment.
NOTE: votes is a second store that must stay equal to balances, synced only by a manual move_units after every balance mutation — and the equality is never asserted in any test.
Context: The mirror is hand-applied at five sites — init, transfer, transfer_from, mint, burn — all correct today. The VotingUnits doc already tells composers to mirror after each hook, but no test pins latest_votes == balance_of / latest_total_votes == total_supply for the composed contract: the VM integration test never queries the vote endpoints, and the primitive's own tests exercise VotingUnits in isolation rather than against live balances. A future balance-changing method that forgets the mirror silently desyncs, after which burn's move_units panics with "insufficient units" or past_votes lies. This is a reference others copy — the discipline is exactly what gets dropped on copy.
Fix: Assert the votes↔balances invariant in a test after representative composed ops (transfer/mint/burn).
| if current < amount { | ||
| panic!("DRC20: balance too low"); |
There was a problem hiding this comment.
NOTE: DRC20 introduces inline panic strings while every other standards primitive routes panics through core::error constants — this is the sole module that deviates.
Context: All ~40 panic sites across pausable/ownable/access_control/owner_set/auth use error::CONSTANT exclusively, with named constants even for domain-specific conditions (INVALID_OWNER, INVALID_NONCE, REPLAY, EXPIRED). DRC20 instead inlines "DRC20:"/"Checkpoints:"/"VotingUnits:" strings at mod.rs:207,234,290,337 and extensions.rs:41,55,104,215,239 — and burn_internal uses error::UNDERFLOW for supply two lines below this inline balance message. core::error is the documented stable-string surface for the reusable primitives; bypassing it means downstream can't match on a stable constant and a typo isn't caught.
Fix: Move the recurring DRC20/checkpoint/votes messages into core::error (or a token-scoped error module) and reference them, matching every other primitive.
| pub fn transfer(&mut self, args: TransferCall) { | ||
| self.pausable.assert_not_paused(); | ||
| let caller = caller(); | ||
| let event = self.token.transfer(caller, args); |
There was a problem hiding this comment.
NOTE: A Phoenix-key holder has no direct self-transfer path in the example, and this isn't documented.
Context: caller() is require_principal, and CallContext::current() only ever resolves to a Moonlight or contract principal (a Phoenix tx exposes no public_sender, so the context is empty and require_principal panics). A Phoenix holder can therefore only move funds via approve_by_authorization + a Moonlight spender calling transfer_from. Funds aren't locked, but a reader copying this example will expect transfer to work for any holder. Carried forward from the prior review.
Fix: Add a doc line on transfer/the contract module stating Phoenix holders authorize via signed approval rather than a direct transfer call.
moCello
left a comment
There was a problem hiding this comment.
COMMENT — Accounting, allowance semantics, signed-approval/envelope binding, the replay/domain matrix, cap-no-bypass, rkyv compat, and the new init_admin_with_roles gate all check out under strong host tests, and most prior-round findings are resolved. Nothing blocks merge. The one thing worth doing before merge: the composed-security VM test is the only runtime coverage of the example and it currently doesn't run — un-ignore it and build the example wasm in CI (a cheap, already-half-met prerequisite), then extend it to the votes and grant/revoke paths it skips. The notes (votes↔balances invariant, DRC20 panic strings bypassing core::error, undocumented Phoenix self-transfer) are polish on a reference others will copy. Link tracking issue #42 in the description.
630153a to
96bd109
Compare
| assert_balance_votes_mirror(&mut session, contract, admin); | ||
| assert_balance_votes_mirror(&mut session, contract, moonlight_owner); | ||
| assert_total_votes_mirror(&mut session, contract); |
There was a problem hiding this comment.
NOTE: The composed contract's vote checkpoints are only ever read at one block height, so the historical-query path is never exercised in the VM.
Context: The test reuses a single genesis_session (drc20_reference_vm.rs:117) and never advances block height, so now() (= abi::block_height(), lib.rs:544) is constant. Every move_units across init/mint/transfer/burn pushes onto the same checkpoint key (the equal-key overwrite branch), collapsing each trace to a single entry. The mirror helpers query past_votes/past_total_supply only at timepoint: u64::MAX (drc20_reference_vm.rs:977,991), which returns the latest value — so they pass whether or not history is recorded. The checkpoint mechanism is covered at the primitive layer (drc20.rs:442-456 asserts past_votes/past_total_supply at distinct timepoints), but the reference contract's headline historical-vote feature has no composed-runtime coverage that a past timepoint returns a prior (non-latest) value.
Fix: Advance the session block height between two balance-changing ops, then assert past_votes/past_total_supply at the earlier height return the earlier balance/supply (not the latest).
| pub fn cap(&self) -> u64 { | ||
| self.cap.map(|cap| cap.cap()).unwrap_or(0) | ||
| } |
There was a problem hiding this comment.
NOTE: cap() returns the sentinel 0 for an uncapped token, with no doc note on the getter.
Context: init treats cap == 0 as uncapped (lib.rs:168,179), so a literal zero-cap token is unconstructable — there is no reachable ambiguous state. But the getter reports 0 for an unlimited-supply token, which reads as "supply frozen at 0"; a copier wiring this to a UI could surface "Max supply: 0" for an uncapped token. Only remaining_mintable() (returns u64::MAX when uncapped, lib.rs:228) signals uncapped. The Init field documents the sentinel (lib.rs:41); the getter does not. This is a reference contract others copy.
Fix: Add the same sentinel doc comment to cap(). (Returning Option<u64> would be unambiguous but desyncs the getter from the u64 Init input, so it only makes sense if Init.cap becomes Option<u64> too.)
| self.votes.latest_total_supply() | ||
| } | ||
|
|
||
| pub fn past_total_supply(&self, timepoint: u64) -> u64 { |
There was a problem hiding this comment.
NOTE: Vote-query surface mixes terminology: latest_total_votes paired with past_total_supply.
Context: The account pair is latest_votes/past_votes (lib.rs:231,235). The contract renames the primitive's latest_total_supply to latest_total_votes but leaves the past total as past_total_supply — inconsistent within the contract's own surface. This is a reference contract others copy, so the mismatch propagates.
Fix: Rename past_total_supply to past_total_votes (or align both totals to _supply) for a consistent query naming pair.
|
|
||
| ### Added | ||
|
|
||
| - Added the DRC20 standards primitive, DRC20 event payloads, token extension |
There was a problem hiding this comment.
NOTE: Changelog entry omits the tracking issue link.
Context: The PR tracks the DRC20 standard under #42; the repo convention references the originating issue inline in changelog entries (e.g. genesis/stake and genesis/transfer changelogs carry [#NNNN]: link definitions).
Fix: Reference [#42] in the entry and add a [#42]: https://github.com/dusk-network/contracts/issues/42 link definition at the bottom of the file.
moCello
left a comment
There was a problem hiding this comment.
APPROVE — Accounting, allowance, cap, pause, signed-approval/role binding, and the votes mirror all check out under strong host and VM tests, and every finding from both prior rounds is resolved. Four NOTE-level polish items remain (single-block-height vote coverage, cap-zero sentinel, vote-query naming pair, changelog issue link); none blocks merge.
|
Thanks, I tracked the four NOTE-level polish items in #51 so we can merge this approved DRC20 slice without churning the reviewed history again. |
Adds the DRC20 slice of the Dusk contract standards stack on top of the merged access/multisig foundation. This replaces the old closed stacked PR #30, which targeted the now-merged/deleted
standards/03-access-multisigbranch.Scope
token::drc20primitive with metadata, balances, allowances, mint/burn, cap, pausable, voting-unit/checkpoint, and signed approval support.forgefeature.standards/examples/drc20_roles_pausablecomposing DRC20 with access control, pausing, capped minting, votes, signed approvals, and data-driver support.AccessControl::init_admin_with_rolesso reference contracts can seed initial roles during the same one-time admin initialization gate while regular role mutations still require fresh authorization witnesses.STANDARDS_TEST_HELPERS; data-driver generation only runs overSTANDARDS_EXAMPLES.Tracking
Review focus
Evidence map
standards/dusk-contract-standards/tests/drc20.rscovers DRC20 accounting invariants, failed-operation rollback, overflow rejection, supply cap behavior, voting-unit/checkpoint hooks, event topic pinning, and rkyv/serde event payload roundtrips.standards/dusk-contract-standards/tests/drc20_reference_vm.rsdeploys the Forge reference contract in the VM and covers signed Phoenix and Moonlight approvals, replay rejection, wrong owner/domain/payload rejection, cap overflow rejection, pause blocking, signed role grant/revoke, votes mirroring balances/supply, direct-call rejection without an observed principal, routed Moonlight transfer/burn, and emitted Transfer/Approval event topic+payload decoding from VM receipts.make standards-cibuilds/clippies the standards crate, builds/clippies all standards WASM examples plus the test-only Moonlight router, and runs the standards test suite after WASM artifacts exist.make standards-data-driversbuilds data-driver WASM for production reference examples only, excluding test helpers.96bd109returned APPROVE with zero validated findings.Validation
cargo +nightly-2026-02-27 test -p dusk-contract-standards --test drc20_reference_vm -- --nocapturemake standards-cimake standards-data-driversNotes
AccessControl::renounce_roleargument order.