spec: bind coin amount and recipient into coin.identifier (§1.4, clauses 2c/5/10) - #82
Conversation
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.
Verification & review statusExploitability confirmed. Tracing the compliance predicate Fix. Fold Review. Two rounds across four dimensions — soundness/logic, spec conformity, completeness (no missed derivation site, no broken anchor, no contradicting claim elsewhere in |
…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.
What
Bind a coin's
recipientandamountinto its commitment so value and ownership are conserved across account boundaries, not only within a single transition: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 neitheramountnorrecipient, andoutput_coins_rootcommits only identifiers (clause 6), so a coin's per-outputamountwas never a verifier-checkable public value. Two consequences followed:recipient/ anchoring but never constrained itsamount; clause 7 then creditedbalances += received_coins[].amountfrom a free witness. A malicious receiver could creditY ≫ Xfor a coin genuinely worthXand spend the difference — directly contradicting the §2.4 "No inflation of others' assets … enforced in-circuit on every hop" guarantee.recipientwas likewise unbound, and each account derives its own nullifier (nf = Hc("Nullifier", nk ‖ identifier), distinctnk⇒ distinctnf), 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 predicateCis — and no clause of it referenced the received/inputamount.How
Confined to
docs/specification.md:coin.identifierpreimage is nowprev_account_state_hash ‖ recipient ‖ asset_id ‖ amount ‖ coin_index; §1.7.3 pins the normative absorption order and per-field encoding.recipient/amountare committed intooutput_coins_root.amountfed to clause-3 conservation is the committed one (recipientcross-checked againstownerin 2(a)).creating_prev_ash ‖ recipient ‖ asset_id ‖ amount ‖ leaf_index, require it to equal the coin's identifier and be anoutput_coins_rootmember.received_auth[]gainscreating_prev_ash(already delivered in theCoinProofbundle, §1.5).coin_indexreusesinclusion_proof.leaf_index(equal to the creatingcoin_index_kby the clause-5 canonical order) — no new witness field.coin.identifier@0formula updated (bytes remain<REGEN>; no Poseidon values invented).Determinism and privacy are unchanged:
recipientandamountare creation-timeCoinTemplatefields (no dependency onnew_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
npm run build) — verified by CICloses #81