Solana Bridge
Anchor program that anchors merkle root, nullifiers, and validator stake on Solana with replay-protected withdrawals.
Solana bridge
The bridge is the on-chain settlement layer for paraloom's privacy pool. It's a single Anchor program — programs/paraloom/ — that anchors the merkle root, enforces nullifier uniqueness, manages validator stake and slashing, and wraps every withdrawal in expiration_slot-based replay protection.
| Value | |
|---|---|
| Program ID (devnet) | DSysqF2oYAuDRLfPajMnRULce2MjC3AtTszCkcDv1jco |
| Anchor version | 0.31 |
| Solana SDK | pinned independently of L2 crate |
| Source | programs/paraloom/src/lib.rs |
The program ID is also the value of declare_id! in programs/paraloom/src/lib.rs — both must match for L2 ↔ chain handshake to succeed.
Accounts
| Account | Kind | Purpose |
|---|---|---|
BridgeState | singleton | merkle root, program version, paused flag, statistics |
BridgeVault | PDA | holds all deposited SOL |
NullifierPDA | per-nullifier | init-on-withdraw → uniqueness check |
ValidatorRegistry | singleton | authority, total/active validators, min stake |
ValidatorAccount | per-validator PDA | stake, reputation, total earnings, times_slashed |
The PDA seeds are intentionally simple:
// Nullifier PDA — init fails if already exists
seeds = [b"nullifier", nullifier_bytes.as_ref()]
// Validator PDA — keyed by validator pubkey
seeds = [b"validator", validator.as_ref()]Version handshake
BridgeState.program_version is checked by the L2 client at startup and on every reconnect. If versions disagree, the L2 refuses to submit anything — preventing a stale bridge listener from interacting with an upgraded program (or vice versa). Closed under #69.
Deposit
Public action. SOL goes from the user wallet into BridgeVault; a commitment is emitted so off-chain pool state can absorb it.
pub fn deposit(
ctx: Context<Deposit>,
amount: u64,
commitment: [u8; 32],
) -> Result<()> {
transfer(/* user → vault */, amount)?;
bridge_state.total_deposits += 1;
emit!(DepositEvent { commitment, amount, slot: Clock::get()?.slot });
Ok(())
}The off-chain bridge listener (src/bridge/listener.rs) tails DepositEvents and adds the commitment to the sparse merkle tree. The on-chain program does not know about commitments other than to emit them — the merkle root is updated in batches by the consensus authority (next section).
Withdraw
This is where the privacy contract is enforced.
pub fn withdraw(
ctx: Context<Withdraw>,
proof: Vec<u8>, // Groth16, 192 B
nullifier: [u8; 32],
amount: u64,
recipient: Pubkey,
expiration_slot: u64,
) -> Result<()> {
// 1. Replay protection
let now = Clock::get()?.slot;
require!(now <= expiration_slot, ParaloomError::WithdrawalExpired);
// 2. Nullifier PDA: init_if_needed fails if already initialized.
// A second withdraw with the same nullifier cannot succeed,
// even if the off-chain BFT cohort is fully compromised.
ctx.accounts.nullifier_pda.nullifier = nullifier;
ctx.accounts.nullifier_pda.spent_slot = now;
// 3. Verify Groth16 proof against committed merkle root + public inputs
require!(
groth16_verify(&proof, &public_inputs(
nullifier, amount, recipient, bridge_state.merkle_root, expiration_slot
)),
ParaloomError::InvalidProof
);
// 4. Pay out from vault
**vault.try_borrow_mut_lamports()? -= amount;
**recipient_acc.try_borrow_mut_lamports()? += amount;
emit!(WithdrawEvent { nullifier, amount, recipient, slot: now });
Ok(())
}Three things to notice:
- Replay window is bounded.
expiration_slotis part of the proof's public inputs, so a leaked proof becomes useless after that slot. Closed under #61. - Uniqueness is on-chain. The nullifier PDA's
initstep is what enforces "spent at most once" — not validator memory. - Verification is on-chain. Even though a 7-of-10 BFT cohort agrees off-chain, the program re-verifies the proof against its own stored
merkle_root. The cohort can't approve a tx that doesn't verify.
Validator economics
Same program also manages the validator set:
pub fn register_validator(ctx, stake: u64) -> Result<()> {
require!(stake >= MIN_VALIDATOR_STAKE, ParaloomError::StakeTooLow);
transfer(/* validator → validator_pda */, stake)?;
validator_pda.stake_amount = stake;
validator_pda.reputation = 1000;
validator_pda.total_earnings = 0;
validator_pda.times_slashed = 0;
Ok(())
}
pub fn slash_validator(ctx, validator: Pubkey, slash_percentage: u8) -> Result<()> {
require!(authority == validator_registry.authority);
let amount = stake * slash_percentage / 100;
transfer(/* validator_pda → bridge_vault */, amount)?;
validator_pda.stake_amount -= amount;
validator_pda.times_slashed += 1;
Ok(())
}MIN_VALIDATOR_STAKE = 1 SOL(constant in the program)- Slashing evidence is built off-chain (Consensus) and submitted as a tx by the authority once the cohort has signed off.
What the L2 talks to
The L2 client speaks to the program via src/bridge/:
| File | Role |
|---|---|
solana_client.rs | RPC client + Anchor program client wrapper |
listener.rs | tails DepositEvent and WithdrawEvent |
submitter.rs | builds + submits withdraw txs after BFT consensus |
version.rs | program-version handshake |
Operational config
[bridge]
solana_rpc = "https://api.devnet.solana.com"
program_id = "DSysqF2oYAuDRLfPajMnRULce2MjC3AtTszCkcDv1jco"
poll_interval_s = 10 # listener polls deposit events at this intervalTests
The bridge has a dedicated end-to-end suite that exercises the program against a local validator:
tests/bridge_integration_test.rstests/validator_privacy_e2e.rs— full deposit → transfer → withdraw
See Quickstart for running these locally.
What the bridge does not do
- It does not run circuits (proof generation is client-side; verification is in-program).
- It does not store transfer commitments — only deposits emit, and the merkle root is updated by the cohort.
- It does not maintain off-chain validator reputation directly — only stake and
times_slashedare on-chain. Live reputation lives in the BFT cohort.
These boundaries keep the on-chain program small (low compute units per tx) while still anchoring the trust-critical state.