Skip to content

standards: add DRC20 primitive and reference#41

Merged
HDauven merged 3 commits into
mainfrom
standards/04-drc20
Jun 5, 2026
Merged

standards: add DRC20 primitive and reference#41
HDauven merged 3 commits into
mainfrom
standards/04-drc20

Conversation

@HDauven

@HDauven HDauven commented Jun 4, 2026

Copy link
Copy Markdown
Member

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-multisig branch.

Scope

  • Adds the reusable token::drc20 primitive with metadata, balances, allowances, mint/burn, cap, pausable, voting-unit/checkpoint, and signed approval support.
  • Adds DRC20 transfer/approval event payloads with Forge event metadata behind the forge feature.
  • Adds a Forge reference contract at standards/examples/drc20_roles_pausable composing DRC20 with access control, pausing, capped minting, votes, signed approvals, and data-driver support.
  • Adds AccessControl::init_admin_with_roles so reference contracts can seed initial roles during the same one-time admin initialization gate while regular role mutations still require fresh authorization witnesses.
  • Adds a test-only Moonlight call router so the reference contract VM test exercises the observed-caller transfer path. The router is kept in STANDARDS_TEST_HELPERS; data-driver generation only runs over STANDARDS_EXAMPLES.

Tracking

Review focus

  • DRC20 accounting and allowance semantics.
  • Pause behavior for balance-changing operations.
  • Signed approval/action envelope binding.
  • Access-control composition in the Forge reference, especially initial role seeding and witness-based grant/revoke paths.
  • Event and data-driver feature-gating hygiene.

Evidence map

  • standards/dusk-contract-standards/tests/drc20.rs covers 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.rs deploys 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-ci builds/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-drivers builds data-driver WASM for production reference examples only, excluding test helpers.
  • Controlecentrum deep review with Codex 5.5 xhigh on head 96bd109 returned APPROVE with zero validated findings.

Validation

  • cargo +nightly-2026-02-27 test -p dusk-contract-standards --test drc20_reference_vm -- --nocapture
  • make standards-ci
  • make standards-data-drivers

Notes

@HDauven HDauven force-pushed the standards/04-drc20 branch from f0d65b5 to a0fd27a Compare June 4, 2026 09:59
@HDauven HDauven marked this pull request as ready for review June 4, 2026 10:30
@HDauven HDauven requested a review from moCello June 4, 2026 10:31
votes: VotingUnits::new(),
}
}
pub fn init(&mut self, args: Init) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment on lines +100 to +112
/// 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 });
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment on lines +249 to +256
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(|| {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 moCello left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@HDauven HDauven force-pushed the standards/04-drc20 branch 2 times, most recently from 458a178 to 11fbd51 Compare June 4, 2026 19:41
@HDauven

HDauven commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

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.

@HDauven HDauven requested a review from moCello June 4, 2026 19:52
Comment on lines +81 to +82
#[ignore = "requires release DRC20 reference Wasm; run after the wasm build"]
fn drc20_reference_contract_enforces_composed_security_paths() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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 wasm
  • cargo +nightly-2026-02-27 test -p dusk-contract-standards --test drc20_reference_vm -- --nocapture
  • make standards-ci


#[test]
#[ignore = "requires release DRC20 reference Wasm; run after the wasm build"]
fn drc20_reference_contract_enforces_composed_security_paths() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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).

Comment on lines +289 to +290
if current < amount {
panic!("DRC20: balance too low");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment on lines +246 to +249
pub fn transfer(&mut self, args: TransferCall) {
self.pausable.assert_not_paused();
let caller = caller();
let event = self.token.transfer(caller, args);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 moCello left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@HDauven HDauven force-pushed the standards/04-drc20 branch from 630153a to 96bd109 Compare June 5, 2026 10:20
@HDauven HDauven requested a review from moCello June 5, 2026 10:46
Comment on lines +167 to +169
assert_balance_votes_mirror(&mut session, contract, admin);
assert_balance_votes_mirror(&mut session, contract, moonlight_owner);
assert_total_votes_mirror(&mut session, contract);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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).

Comment on lines +221 to +223
pub fn cap(&self) -> u64 {
self.cap.map(|cap| cap.cap()).unwrap_or(0)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread standards/CHANGELOG.md

### Added

- Added the DRC20 standards primitive, DRC20 event payloads, token extension

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 moCello left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@HDauven

HDauven commented Jun 5, 2026

Copy link
Copy Markdown
Member Author

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.

@HDauven HDauven merged commit bc1b00e into main Jun 5, 2026
5 checks passed
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.

2 participants