ADR: Relayer-Assisted Merge
ADR — Relayer Merge as an Alternative to IMT for Concealing Stake Count
| Status | Proposed |
Problem
Current L3/staking has very limited mixing capabilities if implemented as regular shielded pool without some additional exotic logic, as seen in the following usage scenario: a merchant receives payments from many clients via L3 stakes. When the merchant withdraws, the number of nullifiers consumed reveals how many clients they have. The system needs to hide this count while allowing efficient bulk withdrawal.
Motivating example: A merchant with 100 clients receives 100 individual stakes. At withdrawal time, the merchant wants to unstake all funds without revealing that they had ~100 separate deposits.
Current solution
Use an Indexed Merkle Tree (IMT) to conceal the number of nullifiers. The unstake circuit proves non-membership and insertion against the IMT root. Only old_root → new_root is visible on-chain — the number of nullifiers is hidden inside the root transition.
Problems with IMT:
- Limited batch size. Works up to
MAX_Nnullifiers per TX (currently 8). Above that, the merchant needs multiple transactions, leaking a rough stake count (e.g., 13 TXs × 8 = ~100 clients). Recursive proving could lift this limit but adds significant client-side complexity. - Catastrophic state loss. The relayer is the sole maintainer of the nullifier tree. If it loses the database, there is no recovery path — nullifiers are private and never appear on-chain. All staked funds become permanently locked. The existing
fullResyncrecovery path is broken (it parses ciphertext fields as nullifiers) and there is no easy way to fix it. - Serialization bottleneck. The contract enforces
old_nullifier_root == current_root. Only one unstake TX can land per block. Every concurrent proof is invalidated and must be regenerated (~28s proving time wasted). The relayer must either serialize requests (throughput bottleneck) or let users race (wasted work).
Proposed solution
Replace the IMT with a more classical shielded pool with relayer-assisted note merge pattern, using a flat on-chain nullifier set (mapping(bytes32 => bool)) instead of a Merkle tree, allowing contract to perform the spent check directly (O(1) lookup) — no tree witnesses, no circuit involvement for non-membership proofs.
Note structure change
Add the holder's public key (PK) and an encrypted balance to the stake commitment:
// Current: hash2(amount, blinding)
// Proposed: hash(R_x, R_y, S_x, S_y, blinding, PK_x, PK_y)
// where (R, S) = ElGamal(amount, PK) — encrypted to holder's PK
- PK binding means the note is no longer a bearer instrument. Only the PK owner (who knows SK) can unstake.
- Encrypted balance means the relayer handles ciphertexts opaquely — it never learns the amounts. ElGamal on Grumpkin supports additive homomorphism natively.
Three operations
Stake (client → note): Client encrypts amount to merchant's PK, creates commitment, deducts from L2 balance. Circuit cost: similar to current — ElGamal encryption already present. Client shares the full note contents with the relayer: (ciphertext (R, S), blinding, leafIndex, PK). The relayer needs all of this to verify notes against the stake tree and perform merges, but critically does not learn the plaintext amount (the ciphertext is opaque without SK). If the ciphertext is emitted in the on-chain Staked event, the client only needs to share (blinding, leafIndex) out-of-band — the relayer reads the rest from chain data.
Merge (relayer, no SK required): Consumes up to MAX_N notes with the same PK, outputs one note with the same PK and homomorphically aggregated ciphertext. Requires only knowledge of note contents (ciphertext, blinding, leafIndex), not SK. The merge circuit:
- Verifies each input commitment against the stake tree
- Computes nullifiers and publishes them (flat mapping, no IMT)
- Performs ElGamal homomorphic addition:
R_out = ΣR_i,S_out = ΣS_i— this is ~5-10 constraints per EC point addition on Grumpkin, trivially cheap inside a BN254 circuit - Outputs a new commitment with the aggregated ciphertext and same PK
Unstake (merchant, requires SK): Consumes the single merged note. Merchant decrypts with SK, proves correct decryption, credits L2 balance. One proof, one nullifier — indistinguishable from any other single-note unstake.
Relayer workflow
- Clients pay by staking notes encrypted to the merchant's PK
- Relayer learns note metadata
(ciphertext, blinding, leafIndex)— but not plaintext amounts - Relayer periodically merges notes in the background (merchant doesn't need to be online)
- When the merchant wants to withdraw, they query the relayer for their current merged note and unstake it
- Alternatively: with each merge, the relayer publishes the new note data on-chain (encrypted to the merchant's PK), removing the need for the merchant to query the relayer at all
Nullifier handling
Nullifiers are published on-chain and checked against a simple mapping(bytes32 => bool). No IMT, no tree witnesses, no relayer state dependency. The merge TX reveals the number of notes being merged, but this is a relayer operation — it doesn't reveal whose notes are being merged or the amounts. The merchant's final unstake always consumes exactly 1 note.
Tradeoffs
| Concern | IMT (current) | Relayer merge (proposed) |
|---|---|---|
| Nullifier count privacy | Hidden in root transition | Merge count visible, but only to relayer operation level; final unstake always = 1 |
| Relayer state loss | Catastrophic — all funds locked | Recoverable — nullifiers on-chain, notes reconstructable |
| Serialization / race condition | One TX per block | Fully parallelizable |
| Relayer learns amounts | Yes (plaintext preimages) | No (encrypted ciphertexts only) |
| Relayer can steal | Yes (bearer instrument) | No (PK-bound, needs SK) |
| Relayer can block withdrawal | Yes (controls witnesses) | Only if note data not published on-chain; solvable by publishing encrypted notes |
| Relayer knows note ownership | Yes (serves per-user witnesses) | Yes (knows which notes share a PK) |
| Circuit complexity (merge) | N/A | Cheap — EC point addition on Grumpkin is ~70-140 constraints for 8 notes |
| Circuit complexity (unstake) | Expensive — IMT non-membership + insertion proofs | Cheap — single note, no tree proofs |
| Client proving time | ~28s (IMT proofs dominate) | ~2-5s (no IMT) |
| On-chain cost | 2 field elements (roots) | MAX_N nullifiers × 32 bytes; on L2 (Base) this is < $0.01 |
Possible Extensions
Hiding Merges Among Regular Transfers
It is possible to design a single circuit used for both merges and private transfers (within pools, between peers), making merges indistinguishable from transfers. The idea is as follows:
- The circuit always takes two input notes and produces two output notes. Either an input or an output note may be a dummy — slightly wasteful, but acceptable.
- Input notes must share the same PK. Spending rights are authorized by checking whether an appropriate signature (signed with SK) is provided:
- If a signature is provided, the output notes may have an arbitrary
receiver_PK. - If no signature is provided (replaced by a dummy), then
receiver_PKmust match thePKfrom the input notes (this is the merge operation). It can also be enforced that the second output note must be a dummy in this case.
- If a signature is provided, the output notes may have an arbitrary
For regular transfers, the user signs as normal. For merges, no signature is required — but only the merge operation is then permitted.