Introducing the New L1 Native Privacy Layer

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) :white_check_mark: pool proof
noteSecretSeed BN254 scalar field r derives the noteSecret of each output note hash only (anchor) :white_check_mark: anchoring assertion
viewKey secp256k1 (mod n) scanning + ECIES decryption of note ciphertext public key is the privacy address :cross_mark: off-chain only
EOA key secp256k1 EIP-712 signature authorization (auth proof) poseidon(pubkey) commitment :white_check_mark: auth circuit

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 outputNoteData is 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:

  1. keygennewl1-shield keygen derives the three keys from the seed and prints the PrivacyInfo tuple (ownerNullifierKeyHash, noteSecretSeedHash, scheme, viewKeyPub).

  2. register — the EOA sends registerPrivacyInfo(ownerNullifierKeyHash, noteSecretSeedHash, scheme, viewKeyPub); the contract locks ownerNullifierKeyHash (onkHash) as globally unique and emits PrivacyInfoSet.

  3. 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

  1. 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.

  2. Alice deposits 10 BNBprivDeposit is a public inbound transfer; the note commitment is enqueued and the NoteQueued event carries the ciphertext immediately.

  3. Batch insertion (drain) — validators issue the deferBatchInsert system 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.

  4. Alice → Bob private transfer — Alice looks up Bob’s registry entry on-chain → encrypts out[0] to his viewKeyPub with ECIES → generates the pool proof locally (bound to the intent digest) → submits via a relayer or broadcasts 0x77 directly. On-chain, only two nullifiers and three new commitments are visible.

  5. Bob scans and discovers the note — Bob trial-decrypts each newly emitted outputNoteData with viewKey; 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.

  6. 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

  1. 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.

  2. CREATE2 temporary forwarder — derive a one-shot address salted with transactionIntentDigest (replayId guarantees uniqueness and prevents address-reuse residue) and inject publicAmountOut in native coin — the caller is isolated, and the pool itself is never the msg.sender of an external call.

  3. The proof-bound external call — execute the calldata against callTarget, requiring keccak256(callData) mod r == callDataHash (already bound by the proof); callTarget ≥ 0x10000 bluntly rejects the pool, the verifiers, system contracts, and precompiles.

  4. Delta measurement + full sweep-back — snapshot bal_before in the same frame, then after the call measured = 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.

  5. Reshield the open note — build noteBody = poseidon(NOTE_BODY, reshieldOwnerCommitment, measured, token, isOpen=1, scheme) and enqueue it (if measured==0, no leaf is inserted); reshieldNoteSecret comes 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.

  6. 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.

03 · Key metrics: measurements and capacity projections

All gas figures below are Foundry gasleft measurements in-repo or live results from the 4-validator devnet; proving times and TPS are projections derived from constraint counts and chain parameters, with the assumptions stated item by item.

3.1 User-side operation gas (before vs. after deferred insertion)

Operation Synchronous insertion (old) Deferred insertion (current, measured) Notes
privDeposit (shield) ~3,450,000 151,951 Enqueue + event only; ~22.7× reduction
transfer (transact op=0) ~3,820,000 775,859 Approaching the dual-Groth16 verification floor (~810k); ~4.9× reduction
withdraw (transact op=1) ~3,850,000 843,000 Includes real ECDSA auth verification; the two Groth16 pairing checks account for ~810k (96%)
atomicCall (transact op=2) ~1,010,000 The withdraw baseline + CREATE2 forwarder + target call + reshield enqueue (measured against a mock DEX)
pool verifyProof (isolated) 402,506 29-signal Groth16 verification; auth verification accounts for roughly the other half of on-chain verification gas

In the synchronous-insertion era, 79% of transact gas (~3.05M, 99 Poseidon2 permutations) went to on-chain Merkle maintenance rather than ZK verification (10%) — deferred insertion hands that whole share over to the system.

3.2 System-side batch insertion (drain) cost

Scenario Gas Notes
deferBatchInsert · one at a time (Phase A) 4,018,915 / note A loop over _insert; a correctness baseline only
Batch · 3 notes (A.2) ~1,350,000 Shared upper path
Batch · 256 notes (A.2, Solidity Poseidon) 17,844,180 = 89% of the 20M system gas hard cap (the basis for DRAIN_CHUNK_CAP=256)
Marginal cost / note · Solidity Poseidon 57,300 Fitted as ≈ 3.17M fixed + 57.3k/note
Marginal cost / note · 0x67 precompile 10,670 ~5.4× lower; a single permutation drops from 27.2k to 1,500 gas

3.3 ZK proving metrics (measured: snarkjs/WASM and rapidsnark)

