Skip to content

spec: bind coin amount and recipient into coin.identifier (§1.4, clauses 2c/5/10) - #82

Merged
TaprootFreak merged 2 commits into
developfrom
fix/coin-amount-recipient-binding
Jul 10, 2026
Merged

spec: bind coin amount and recipient into coin.identifier (§1.4, clauses 2c/5/10)#82
TaprootFreak merged 2 commits into
developfrom
fix/coin-amount-recipient-binding

Conversation

@TaprootFreak

Copy link
Copy Markdown
Contributor

What

Bind a coin's recipient and amount into its commitment so value and ownership are conserved across account boundaries, not only within a single transition:

coin.identifier = Hc("Coin", prev_account_state_hash ‖ recipient ‖ asset_id ‖ amount ‖ coin_index)

and recompute-and-compare the identifier in-circuit wherever a coin is created, spent, or received (§2.1 clauses 5, 2(c), 10(b)).

Why

Fixes the soundness gap reported in #81. The previous identifier Hc("Coin", prev_account_state_hash ‖ asset_id ‖ coin_index) bound neither amount nor recipient, and output_coins_root commits only identifiers (clause 6), so a coin's per-output amount was never a verifier-checkable public value. Two consequences followed:

  • Inflation on receive. Clause 10 admitted a received coin on provenance / membership / recipient / anchoring but never constrained its amount; clause 7 then credited balances += received_coins[].amount from a free witness. A malicious receiver could credit Y ≫ X for a coin genuinely worth X and spend the difference — directly contradicting the §2.4 "No inflation of others' assets … enforced in-circuit on every hop" guarantee.
  • Cross-account duplication / theft. recipient was likewise unbound, and each account derives its own nullifier (nf = Hc("Nullifier", nk ‖ identifier), distinct nk ⇒ distinct nf), so the same output coin could be credited and spent by two different accounts. Nullifier-based double-spend protection does not catch this.

The §2.3.3 receive gates (decrypt, anchoring completed, nullifier non-membership, amount/asset sanity) are out-of-circuit node-side checks; a malicious receiver runs its own prover and builds its own witness, so they are not the soundness boundary. The in-circuit compliance predicate C is — and no clause of it referenced the received/input amount.

How

Confined to docs/specification.md:

  • §1.4 / glossary / §1.7.3coin.identifier preimage is now prev_account_state_hash ‖ recipient ‖ asset_id ‖ amount ‖ coin_index; §1.7.3 pins the normative absorption order and per-field encoding.
  • Clause 5 — output construction builds each identifier over the full tuple, so recipient/amount are committed into output_coins_root.
  • Clause 2(c) — input-coin recompute over the full tuple, so the amount fed to clause-3 conservation is the committed one (recipient cross-checked against owner in 2(a)).
  • Clause 10(b) — upgraded from a bare membership check to recompute-and-compare: recompute the identifier from creating_prev_ash ‖ recipient ‖ asset_id ‖ amount ‖ leaf_index, require it to equal the coin's identifier and be an output_coins_root member. received_auth[] gains creating_prev_ash (already delivered in the CoinProof bundle, §1.5). coin_index reuses inclusion_proof.leaf_index (equal to the creating coin_index_k by the clause-5 canonical order) — no new witness field.
  • §2.4 — soundness rows updated so the "No inflation" / "No fabricated receipts" guarantees reflect the cross-hop binding.
  • §2.3.3 step 6 — clarified as an out-of-circuit early reject; the in-circuit clause 10(b) is the actual boundary.
  • Test vector V.4coin.identifier@0 formula updated (bytes remain <REGEN>; no Poseidon values invented).

Determinism and privacy are unchanged: recipient and amount are creation-time CoinTemplate fields (no dependency on new_account_state_hash, so the non-recursion property holds) and stay in the witness — only the identifier and the roots over it are public (clause 9).

Checklist

  • Lint passes — not applicable (documentation only)
  • Build succeeds (npm run build) — verified by CI
  • No secrets in code or commits
  • Documentation updated (this change is the normative spec)

Closes #81

Fold recipient and amount into the coin commitment so per-asset value
conservation holds across account boundaries, not only within a single
transition:

  coin.identifier =
    Hc("Coin", prev_account_state_hash ‖ recipient ‖ asset_id ‖ amount ‖ coin_index)

The previous identifier bound neither amount nor recipient, and
output_coins_root commits only identifiers, so a coin's per-output amount
was never a verifier-checkable value. This let a receiving account credit
an arbitrary amount (clause 10 + clause 7) and, because recipient was
likewise unbound and each account derives its own nullifier, let two
accounts credit and spend the same coin.

