Skip to content

feat(shielded-pool)!: private transfer across two trees, spend hardening (circuit v3, spec 18 / tx 6) - #160

Merged
nol4lej merged 2 commits into
mainfrom
feat/transfer-cross-tree-spec18
Oct 8, 2026
Merged

nol4lej merged 2 commits into
mainfrom
feat/transfer-cross-tree-spec18

Conversation

@nol4lej

@nol4lej nol4lej commented Oct 8, 2026

Copy link
Copy Markdown
Member

Summary

Spec 18 (unreleased) gains private transfers across two trees: transfer circuit v3 proves each input against its own Merkle root, so a note in a sealed tree can be spent together with one in the active tree, with no consolidation step. The spend pipeline is hardened along the way. transaction_version 5 → 6.

Pairs with @orbinum/circuits 0.17.1 (transfer v3, unshield v3, release ceremony).

Changes

orbinum-zk-verifier 3.0.0 (primitives)

  • InputLayout::CrossTree: a transfer key of 9 public inputs. Unshield and shield keys of that arity are still refused.
  • max_public_inputs(circuit_id): the single source for each circuit's widest arity, used by the compile-time cap and the verification weight.
  • Breaking: a proof must be exactly PROOF_BYTES (128) long, and MAX_PROOF_BYTES is removed. The deserializer ignored trailing bytes, so a padded proof was a second encoding of the same proof.

pallet-zk-verifier 0.15.0

  • TransferStatement.merkle_roots: [[u8; 32]; 2].
  • encode_transfer / encode_unshield return Option. None means the key cannot attest to the statement (two different roots under a v1/v2 key), and the proof fails without a pairing.
  • verification_weight covers 9 inputs.

pallet-shielded-pool 0.22.0

  • Breaking: private_transfer takes merkle_roots: [Hash; 2].
  • TransferRequest::ensure_roots: every root must be known, and a dummy input's root must repeat the real input's (InvalidPublicSignals, pool code 13). The circuit leaves a dummy's root free.
  • transfer_op_hash binds both roots, in order.
  • Fix: sealed-tree Merkle paths no longer read the next tree's leaves when MaxLeavesPerTree < 2^depth. Production (2^20) was not affected.
  • The private_transfer benchmark now spends from two sealed trees (worst-case root lookups).

pallet-evm-precompile-shielded-pool 0.9.0

  • New ABI privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32), selector 0x63d0b9a0, with exactly two roots.
  • The single-root selector 0x66ed2cd4 is gone. The fee stays in slot 6.

Relay RPC, runtime, scripts

  • Relay RPC: docs and guard tests for the new layout.
  • Runtime: transaction_version: 6, and RUNTIME_VERSIONS.md spec 18 with crate versions and the activation steps.
  • rotate-dev.sh: expected arity per (circuit, version), plus a --circuits flag.

Security

The circuits side carries a critical fix: transfer v1/v2 and unshield v1/v2 accepted non-canonical spending keys, so one note admitted several nullifiers. Transfer v3 and unshield v3 (circuits 0.17.1) constrain the key to be canonical. No runtime code is needed, because unshield v3 keeps v2's 8-input layout. Transfer v2 and unshield v2 must be retired as soon as v3 is active.

The spend pipeline (relay RPC → precompile → pool admission and dispatch → verifier) was attacked adversarially. Covered by tests:

  • re-randomised (malleable Groth16) copies;
  • swapped inputs and roots;
  • one note in both slots;
  • re-spends from another tree;
  • x + r twins of nullifier, commitment, change and root;
  • dummy-root abuse;
  • tampered, degenerate and foreign proofs;
  • padded proofs;
  • G2 points outside the subgroup in proofs and keys;
  • hostile ABI offsets and lengths in the precompile and the relay guard;
  • op-hash bound to root order.

Test plan

  • cargo test: primitives 82, pallet-zk-verifier 137, pallet-shielded-pool 406 (416 with runtime-benchmarks), precompile 97, fc-rpc relay 65, runtime 46
  • cargo fmt --check, taplo fmt --check, make clippy
  • Benchmarks for pallet_shielded_pool and pallet_zk_verifier generate (private_transfer: SealedRootIndex r:2)
  • Live e2e on a dev node with real proofs and circuits 0.17.1 artifacts (8-leaf trees to force sealing):
    • e2e-cross-tree-live.cjs: 42/42
    • e2e-spend-hardening-live.cjs: 78/78, including the relay RPC flow with a two-tree spend (commit, fee to the committed relayer, no double relay, refusals before any claim)

@nol4lej
nol4lej merged commit b2b0f57 into main Oct 8, 2026
6 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.

1 participant