Metric pool auth (ECDSA-in-circuit)
Constraint count 70,355 2,121,764 (~30×)
Proof size (fixed for Groth16) 256 B 256 B (512 B total per transact)
Witness generation (WASM, measured) 0.13 s 147.6 s
Groth16 prove · snarkjs (measured) 3.10 s 75.0 s
Groth16 prove · rapidsnark (measured) 0.46 s (6.7×) 10.17 s (7.4×)
Witness generation · native C++ (estimated) ~0.02 s ~8–15 s
On-chain verification gas 402,506 ~400k (~810k combined, i.e. 96% of withdraw’s 843k)
Total proving time per transaction Total Composition
-– -– -–
snarkjs + WASM (today’s dev toolchain, measured) ~226 s (~3.8 min) 147.6s WASM witgen + 75s prove, ~98% of it in the auth circuit
rapidsnark + WASM witgen (measured) ~158 s Proving is already 7× faster; WASM witness generation becomes the new bottleneck
rapidsnark + native witgen (estimated) ~20–25 s The realistic shape of a production toolchain; with further parallelism/GPU, ~5–20 s per transaction is plausible

On-chain verification is constant time (one pairing equation plus an MSM over the public signals; milliseconds natively on a node). The dev ceremony’s zkey is forgeable but does not affect gas, proof size, or constraint count — a mainnet ceremony only replaces the trust root, and proving time varies only with the prover implementation. Roadmap comparison: a Honk auth circuit is ~2M gas with a 16–24 KB proof (no per-circuit ceremony); wrapping Honk→Groth16 compresses it back to ~260 B / ~270k gas.

3.4 Throughput (TPS) projection with the current optimisations (deferred batching + the 0x67 precompile)

Chain parameters: block gas limit 150M, block interval 200ms (5 blocks/s). With deferred insertion and the Poseidon2 precompile both live, chain-level privacy throughput is bounded by user transaction gas:

Basis Derivation TPS
withdraw theoretical ceiling (100% privacy blocks) 150M ÷ 843k ≈ 178 tx/block × 5 ~890
atomicCall theoretical ceiling 150M ÷ 1.01M ≈ 148 tx/block × 5 ~740
Including amortised drain insertion cost (~+400k amortised per output note inserted) effective ~1.25M/tx → 150M ÷ 1.25M × 5 ~600

3.5 Latency and windows

  • note discovery · Immediate

The ciphertext is visible in the same block as the transact event; the recipient can decrypt the amount and secret right away.

  • note spendability · ~5s average / ~10s worst case

Waits for the next 50-block drain boundary to enter the tree; when chunked under congestion, one further cycle.

  • proof submission window · ≈ 640s

ROOT_HISTORY(64) × 50 blocks × 200ms — over 10 minutes of headroom after a proof is generated.

04 · Future: PQ, recursion, a compliance hook, and scenario evolution

Every “reserved seam” in the current implementation exists so that later capabilities can be added additively, with zero note-state migration: tokenField is reserved for ERC20, scheme for PQ, complianceHook for compliance, and open notes for DeFi composability.

4.1 Post-quantum (PQ) migration path

PQ is not a rebuild from scratch but incremental progress along three seams that are already in place:

  1. The authorization channel: scheme=1 is already carved into the leafscheme ∈ {ecdsa=0, pq=1} is bound inside the note leaf and the circuit enforces publicScheme == note.scheme, so shipping PQ only requires deploying a new auth verifier (the staticcall address dispatched by scheme); existing notes and the pool hot path do not move at all. The concrete algorithm, encapsulation, and proof system are left to later design (candidate directions align with the BNB Chain mainnet PQ roadmap: Falcon-512 for its small on-chain signature size, ML-DSA as the conservative option; on the ZK side, the circuit-friendliness of hash-based signatures can be evaluated).

  2. Key rotation semantics: an escape hatch prepared for “Q-day” — the registry design already embeds the migration mechanism: onkHash is immutable, which keeps the nullifier domain stable, while authScheme is rotatable — a user can actively switch authorization from ecdsa to pq, the old authorization expires naturally as its root slides out of the 64-slot history window (≈640s), and old notes remain spendable (scheme is frozen with the leaf). This shares the same “multi-signature-scheme dispatch” engineering as New L1’s native AA (0x76 already supports secp256k1 / P256 / WebAuthn plus keychain authorization rotation).

  3. PQ-ifying note-delivery encryption — the ECDH in today’s ECIES-over-secp256k1 is the Shor-vulnerable surface (record the ciphertext, decrypt it later, and historical amounts and secrets leak). The leading version byte of outputNoteData is exactly the upgrade switch reserved for this: v3 can switch to ML-KEM (or a hybrid X25519+ML-KEM KEM) encapsulation — a purely off-chain change that touches neither the circuits nor the ceremony, requiring only a new KEM public-key field in wallets and the registry. This is the highest benefit/cost step of the three and the one that can ship first (“harvest-now-decrypt-later” is the most realistic quantum threat to a privacy pool).

4.2 ZK recursion: client proof + zkVM aggregation

