Shielded Token Bridge
Problem
A standard lock-and-mint bridge leaks everything: source address, destination address, amount, timing. I want a bridge that preserves the invariant “tokens out == tokens in” while hiding sender, receiver, and amount from all observers, including the relayers.
Solution
sealed contract ShieldedBridge {
field token: address
field commitments_root: bytes32 // append-only Merkle root on this chain
field nullifier_set: map<bytes32, bool>
field total_shielded: encrypted<amount>
event Deposit(commitment: bytes32 indexed, leaf_index: u64)
event Withdraw(nullifier: bytes32 indexed, receiver: address)
error InvalidProof()
error AlreadySpent()
error InvalidRoot()
// Deposit: user burns `msg.value` tokens and adds a commitment
// C = poseidon(encrypted_amount, secret, receiver_blinding)
// to the append-only tree. The `encrypted_amount` is a BFV ciphertext
// under the bridge's public key; total_shielded is kept in sync homomorphically.
action shielded_deposit(
note_commitment: bytes32,
encrypted_amount: encrypted<amount>,
range_proof: bytes,
) payable {
// Prove encrypted_amount is in [0, msg.value] and matches the commitment
given zk_verify(
deposit_range_circuit,
range_proof,
[note_commitment, hash(encrypted_amount), msg.value],
) or revert_with InvalidProof()
total_shielded = fhe_add(total_shielded, encrypted_amount)
let leaf_index = tree_append(note_commitment)
emit Deposit(note_commitment, leaf_index)
}
// Withdraw: user proves knowledge of a commitment in the tree whose
// nullifier has not been spent, and that the disclosed amount matches.
action shielded_withdraw(
nullifier: bytes32,
receiver: address,
root: bytes32,
amount_out: amount,
proof: bytes,
) {
given !nullifier_set[nullifier] or revert_with AlreadySpent()
given is_known_root(root) or revert_with InvalidRoot()
given zk_verify(
withdraw_circuit,
proof,
[root, nullifier, receiver, amount_out],
) or revert_with InvalidProof()
nullifier_set[nullifier] = true
// Decrement the shielded supply homomorphically
total_shielded = fhe_sub(total_shielded, encrypt(amount_out))
transfer(token, receiver, amount_out)
emit Withdraw(nullifier, receiver)
}
}
Explanation
The bridge has three moving parts:
- Commitment tree — every deposit appends
poseidon(amount, secret, blinding)to an append-only Merkle tree. The root is what provers prove membership against. - Nullifier set — spending a note publishes
nullifier = poseidon(secret, leaf_index). The circuit proves the nullifier is derived from a leaf in the tree without revealing which leaf. - FHE-encrypted supply —
total_shieldedis a BFV ciphertext of the total locked amount. The cross-chain relayer periodically proves (via another ZK circuit) thatdecrypt(total_shielded) == locked_balance(token), catching bugs and insolvency without leaking individual amounts.
On the destination chain, the mirror contract mints against a proof that a given (nullifier, receiver, amount) tuple was finalized on the source chain. Combining commitments + nullifiers + FHE supply tracking closes the usual leakage channels:
- Sender hidden: the
shielded_withdrawis called by a relayer, not the original depositor. - Receiver hidden on deposit: only the commitment is stored; receiver is revealed at withdraw time.
- Amount hidden on both ends: only
amount_outat withdraw leaks, and only to the receiver.
Gas Estimate
| Operation | Gas (L1) | pGas (Aster) |
|---|---|---|
shielded_deposit | ~420,000 | ~36,000 |
shielded_withdraw | ~390,000 | ~33,000 |
| Supply solvency proof | ~280,000 | ~24,000 |
Common Pitfalls
- Historical roots: Withdraw proofs are built against a root that was current at proof-generation time. Keep a ring buffer of the last ~100 roots and check membership with
is_known_root. - Nullifier collisions across chains: If the destination chain uses the same nullifier namespace, a withdraw on chain A can be replayed on chain B. Include
chain_idin the nullifier preimage. - Frontrunning the withdraw: A relayer can steal the fee by replacing the receiver. Fix
receiveras a public input inside the circuit so replacing it invalidates the proof. - FHE bootstrap cost: After ~10,000 deposits the
total_shieldedciphertext needs bootstrap. Schedule this viaamnesia { cleanup_phase: 1 day }. - Relayer censorship: If only one relayer can publish withdraws, they can censor. Expose
shielded_withdrawas permissionless and compensate the caller with a tip drawn from the note.
Variations
- Multi-asset: Key the nullifier set by
(token, nullifier)and addtokento the circuit public inputs. - Shielded transfers (no withdraw): Use a join-split circuit that consumes two notes and produces two new notes, never revealing amounts.
- Compliant shielding: Include a viewing key per jurisdiction; regulators can decrypt flows but the public cannot.