Recompute-and-compare the identifier over the full tuple wherever a coin
is created (clause 5), spent (clause 2c), or received (clause 10b); add
creating_prev_ash and coin_index to the input/received witnesses so the
recompute is well-sourced. Update the §1.4 definition, the §1.7.3 preimage
order, the §2.4 soundness rows, the §2.3.3 receive note, the glossary and
test vector V.4 accordingly (Poseidon bytes stay <REGEN>).

Closes #81
Align the continuation-line indentation of the coin_index (input_auth[])
and creating_prev_ash (received_auth[]) comments with the surrounding
single-space // style; comment-only, no normative change.
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Verification & review status

Exploitability confirmed. Tracing the compliance predicate C: on receive, received_coins[j].amount is a free witness — clause 10 constrains provenance / membership / recipient / anchoring but never the amount, and clause 7 then credits balances += received_coins[].amount. The §2.3.3 receive gates (decrypt, anchoring completed, nullifier non-membership, amount/asset sanity) are out-of-circuit, so a self-hosted malicious prover builds its own witness and evades them; the in-circuit predicate is the only soundness boundary and it did not bind the amount. The same held for recipient, so — because each account derives its own nullifier (nf = Hc("Nullifier", nk ‖ identifier), distinct nk ⇒ distinct nf) — the same output coin could be credited and spent by two different accounts. Both the receive-time inflation and the cross-account duplication vectors reproduce.

Fix. Fold recipient and amount into coin.identifier and recompute-and-compare it in-circuit at clause 5 (create), clause 2(c) (spend) and clause 10(b) (receive). Membership of the recomputed identifier in output_coins_root can hold only for the exact (recipient, asset_id, amount) the creating account assigned, so an inflated amount or a redirected recipient yields a non-member identifier (Poseidon collision-resistance). Determinism and privacy are unchanged — recipient/amount are creation-time CoinTemplate fields and stay witness-only.

Review. Two rounds across four dimensions — soundness/logic, spec conformity, completeness (no missed derivation site, no broken anchor, no contradicting claim elsewhere in docs/), and a PII/secrets sweep. Soundness and PII clean in round 1; the conformity/completeness nits (clause 2(c) operand qualification, input_auth[] coin_index provenance, witness-comment indentation) are all resolved. Round 2 re-confirmed soundness clean. CI is green — the Docusaurus build passes, so no broken internal anchors/links.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 10, 2026 19:16
@TaprootFreak
TaprootFreak merged commit 63386db into develop Jul 10, 2026
5 checks passed
TaprootFreak added a commit that referenced this pull request Jul 10, 2026
…t_nullifiers_root (C_batch) (#83)

Closes a fork double-spend / inflation hole of the same class as the
coin-amount binding (#82), applied to the double-spend commitment itself.

The nullifiers inserted into the global accumulator came from the
free-witness SpendRecord.nullifiers list (§2.2 clause 3: batch_nullifiers
= union of member lists), but no clause bound that list to the
input_nullifiers_root (inr) each per-account proof committed. clause 1
binds only the root inr to ProofData (root-to-root) and never opens it to
the individual nf, and clause 9 keeps the nf private to C. A spender could
prove an honest spend (inr = NullifiersRoot([nf_C]), signed message =
inr ‖ ocr) yet hand the publisher a SpendRecord with nullifiers = [],
k = 0 — the AggregateBatchProof still verifies, nf_C is never inserted,
and the coin is re-spent in a fork.

Fix: §2.2 clause 3 now requires, for each member,
NullifiersRoot(SpendRecordⱼ.nullifiers) == ProofDataⱼ.input_nullifiers_root
and k == |nullifiers| (the §1.7.5 NullifiersRoot construction). C_batch
cannot re-derive the nf from nk (private to C), but it can open the
committed root to the declared list — both are available — so
batch_nullifiers is provably the opening of each member's inr and no nf
can be dropped or substituted. NullifiersRoot([]) = L_⊥ ≠ inr makes the
nullifiers=[] proof unsatisfiable; the empty-list case (mint/receive,
inr = L_⊥, k = 0) passes trivially.

Also anchors the check at the operative insertion site (§2.5 leaf mode,
clauses 1–3) and hardens the §2.1 clause 4 cross-reference and the §2.4 /
§3.6 step 7-8 prose. No test vectors affected.
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.

Coin amount is not bound to any verifier-checked commitment (§2.1) — cross-account value conservation not enforced

1 participant