The target architecture is two-layer proving: every user produces one proof for their own transaction on the client, and a zkVM aggregates a batch of user proofs into a single proof that the chain verifies once. The client-side proof system changes across two phases; the aggregation layer keeps the same shape in both. Until that layer lands, near-term prover work stands as measured before (rapidsnark plus native witness generation, ~20–25 s per transaction).

  • phase 1 · pre-quantum · Client Groth16 → zkVM aggregation → one on-chain verification

The client keeps today’s Groth16/BN254 pool + auth proofs (256 B each), so wallets and the proving toolchain are untouched. An aggregator collects the proofs of N transactions and, inside a zkVM, verifies all N against the frozen verification keys and binds each proof to its own public signals. The zkVM’s STARK is then wrapped into a single Groth16/PLONK proof, and the chain performs one pairing check plus N sets of public inputs, through a new transactBatch entrypoint — the per-transaction transact path stays as it is. On gas: the ~810k of dual-pairing verification per transaction collapses into one fixed cost per batch, but the N nullifiers, commitments, recipients, and ciphertext hashes still have to reach the chain, so calldata and storage do not amortise — only verification does.

The natural aggregator is the existing relayer (mode ②): it already collects transactions, already pays gas, and is already compensated by the slot-2 private fee note, so no new economic actor has to be introduced.

  • phase 2 · post-quantum · Client STARK → zkVM aggregation → one on-chain verification

The client side moves off Groth16 to a hash-based STARK (a zkVM guest program, or a Plonky3-class prover), which removes both the per-circuit trusted setup and the BN254 discrete-log assumption from the client. Proof size grows from 256 B to tens or hundreds of KB, which is acceptable precisely because a user proof travels to the aggregator off-chain and never enters a block. Aggregation then becomes recursive STARK verification — the same pipeline with the inner verifier swapped.

4.3 Compliance hook and full governance (planned)

The key design ruling is to abandon “splitting the anonymity set by asset”: tokens are hidden by default and the anonymity set is not fragmented per asset; compliance is instead enforced by a revocable compliance hook at three chokepoints — shield, unshield, and transfer. The _complianceHook() no-op seam is already in place, so integration requires zero migration:

Mechanism Design
Per-asset compliance verifier mapping(token ⇒ verifier), set by governance, pluggable, and setting it to 0 revokes with immediate effect (loosening a restriction needs no timelock) — a credible-neutrality guarantee in engineering terms; complianceNullifier prevents proof reuse
Priority OFAC non-membership proofs first (the highest priority in regulatory dialogue), then zkKYC and source-of-funds proofs
Guard the entrance, never the exit Compliance checks apply only at deposit / reshield; withdraw is always open and governance can never touch funds already in the pool — the engineering answer to how Tornado Cash ended
Governance switches 11/21 validator multisig, public on-chain: globalDepositEnabled (emergency, no timelock) / globalShieldEnabled (24h) / tokenDepositEnabled[t] (24h); during a timelock window users can see the pending change and have time to unshield out of the way

4.4 ERC20 / BEP-20 extension

tokenField has been inside the note commitment since day one (currently pinned to 0 = native coin), so ERC20 support requires migrating no note state: the deposit side switches to transferFrom plus per-asset compliance checks, the token stays hidden for in-pool transfers (conservation already forces all real inputs and outputs to share one tokenField), and the token is exposed in only two places — open notes and the native-transfer-specific transaction type. One boundary to note: 0x77 with its public gasFee is native-BNB only — private ERC20 transfers go through the relayer’s private fee note or the paymaster path, so that a fee asset does not tear the anonymity set open.

4.5 Future scenarios

  • Privacy demo (full devnet flow)

e2e/run.sh privacy-flow: keygen → register → shield → private transfer → scan/discover → unshield, generating proofs against live on-chain tree state; shielded-native.sh validates the whole 0x77 pipeline (decode → mempool → packing → execution → coinbase receives the gasFee → state roots agree across full nodes). This is the minimum demonstrable product form.

  • Privacy transfer

Everyday private payments — payroll, B2B settlement, personal transfers — with amount, asset, and both parties hidden; the recipient needs only an EOA address, funds are re-spendable within 10 seconds, and fees can be sponsored by a relayer for “send and receive with zero BNB on hand”.

  • Privacy swap

atomicCall wired straight into an AMM: unshield → swap on PancakeSwap → atomically reshield the output back into the pool as an open note, which becomes fully private when next spent. Externally one sees only “the pool made a swap”, not who made it — trading strategy and position adjustments no longer run in the clear. MEV/sandwich protection and a callTarget allowlist are audit surfaces to settle before mainnet.

  • Privacy loan

The same atomicCall primitive composed with lending protocols: unshield a private position into a Venus-style market as collateral → borrow and take the asset back into the pool as an open note; position health stays public and liquidatable while the position’s owner stays hidden. Further, zk credit/compliance proofs can be layered on (reusing the compliance hook) toward private credit lending.

4 Likes