Skip to content

feat!: spec 18 transfer across trees (merkle_roots, selector 0x63d0b9a0), unshield v3 recipient, busy proof RPC - #8

Merged
nol4lej merged 1 commit into
mainfrom
feat/spec18-cross-tree-transfer
Oct 10, 2026
Merged

nol4lej merged 1 commit into
mainfrom
feat/spec18-cross-tree-transfer

Conversation

@nol4lej

@nol4lej nol4lej commented Oct 10, 2026

Copy link
Copy Markdown
Member

Brings @orbinum/protocol to node spec 18 / transaction version 6.

Breaking: transfer across trees (circuit 1 v3)

  • PrivateTransferParams.merkleRoot → merkleRoots: [string, string]: the root each input is proven against, in input order.
    • Equal for two notes of one tree.
    • A dummy input's slot repeats the real root (the chain requires every root to be known).
  • ShieldedPoolModule.privateTransfer sends merkle_roots.
  • Precompile: privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32), selector 0x63d0b9a0. The single-root 0x66ed2cd4 stays in KNOWN_PRECOMPILES only to decode older blocks.
  • Decoders:
    • new calldata → roots, old calldata → root;
    • mapExtrinsicArgs → merkle_roots for spec 18+ blocks, merkle_root for older ones.

Also for spec 18

  • addressToFieldElement accepts circuit version 3. Unshield v3 keeps v2's memo-bound recipient (blake2_256); only its spending-key rule changed. Without this, unshield v3 could not be proven. Unknown versions still throw.
  • Busy node. Spec 18 queues Merkle proof requests per node and refuses as busy (-32009) only past a full queue.
    • New: isServerBusyError, SERVER_BUSY_CODE.
    • PrivacyModule.getMerkleProof* retries a busy refusal with backoff (only busy, a few tries, jittered).
    • classifyChainError never reads a busy refusal as ghost-note. Before, a wrapped busy message matched the ghost-note pattern, and a wallet would have purged a real note.

Hardening

Change Why
assertMerkleRoots refuses non-canonical field elements (≥ r) The pallet matches roots byte for byte: r + x is the same element as x but no root it ever recorded, so the spend would fail after the proof was paid for
decodeRoots refuses an offset into the ABI head It would read the head's own words as roots. Counts past two, huge offsets and roots running past the data read as none (all from untrusted calldata)
mapExtrinsicArgs reads camelCase arg names polkadot.js decodes merkleRoots, encryptedMemos…; the mapper only knew snake_case and positional names, and dropped those fields in silence. Found by the live test below

Testing

Unit: 927 tests, plus typecheck, lint, format and build. New:

  • Adversarial:
    • roots offset into the head, 2^200, past the data;
    • counts 0/3/2^255;
    • roots that run past the data;
    • non-canonical roots (r, 2^256−1);
    • wrong root counts and malformed hex;
    • addressToFieldElement with versions 0, 4, 1.5, -1, NaN, ∞.
  • v3 recipient equals v2 byte for byte.
  • Busy: detection by code and by text; retry only on busy, backoff bounds, gives up with the busy error; a wrapped busy message is never a ghost note.
  • camelCase mapping for both the new and the old transfer.
  • Root fixtures made canonical: the old ones (0xcc…cc) were not field elements and are now refused.

Live, against a dev node (8 leaves per tree): this package as the client, @orbinum/proof-generator 9.0.0 proving with the published @orbinum/circuits 0.17.1 keys. 15/15:

Scenario Result
tx version 6
shield through ShieldedPoolModule 16 notes across trees 0 and 1
privateTransfer, same tree, signed lands, both nullifiers spent
privateTransfer, cross-tree, unsigned lands; with roots swapped, refused
Non-canonical root refused before anything is sent
ShieldedPoolPrecompile.privateTransfer (EVM) selector 0x63d0b9a0, calldata decodes back to its two roots, lands
Unshield v3 to an SS58 account lands, exact amount credited
Unshield v3 to an EVM address lands on its mirror account, exact amount credited
mapExtrinsicArgs on the real block reads merkle_roots of the cross-tree transfer
400 simultaneous proof requests all served after retries; no failure read as a ghost note

@nol4lej
nol4lej merged commit 5552149 into main Oct 10, 2026
3 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