New L1 embeds a native shielded pool inside the L1 protocol: a note/nullifier accounting model, dual Groth16/BN254 proofs, and a Poseidon2 commitment tree, together with the genesis-predeployed, non-upgradeable system contract ShieldedPool(0x5000), the Poseidon2 precompile 0x67, and the native privacy transaction type 0x77. This article starts from the user-facing scenarios and the end-to-end flow, then dissects the implementation, presents measured metrics, and closes with the forward path: PQ migration, recursive proofs, a compliance hook, and ERC20 support.
01 · Usage: how the privacy layer looks
Privacy in New L1 is not a bolt-on mixer but a protocol-native “second accounting layer”: once funds are deposited into the pool they exist as encrypted notes; in-pool transfers hide amount, asset, and both counterparties completely; only withdraw returns to the public world. Throughout, the user’s only backup is a single 32-byte seed.
1.1 Participants and the three core operations
There are four kinds of participants: users (hold the seed and the notes), relayers (pay gas, hide the user’s EOA), validators / block producers (in Direct mode they pack 0x77 directly and collect the public gasFee), and the genesis-predeployed system contract itself. Everything today is built around three primitives:
shield· privDeposit
An EOA calls the system contract directly, transfers BNB in the clear, and mints one private note inside the pool. The entrance is necessarily public — “I am a pool user” is the inherent cost of entering a shielded pool.
private· transfer / atomicCall
In-pool 2-in-3-out private transfer: amount, asset, and both counterparties are hidden. The atomicCall variant can unshield → call public DeFi → reshield within a single atomic transaction.
unshield· withdraw
Nullify a note and pay out publicly to any EOA. Submitting through a relayer or 0x77 hides “who initiated the withdrawal”. Withdraw can never be disabled by governance.
1.2 Key hierarchy: one seed, three role-separated keys
The user only backs up a single 32-byte seed (seed); all three keys are derived from it. The derivation deliberately places the “key that spends” and the “key that reads” in two mathematically disjoint worlds: ownerNullifierKey (the spend key, abbreviated onk below) is a scalar on BN254 — spending requires Poseidon2 hashing inside a zero-knowledge circuit, and the circuit only understands that field; viewKey (the viewing key) is a scalar on secp256k1 — it only decrypts incoming ciphertexts off-chain and never enters a circuit.
The two keys live in different fields and serve different purposes, and leaking one does not yield the other; this also avoids the roughly 1.5 million constraints that emulating secp256k1 arithmetic inside a circuit would cost. Abbreviations used in this report: onk = ownerNullifierKey (spend key), onkHash = ownerNullifierKeyHash (spend key hash, registered on-chain), nss = noteSecretSeed (note blinding seed), seedHash = noteSecretSeedHash (blinding seed hash).
# the only secret that must be backed up
seed (32B)
├─ ownerNullifierKey = keccak256("newl1.shield.onk" ‖ seed) mod r # BN254: spending + nullifier derivation
│ └─ ownerNullifierKeyHash = poseidon(OWNER_NK_HASH, onk) # registered on-chain, globally unique, immutable
├─ noteSecretSeed = keccak256("newl1.shield.nss" ‖ seed) mod r # BN254: derives the blinding factor of output notes
│ └─ noteSecretSeedHash = poseidon(NOTE_SECRET, nss) # registered on-chain, circuit anchor only
└─ viewKey = normalizeEvenY(sha256("newl1.shield.view" ‖ seed)) # secp256k1
└─ viewKeyPub = x-only(viewKey·G) # 32B X coordinate = privacy address
| Key | Field / curve | Role | On-chain form | In circuit? |
|---|---|---|---|---|
| ownerNullifierKey | BN254 scalar field r | spend authorization + nullifier derivation | hash only (registry) | |
| noteSecretSeed | BN254 scalar field r | derives the noteSecret of each output note | hash only (anchor) | |
| viewKey | secp256k1 (mod n) | scanning + ECIES decryption of note ciphertext | public key is the privacy address | |
| EOA key | secp256k1 | EIP-712 signature authorization (auth proof) | poseidon(pubkey) commitment |
View key in detail: the cryptographic boundary of read-only access
viewKey is the only “read-only key” in the system, and its power is deliberately narrowed to one thing: decrypting note ciphertexts addressed to you. Understanding its four boundaries is understanding New L1’s visibility model:
what it can do· Discover and read incoming funds
Trial-decrypt each outputNoteData with viewKey via ECDH — if the AEAD tag verifies, the note is yours, and the plaintext yields (amount, tokenField, onkHash, noteSecret, memo). Handing viewKey to an auditor or accountant grants an incoming-only read view: they can see every amount you received but obtain no spending power.
what it cannot do· Spend · full outgoing view · linkage
Spending requires ownerNullifierKey (independently derived; viewKey cannot yield it). The outgoing side is only visible when the change note is also encrypted to yourself (normal wallets do this, but that is a wallet convention, not a protocol guarantee). viewKey also cannot link any two on-chain notes to the same person — blinding lives in noteSecret, not in the viewing layer.
- PQ exposure: ECDH is the harvest-now-decrypt-later surface. The leading version byte of
outputNoteDatais the switch for upgrading to ML-KEM hybrid encapsulation — a purely off-chain change that does not touch the circuits.
1.3 EOA address ↔ privacy address: an on-chain registry
The privacy address is simply viewKeyPub, paired with an on-chain privacy registry: the sender performs one on-chain lookup keyed by the recipient’s EOA address and obtains everything needed to encrypt and to construct the commitment, with no off-chain address-distribution protocol required.
// registry inside ShieldedPool (rule M-A)
mapping(address => PrivacyInfo) registry;
struct PrivacyInfo {
uint256 ownerNullifierKeyHash; // globally unique, immutable after first registration (prevents nullifier-domain collisions / orphan notes)
uint256 noteSecretSeedHash; // rotatable
uint256 authDataCommitment; // poseidon(EOA pubkey limbs), rotatable (used by EOA-reuse authorization)
uint8 scheme; // 0=ecdsa (today: the spend-key channel), 1=pq (reserved), rotatable
bytes32 viewKeyPub; // the privacy address itself, rotatable
}
1.4 Privacy registration (registerPrivacyInfo)
Registration is keyed by msg.sender (no registration on someone else’s behalf). It is the user’s first step into the privacy system and the only explicit binding between an EOA and a privacy identity:
-
keygen —
newl1-shield keygenderives the three keys from the seed and prints thePrivacyInfotuple (ownerNullifierKeyHash, noteSecretSeedHash, scheme, viewKeyPub). -
register — the EOA sends
registerPrivacyInfo(ownerNullifierKeyHash, noteSecretSeedHash, scheme, viewKeyPub); the contract locks ownerNullifierKeyHash (onkHash) as globally unique and emitsPrivacyInfoSet. -
address distribution — from then on anyone who knows your EOA address can send you a private transfer: the sender looks the registry up on-chain, so you never have to provide any out-of-band information.
1.5 Deposit: public inbound funds → private note
function privDeposit(uint256 ownerCommitment, uint8 scheme,
uint256 tokenField, bytes outputNoteData) payable
// native coin only for now: require(tokenField == 0 && msg.value > 0 && !depositPaused)
// noteBody = poseidon(NOTE_BODY, ownerCommitment, msg.value, 0, isOpen=0, scheme)
// enqueue into the deferred queue; totalShielded += msg.value; emit NoteQueued / Deposit
What the chain shows: from = the user’s EOA (public), to = the system contract, amount public. When depositing to someone else, the caller builds ownerCommitment from that party’s registry record and attaches the ECIES-encrypted outputNoteData; when depositing to yourself, you use your own material. Under deferred insertion a deposit only enqueues, and the measured user cost is 151,951 gas.
1.6 Transfer: 2-in-3-out fully private transfer
A transfer consumes 2 inputs (when fewer than two real notes are available, phantom inputs fill the slots, hiding the real input count) and produces 3 notes — the payment, the change, and the private fee note paid to the relayer. Value conservation is enforced inside the circuit: Σinputs = out0 + out1 + out2(fee) + publicAmountOut, and in the transfer case publicAmountOut ≡ 0, so no amount appears on-chain at all.
fig 1 · 2-in-3-out: fixed arity plus phantom inputs hide the real input count; feeAmount is a private witness bound through the intent digest so a relayer cannot tamper with it
1.7 Withdraw: the public exit
A withdrawal goes through the same transact entrypoint (operationKind=1): the circuit proves possession of ownerNullifierKey and nullifies the nullifier, the contract pays publicAmountOut to publicRecipient, and totalShielded decreases. The recipient address and the amount are necessarily public (funds really do leave), but submitting via a relayer or 0x77 hides “who initiated the withdrawal” — on-chain one only sees the pool paying a fresh address.
1.8 Native privacy transaction type 0x77 and the three submission modes
0x77 (ShieldedTx) is a native EIP-2718 transaction type with no from, no nonce, no gasPrice, and no outer signature — authorization is carried entirely by the embedded proofs, so any validator or builder can pack it directly. It coexists with the existing relayer path, giving three submission modes:
| Mode | Status | Fees and exposure surface |
|---|---|---|
| ① Direct (0x77, implemented) | implemented · verified on devnet | The user broadcasts via eth_sendRawTransaction; the public gasFee is unshielded to block.coinbase through value conservation (the slot-2 fee note is left empty). The user needs no BNB and exposes no EOA, and this is also the censorship-resistance fallback. Native BNB transfers only (op=0). |
| ② Relayer | implemented · main path | The relayer wraps the proofs in an ordinary signed transaction calling transact(), pays the BNB gas, and is compensated by the slot-2 private fee note (gasFee=0). Before packing, the relayer must decrypt the slot-2 ciphertext itself to confirm the fee is really payable to it. |
| ③ Paymaster (ERC-4337) | planned | The user signs a UserOperation, a bundler submits it, and a paymaster pays and can perform complex token conversion — the bridge into the AA wallet ecosystem. |
1.9 End-to-end: Alice → Bob
The following is the full lifecycle that e2e/cases/privacy-flow.sh verifies on a real devnet (proofs are generated against live on-chain tree state, not fixed fixtures):
fig 2 · End-to-end sequence: grey = public actions · gold = private/proof actions · purple = events/read-only; six stages cover keys → registration → deposit → transfer → discovery → withdrawal
-
Both sides keygen + register — Alice and Bob each derive their keys and register
PrivacyInfo; from then on they only need each other’s EOA address. -
Alice deposits 10 BNB —
privDepositis a public inbound transfer; the note commitment is enqueued and theNoteQueuedevent carries the ciphertext immediately. -
Batch insertion (drain) — validators issue the
deferBatchInsertsystem call (triggered automatically every 50 blocks ≈ 10s; anyone may also trigger it earlier by paying) to insert queued notes into the commitment tree in batch, advancing one new root. The notes become spendable at that point. -
Alice → Bob private transfer — Alice looks up Bob’s registry entry on-chain → encrypts out[0] to his
viewKeyPubwith ECIES → generates the pool proof locally (bound to the intent digest) → submits via a relayer or broadcasts0x77directly. On-chain, only two nullifiers and three new commitments are visible. -
Bob scans and discovers the note — Bob trial-decrypts each newly emitted
outputNoteDatawithviewKey; a passing AEAD tag means the note is his and simultaneously yields(amount, noteSecret, onkHash). Discovery is immediate (the ciphertext is visible with the transact event); only spendability waits for the drain. -
Bob withdraws to a fresh EOA — Bob derives the nullifier to nullify the note and withdraws to an address entirely unrelated to his history; the test asserts the balance arrived, the nullifier is burned, and a double-spend replay is rejected.
02 · Implementation: how contracts, circuits, and the node interlock
New L1’s circuits are fully in-house (circom 2.2.3 + snarkjs, Groth16/BN254); The solution has an independent domain-tag namespace newl1.shielded.*, an independent VK, and an independent ceremony. Bit-level agreement between contracts and circuits is frozen by shared test vectors gated in CI (make verify-shielded-set).
2.1 Note / nullifier: a five-level hash derivation chain
// all Poseidon2 t=4 with New L1 domain tags (PIN-3: tag = keccak256("newl1.shielded.") mod r)
ownerNullifierKeyHash = poseidon(OWNER_NK_HASH, ownerNullifierKey)
ownerCommitment = poseidon(OWNER_COMMITMENT, onkHash, noteSecret, authDataCommitment) // arity 4: last slot binds the EOA authorization commitment
noteBody = poseidon(NOTE_BODY, ownerCommitment, amount, tokenField, isOpen, scheme)
noteCommitment = poseidon(NOTE_COMMITMENT, noteBody, leafIndex)
nullifier = poseidon(NULLIFIER, noteCommitment, ownerNullifierKey)
Three design points are worth noting. ① isOpen and scheme are carved into the leaf — open notes (public amount/asset) share one tree and one Merkle-proof path with standard notes, and the authorization scheme is frozen per note so rotation does not affect old notes. ② noteSecret is derived from the sender’s seed (the decision after the audit fix): poseidon(NOTE_SECRET, senderSeed, intentReplayId, outIndex); the recipient obtains it by ECDH decryption rather than recomputation, and noteSecretSeedHash is only a circuit anchor. ③ The nullifier binds leafIndex (via noteCommitment), so two notes with the same amount and same owner still nullify to different values.
2.2 Note tree and deferred batched insertion
The commitment tree is a depth-32 incremental Merkle tree (nodes are poseidon([l,r]), the zero ladder is z[i+1]=poseidon(z[i],z[i]), and the empty root is the fixed PIN-6 constant written into genesis storage). Early synchronous insertion loaded every transact with 99 Poseidon2 permutations (~3.05M gas, 79% of the total); it is now two-phase:
User transact (block B):
verify both proofs · burn nullifiers · consume replayId · check the ciphertext-hash binding
→ the 3 output commitments are only enqueued (cheap SSTORE; queue index == future leafIndex)
System deferBatchInsert (block H, H % 50 == 0, validator post-execution system call):
FIFO batch insertion · shared upper Merkle path · each drain advances the root exactly once · paid from system gas
← permissionless: anyone may also call it early and pay (trustless; inserts only already-verified commitments; no-op on an empty queue)
The safety boundary is clear. Double-spend protection is not deferred (nullifiers are burned inside transact, immediately). The queue is ordinary contract storage, so on a reorg it rolls back and replays together with the transactions, and drain is deterministic on rebuild (pinned by the shielded_reorg.rs unit test). Equivalence between the batch algorithm and per-note append is proven by differential testing. Batching reduces the cost of K notes from K×33 permutations to about 2K+32.
2.3 Recent root history: a 64-slot ring buffer
A spend proof proves membership against some historical root, while the tree keeps advancing — so the contract maintains a ROOT_HISTORY=64 ring buffer of roots, and isKnownRoot accepts any root inside that window. Since each drain records exactly one root, the window is 64 drains × 50 blocks × 200ms ≈ 3,200 blocks / 640 seconds — more than 10 minutes of submission headroom after a proof is generated. The revocation semantics of an old authorization key are anchored here too: after rotation, the old key expires naturally as its root slides out of the window.
2.4 zkProof · pool proof (29 public signals)
The pool circuit establishes accounting correctness and spend authorization in one shot: 2-in-3-out conservation, dual nullifier derivation (real and phantom indistinguishable, nf0≠nf1), Merkle membership, and full-field binding to transactionIntentDigest. The operationKind constraint matrix enforces at the R1CS level that the three operations have mutually exclusive public surfaces (its absence would be a privilege-escalation bug):
| Public signal (PIN-4 order) | op=0 transfer | op=1 withdraw | op=2 atomicCall |
|---|---|---|---|
| publicAmountOut | == 0 | ≠ 0 | ≠ 0 |
| publicRecipient | == 0 | ≠ 0 | == 0 (a CREATE2 temporary address is used) |
| callTarget / callDataHash / reshield* | all 0 | all 0 | callTarget ≥ 0x10000, etc. |
| gasFee (index 28, 0x77-only) | all ops: the public fee unshielded to coinbase; always 0 on the relayer path; counted in conservation and folded into the intent digest | ||
| scheme | all ops: must equal the scheme inside the spent leaf (prevents cross-scheme privilege escalation); all amounts pass Num2Bits(248) to prevent field wraparound |
transactionIntentDigest = poseidon(INTENT, all public fields + feeAmount + reshieldNoteSecret + replayId + validUntil + chainId, …) is recomputed inside the circuit and constrained to be equal — if a relayer tampers with any field (recipient, fee, calldata hash, ciphertext hash) the proof becomes invalid. Replay protection is threefold: intentReplayId (one-shot), executionChainId (cross-chain), validUntilSeconds (expiry).
2.5 zkProof · auth proof (the authorization side of the split proof)
The authorization design went through one explicit trade-off. The minimal reliable model is “authorization is the spend key” — the pool proof proves knowledge of onk and locks the intent, with no separate signature (the Zcash/Tornado lineage). On top of that it was upgraded to EOA-reuse authorization: the user adds no new key and signs EIP-712 with their existing Ethereum account key, which the circuit verifies:
// auth_ecdsa.circom — only 2 public signals: [blindedAuthCommitment, transactionIntentDigest]
1. digest712 = keccak256(0x1901 ‖ DOMAIN_SEPARATOR ‖ hashStruct(tid)) // keccak recomputed in-circuit, closing off a free-choice msghash
2. ECDSAVerifyNoPubkeyCheck(64,4)(r, s, digest712, pubkey) === 1 // circom-ecdsa
3. authDataCommitment = poseidon(AUTH_DATA, xHi, xLo, yHi, yLo)
4. blindedAuthCommitment = poseidon(BLINDED, authDataCommitment, blindingFactor) // fresh blinding per transaction → method-level unlinkability
Owner-binding is the core of PIN-5: folding authDataCommitment into ownerCommitment (arity 3→4) lets the pool proof establish “this note committed to this EOA” and the auth proof establish “this EOA signed this digest”, with the shared blindedAuthCommitment tying the two together — otherwise all you proved is that “somebody signed something”. The cost is that the pool circuit changed and a new ceremony is required. Wallet-side changes are zero: any standard wallet supporting eth_signTypedData_v4 works (domain: NewL1ShieldAuth/1, verifyingContract = 0x5003).
2.6 Note delivery: ECIES-secp256k1 ciphertext delivery
outputNoteData = version(1B) ‖ ephPubCompressed(33B) ‖ ChaCha20-Poly1305(key, plaintext)
plaintext = { amount, tokenField, ownerNullifierKeyHash, noteSecret, scheme, memo? }
shared = (ephScalar · viewKeyPub).x # sender side; = (viewKey · ephPub).x on the recipient side
okm = HKDF-SHA256(ikm=shared, salt=ephPub, info="newl1.shield.note.ecies.v2", 44)
key = okm[0..32], nonce = okm[32..44]
The recipient trial-decrypts every ciphertext — a passing AEAD tag means it is theirs (the tag is the discovery oracle) — then recomputes the commitment to confirm membership in the tree. Integrity is doubly enforced: outNoteDataHash0..2 are folded into the intent digest (circuit side), and transact additionally re-checks keccak256(ciphertext) mod r slot by slot (contract side, with empty slots submitting keccak("") for uniformity). A relayer therefore cannot swap ciphertexts to mount a “delivery DoS” (leaving the recipient permanently unable to discover their note). secp256k1 was chosen over X25519 because it is the same curve family as EVM wallets and because viewKey never enters a circuit, so the in-circuit secp256k1 constraint trap does not apply.
2.7 Poseidon2 and the 0x67 precompile
parameters (PIN-1)· Horizen BN254-t4 standard parameter set
t=4, RF=8 full rounds + RP=56 partial rounds, length-tagged sponge (capacity initialised to N<<64, rate 3). The four implementations — circom, the JS mirror, Solidity, and the Rust precompile — agree bit for bit, pinned by 9/9 cross-implementation vector tests. The 14 domain tags do not collide across contexts.
precompile (0x67)· 27,200 → 1,500 gas per permutation
crates/evm/src/precompiles/poseidon2.rs exposes the bare t=4 permutation, active from genesis (same pattern as 0x66 BLS). Poseidon2Sponge keeps the sponge wrapper and staticcalls once per permutation — after deleting the inlined Solidity round loops, the deployed ShieldedPool bytecode halved from 23,090 to 11,924 bytes.
Predeploy layout (genesis, non-upgradeable, no admin)
| Address | Contents | Notes |
|---|---|---|
| 0x5000 | ShieldedPool | The pool itself. It embeds the snarkjs-exported PoolGroth16Verifier by inheritance (the combined artifact is GPL-3.0) and compiles in the Poseidon2 hash library; genesis also writes the tree’s initial storage (32 levels of zero-subtree hashes, the seed root, nextLeafIndex=0) — so this is not a pure code predeploy |
| 0x5003 | Auth verifier | A separate staticcall dispatched by scheme; deploying it separately makes a future pq channel additive (swap in another verifier address and the pool hot path stays untouched) |
| 0x67 | Poseidon2 precompile | A consensus-layer change (all full nodes agree), Rust implementation |
| 0x1007 | GovHub (existing) | The only privileged caller, and only for setDepositPaused; withdraw/transfer/atomicCall can never be paused |
2.8 Native transaction type 0x77: the node side
The node side mirrors the existing native AA type 0x76 and its NewL1TxEnvelope mechanism exactly: NEWL1_SHIELDED_TX_TYPE = 0x77 sits in New L1’s reserved high range, avoiding collisions with future Ethereum standard types (0x00–0x0f). The pipeline is eth_sendRawTransaction (EIP-2718 decoding) → mempool admission (PooledKind::Shielded, proof verified once and memoised in a OnceLock) → miner packing → executor shielded_transact (synthesised sender + transact calldata + in-EVM Groth16 verification) → receipt type 0x77. The key divergences from AA: no account nonce (replay protection is nullifier + replayId), no balance deduction from the caller (the fee is the public gasFee), and authorization is dual proofs rather than ecrecover.
2.9 Gas and fee mechanics
| Path | Fee form | Conservation equation |
|---|---|---|
| Relayer (②) | A private fee note (slot 2); feeAmount is a private witness folded into the digest; the relayer only pays after decrypting and verifying it | Σin = out0 + out1 + feeNote |
| 0x77 Direct (①) | The public gasFee (pub[28]) is paid by the pool directly via _payNative(block.coinbase); the proof does not bind the coinbase address (the packer is unknown at proving time); the slot-2 fee note is set to a dummy |
Σin = out0 + out1 + gasFee |
| Underlying fee model | (applies chain-wide) New L1 charges prepaid, by declared gas limit (under asynchronous execution, blocks are packed against declared gas — what is sold is declared space): gas_used == gas_limit, no refund of unused gas, EIP-3529 refunds void; the base fee is burned and tips are collected to validators via SYSTEM_ADDRESS. Wallets must set gas_limit precisely |
2.10 The responsibility boundary of a privacy relayer
A relayer is a pure execution agent, cryptographically stripped of every avenue for misbehaviour: it cannot change the recipient, amount, or ciphertexts (digest binding); it cannot replay (replayId); and it cannot point the fee note at someone else and then trick another relayer into packing the transaction (the circuit does not enforce slot-2 ownership, but the decrypt-and-verify step before packing means a proof that pays someone else finds no sponsor). On devnet, any funded EOA can act as a relayer (transact has no msg.sender authorization).
2.11 Open notes + atomic EVM calls
This is New L1’s largest capability increment over EIP-8182: completing unshield → public DeFi call → reshield inside a single atomic transaction, instead of splitting the intermediate state into several linkable transactions. The difficulty is that the DeFi output amount is unknown at proving time — the solution is the open note (isOpen=1, a leaf with public amount/asset) plus contract-side delta measurement:
fig 3 · Open note + atomic EVM call: gold = steps in the private domain · white = steps in the public domain; when the open note from ⑥ is later spent it converts into a fully private standard note
-
Verification + accounting — verify both proofs (conservation:
Σin = private outputs + publicAmountOut + fee), run the known-root / nullifier / replay checks, burn the nullifiers, and enqueue the 3 private outputs. -
CREATE2 temporary forwarder — derive a one-shot address salted with
transactionIntentDigest(replayId guarantees uniqueness and prevents address-reuse residue) and injectpublicAmountOutin native coin — the caller is isolated, and the pool itself is never the msg.sender of an external call. -
The proof-bound external call — execute the calldata against
callTarget, requiringkeccak256(callData) mod r == callDataHash(already bound by the proof);callTarget ≥ 0x10000bluntly rejects the pool, the verifiers, system contracts, and precompiles. -
Delta measurement + full sweep-back — snapshot
bal_beforein the same frame, then after the callmeasured = bal_after − bal_before(absolute balances are forbidden, to prevent an external top-up from forging an open-note amount); the temporary address’s whole balance is swept back into the pool, and any force-fed surplus accrues to the protocol. -
Reshield the open note — build
noteBody = poseidon(NOTE_BODY, reshieldOwnerCommitment, measured, token, isOpen=1, scheme)and enqueue it (ifmeasured==0, no leaf is inserted);reshieldNoteSecretcomes from the initiator’s seed and is folded into the digest, so a relayer cannot rewrite ownership. The custody invariant is asserted alongside:totalShielded += measured − publicAmountOut, and no payment may overdraw. -
Failure semantics = whole-transaction revert — if the external call reverts, the entire transaction reverts (no nullifier consumed, no leaf inserted, no replayId burned) — refusing the “refund the unshield into a reshield” path and closing off the ownerless-blinding-factor problem. All entrypoints share one reentrancy guard slot.
2.12 Wallet toolchain and note-discovery optimisation (newl1-shield)
The protocol deliberately provides no Merkle-path RPC — wallets rebuild all state from the event stream and nodes stay stateless services. The reference implementation is the newl1-shield CLI (JS proving toolchain + Rust wrapper):
| Command | Responsibility |
|---|---|
| keygen / address / register | Derive the three keys from the seed, print the privacy address (viewKeyPub), register PrivacyInfo on-chain |
| synctree / scan | Replay NoteQueued(seq, outputNoteData) events to rebuild the commitment tree (seq == leafIndex, i.e. the queue index is the leaf position); trial-decrypt with viewKey to discover your own notes and persist a local note store |
| prove-spend | Build the witness against live on-chain tree state (real Merkle paths) → generate the pool + auth proofs with snarkjs; --ond requires the ciphertext to be fixed at proving time (its hash enters the digest) |
| wallet / status / inspect-tx | Local balance with the pending/spendable split; on-chain root / totalShielded / queue depth; decode the 29 public signals of any privacy transaction |
Scanning optimisation: a view tag (the leading bytes of the ephemeral public key) provides a cheap pre-filter, and full AEAD decryption runs only on hits; incremental scans look only at new commitments. The event design guarantees the ciphertext is visible from the moment of enqueueing — discovery never waits for a drain.


