ParaloomPARALOOM

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 version0.31
Solana SDKpinned independently of L2 crate
Sourceprograms/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

AccountKindPurpose
BridgeStatesingletonmerkle root, program version, paused flag, statistics
BridgeVaultPDAholds all deposited SOL
NullifierPDAper-nullifierinit-on-withdraw → uniqueness check
ValidatorRegistrysingletonauthority, total/active validators, min stake
ValidatorAccountper-validator PDAstake, 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:

  1. Replay window is bounded. expiration_slot is part of the proof's public inputs, so a leaked proof becomes useless after that slot. Closed under #61.
  2. Uniqueness is on-chain. The nullifier PDA's init step is what enforces "spent at most once" — not validator memory.
  3. 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/:

FileRole
solana_client.rsRPC client + Anchor program client wrapper
listener.rstails DepositEvent and WithdrawEvent
submitter.rsbuilds + submits withdraw txs after BFT consensus
version.rsprogram-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 interval

Tests

The bridge has a dedicated end-to-end suite that exercises the program against a local validator:

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_slashed are 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.

On this page