Skip to content

spec: hide the next_pubkey rotation edge behind a per-transition commitment - #86

Merged
TaprootFreak merged 1 commit into
developfrom
fix/hide-next-pubkey-rotation-edge
Jul 11, 2026
Merged

spec: hide the next_pubkey rotation edge behind a per-transition commitment#86
TaprootFreak merged 1 commit into
developfrom
fix/hide-next-pubkey-rotation-edge

Conversation

@TaprootFreak

Copy link
Copy Markdown
Contributor

Problem

PR #85 bound next_pubkey into the signed SpendRecord message so the account's single transition signature authorises the key rotation on every transition (custody — closes the operational-bundle-node account takeover via an unbatched receive).

But it placed the raw next_pubkey = Pkᵢ₊₁ in the clear in the public, plaintext BatchBundle, next to each record's public_key = Pkᵢ. Since next_pubkey(txn i) == public_key(txn i+1), any observer fetching the public bundles can chain an account's consecutive SpendRecords by the rotation edge — a public, cross-transition account linkage of the transaction graph.

This contradicts the unlinkability the spec itself states against a BatchBundle scanner:

  • §2.3.2: "the publisher still cannot link this SpendRecord to the account's prior records"
  • §3.5 metadata: "the transaction graph … remain invisible to a chain-only observer"
  • §5.5 public mode: "per-spender activity remains hidden behind the bundle boundary"
  • Requirement 2 (no linking two on-chain events to the same account)

It is a regression: before #85, message = inr ‖ ocr (64 B) and next_pubkey was witness-only, so the rotation edge was never published.

Fix

Make the third message segment a hiding commitment rather than the raw key:

next_pubkey_commit = H("zkCoins/v1/NextPubkeyCommit" ‖ next_pubkey ‖ npk_rand) (SHA-256, wallet-native, no Poseidon; npk_rand = fresh secret 32-byte blind the wallet draws per transition).

  • message = inr ‖ ocr ‖ next_pubkey_commit — still 96 bytes; all SpendRecord sizes unchanged (193 + 32·|nf|, 225 single-input, 193 mint).
  • §2.1 clause 2 additionally checks the opening next_pubkey_commit == H(tag ‖ w.next_pubkey ‖ w.npk_rand) against the same w.next_pubkey clause 7 folds into the account state. Custody is preserved exactly as tightly (SHA-256 collision resistance: a party without skᵢ can neither sign a different commitment nor open the given one to another key).
  • The published value is hiding: a scanner reading the next record's public_key = Pkᵢ₊₁ cannot recompute the commitment without the secret npk_rand, so the rotation edge no longer chains records — the §2.3.2/§3.5/§5.5 claims become true again.
  • npk_rand is a circuit witness only, carried wallet→node in the /sign body; never in a SpendRecord, BatchBundle, CoinProof, or to a publisher.

Single file (docs/specification.md). Test-vector blind npk_rand@0 = H("zkCoins/v1/test-vector/npk_rand") pinned (verified SHA-256); next_pubkey_commit@0 left <REGEN>.

…itment

PR #85 bound next_pubkey into the signed SpendRecord message to authorise the
key rotation on every transition (custody). But the raw next_pubkey = Pk(i+1)
then sat in the clear in the public, plaintext BatchBundle, next to each
record's public_key = Pk(i). Because next_pubkey(txn i) == public_key(txn i+1),
any observer fetching the public bundles could chain an account's consecutive
SpendRecords by the rotation edge -- a public, cross-transition account linkage
that contradicts the transaction-graph unlinkability the spec states for a
BatchBundle scanner (2.3.2, 3.5, 5.5) and Requirement 2.

Make the third message segment a hiding commitment
next_pubkey_commit = H("zkCoins/v1/NextPubkeyCommit" || next_pubkey || npk_rand)
instead of the raw key. The wallet draws a fresh secret npk_rand per transition
(SHA-256, wallet-native, no Poseidon) and signs the commitment; the per-account
circuit (clause 2) additionally checks the opening against the same w.next_pubkey
clause 7 folds into the account state. Custody is preserved exactly as tightly --
a party without sk(i) can neither sign a different commitment nor open the given
one to another key -- while the published value is hiding, so the rotation edge
can no longer chain records. npk_rand is a circuit witness only, carried
wallet->node in the /sign body, never in a SpendRecord, BatchBundle, CoinProof,
or to a publisher. Message stays 96 bytes; all record sizes unchanged.
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

One review pass (soundness + consistency lenses) with zero findings. The soundness lens confirmed custody is preserved exactly as tightly as the raw-key signature — a party without skᵢ can neither sign a different commitment nor open the given one to another key (SHA-256 collision/second-preimage resistance), and the clause-2 opening ties the committed value to the same w.next_pubkey clause 7 folds into the account state — while the privacy regression is genuinely closed: the raw next_pubkey appears in no public object, npk_rand is a 256-bit secret witness never placed in any SpendRecord/BatchBundle/CoinProof/publisher body, so a bundle scanner can no longer chain records by the rotation edge. The consistency lens independently recomputed the pinned npk_rand@0 SHA-256, confirmed message stays 96 bytes with all record sizes unchanged, and verified the previously contradictory §2.3.2/§3.5/§5.5 unlinkability statements are now true.

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