Skip to content

fix(security): refuse builtin ERA/dBTC issuance at the accepting transition - #721

Merged
cryptskii merged 1 commit into
mainfrom
fix/builtin-mint-accepting-layer-authorization
Aug 24, 2026
Merged

fix(security): refuse builtin ERA/dBTC issuance at the accepting transition#721
cryptskii merged 1 commit into
mainfrom
fix/builtin-mint-accepting-layer-authorization

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

The defect

Any wallet user could mint unlimited ERA or dBTC via the production token.mint route. Verified link by link:

  1. handle_token_mint (token_routes.rs:1871) checks only amount > 0, then signs its own authorization with the caller's device key and sets authorized_by: dev_id.
  2. resolve_token_for_value_op returns the builtin policy commit for ERA/dBTC.
  3. ERA's preloaded policy (create_root_token_policy) sets only name/description/metadata — zero conditions, zero roles — so enforce_policy iterates nothing and returns allowed. dBTC has no registered policy and takes the builtin escape hatch (policy/mod.rs:194-205).
  4. DeviceState::advance never calls enforce_operation_authorization; its only authorization call is gated to DlvSettle | DlvOwnerApply | DlvClose.

validate_conservation checks only that the single credit delta matches the amount and asset the same caller signed. Nothing anywhere established a right to issue.

ERA is a tradeable AMM leg, so minted-from-air ERA buys real assets out of a vault; dBTC is meant to be Bitcoin-backed. The bilateral path does not check sender balances either, so minted units also spend to counterparties.

The fix

At DeviceState::advance, not on the route. A route guard binds only the callers that pass through it — any future route or direct advance caller would silently reopen the hole.

Keyed on policy_commit, not the ticker. That is what validate_conservation binds the credit delta to, what the balances map is keyed by, and what the compat projection resolves a ticker from. A mint carrying the string "ERA" with a non-builtin commit credits that non-builtin asset and can never project as ERA, so rejecting on the string would refuse honest issuance while closing nothing. A second test pins that narrowness.

SupplyCap is deliberately not the basis. It reads circulating_le from caller-supplied enforcement context, and no canonical producer authenticates that number.

Proven by mutation, not by a green suite

remove the gate  ->  the named test fails because the advance SUCCEEDS,
                     balance witness = 18446744073709551615 ERA (u64::MAX, from air)
restore the gate ->  the mint is refused

A green suite around a gate proves nothing about whether the gate is load-bearing. Both tests assert on the specific message rather than is_err(), so an unrelated error cannot credit the guard.

The faucet was the same defect

faucet.claim minted builtin ERA on a caller-supplied device_id plus a local cooldown — no verifiable right to issue. It now returns an explicit refusal and check_nearby reports unavailable. The minting body is deleted, not left unreachable: a dead path that still knows how to mint is what a later edit resurrects. No exemption, no dev flag, no faucet key — the accepting gate stays the only control.

Consequence: device funding needs an authenticated issuance predicate before the two-phone rig can fund fresh devices. rig_dlv_market.py drives the UI and does not call the faucet API, so phones already holding legitimate ERA can still run the DLV flow. Authenticated testnet issuance is follow-up work and can later become an AuthorizedIssuance source.

Fixtures

Two fixtures funded heads by minting builtin ERA and now use DeviceState::restore — the shape a reloaded device actually has. Worth noting the asymmetry: in the projection tests ERA is genuinely load-bearing (the projection resolves tickers from the builtin commit, so a synthetic asset would project as empty and the assertions would test nothing); in the state-machine test the asset was incidental. Each broken fixture marked a place the codebase had been relying on the unauthorized capability.

Verification

  • dsm lib: 1658 passed / 0 failed
  • dsm_sdk lib: 1772 passed / 0 failed (7 ignored)
  • make lint from the repo root: passed
  • Mutation control recorded above, run and restored.

…sition

Any wallet user could mint unlimited ERA or dBTC. Four layers each declined
to authorize it: handle_token_mint signs its OWN authorization and stamps
authorized_by with the caller's device id; resolve_token_for_value_op
returns the builtin commit; ERA's preloaded policy carries zero conditions
and zero roles so enforce_policy iterates nothing and returns allowed while
dBTC has no policy and takes the builtin escape hatch; and advance never
calls enforce_operation_authorization, its only authorization call being
gated to the three DLV ops. validate_conservation checks only that the
single credit delta matches the amount and asset the same caller signed.
Nothing established a RIGHT to issue.

ERA is a tradeable AMM leg, so minted-from-air ERA buys real assets out of
a vault, and dBTC is meant to be Bitcoin-backed.

The gate is at DeviceState::advance, not on the route: a route guard binds
only the callers that pass through it, so any future route or direct
advance caller would silently reopen the hole. It is keyed on
policy_commit, which is what validate_conservation binds the credit delta
to, what the balances map is keyed by, and what the compat projection
resolves a ticker FROM — not on the token_id string, since a builtin ticker
with a non-builtin commit credits that non-builtin asset and can never
project as ERA, so a string check would refuse honest issuance while
closing nothing.

Proven by mutation, not by a green suite: delete the block and the test
fails because the advance SUCCEEDS, with the balance witness carrying
18446744073709551615 ERA; restore it and the mint is refused.

SupplyCap is deliberately not the basis — it reads circulating_le from
caller-supplied enforcement context and no canonical producer authenticates
that number.

faucet.claim was the same defect: it minted builtin ERA on a caller-supplied
device_id plus a local cooldown. It now refuses explicitly and
check_nearby reports unavailable; the minting body is DELETED rather than
left unreachable, because a dead path that still knows how to mint is what
a later edit resurrects. No exemption, no dev flag, no faucet key — the
accepting gate stays the only control. Consequence: device funding needs an
authenticated issuance predicate before the two-phone rig can fund fresh
devices.

Two fixtures funded heads by minting builtin ERA and now use
DeviceState::restore, the shape a reloaded device actually has. In the
projection tests ERA is load-bearing (the projection resolves tickers from
the builtin commit, so a synthetic asset would project as empty and assert
nothing); in the state-machine test it was incidental.

dsm 1658/0; dsm_sdk 1772/0; make lint green.
@cryptskii
cryptskii merged commit 7b57d4f into main Aug 24, 2026
16 of 18 checks passed
@cryptskii
cryptskii deleted the fix/builtin-mint-accepting-layer-authorization branch August 24, 2026 23:42
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.

1 participant