Skip to main content

Bridge Program State Machine Analysis

Program Overview

Program ID: 8SE6gCijcFQixvDQqWu29mCm9AydN8hcwWh2e2Q6RQgE

The bridge program is a Solana-to-L2 bridge that supports:

  • SOL deposits from users to an L2 domain
  • Sequencer-attested withdrawals from L2 back to Solana
  • Batch state root submissions with Groth16 proof verification via the verifier program

1. Account States

1.1 Config Account

PDA Seeds: ["config", domain]

FieldTypeDescription
sequencer_authorityPubkeyThe authorized sequencer that can submit batches and process withdrawals
domain[u8; 32]Unique identifier for this bridge domain
state_root[u8; 32]Current Merkle root of L2 state (starts at 0 or genesis)
batch_indexu64Index of the last processed batch
bumpu8PDA bump seed
is_initializedu8Initialization flag (1 = initialized)
_padding[u8; 6]Alignment padding

State Diagram:

 __________________________
| NonExistent |
|__________________________|
|
| Initialize
v
__________________________
| Initialized |
| state_root = [0; 32] |
| batch_index = 0 |
|__________________________|
|
| SubmitBatch (verified)
v
__________________________
| Updated |
| state_root, batch_index |
|__________________________|

States:

  • NonExistent: Account does not exist (lamports = 0, data empty)
  • Initialized: is_initialized == 1, sequencer_authority != 0, domain != 0

1.2 Vault Account

PDA Seeds: ["vault", domain]

FieldTypeDescription
domain[u8; 32]Domain this vault belongs to
bumpu8PDA bump seed
_padding[u8; 7]Alignment padding

State Diagram:

 __________________________
| NonExistent |
|__________________________|
|
| Initialize
v
__________________________
| Initialized |
| (Vault PDA) |
|__________________________|
|
| Deposit: +lamports
v
__________________________
| Active |
| (lamports increase) |
|__________________________|
|
| WithdrawAttested: -lamports
v
__________________________
| Active |
| (lamports decrease) |
|__________________________|

States:

  • NonExistent: Account does not exist
  • Initialized: bump != 0 (PDA derivation guarantees non-zero bump)

Note: The vault holds SOL (lamports). It is a lamport sink/source, not a complex state machine.

1.3 DepositReceipt Account

PDA Seeds: ["receipt", domain, depositor, nonce_le_bytes]

FieldTypeDescription
depositorPubkeyThe user who made the deposit
domainPubkeyThe bridge domain
amountu64Amount of SOL deposited (in lamports)
nonceu64Unique nonce for this deposit
tsi64Unix timestamp of the deposit
bumpu8PDA bump seed
is_initializedu8Initialization flag
_padding[u8; 6]Alignment padding

State Diagram:

 __________________________
| NonExistent |
|__________________________|
|
| Deposit
v
__________________________
| Initialized/Finalized |
| (write-once) |
|__________________________|

States:

  • NonExistent: Account does not exist (data empty)
  • Initialized: is_initialized == 1, receipt is permanent proof of deposit

Note: DepositReceipts are write-once, read-many. Once created, they cannot be modified or deleted by the program.

1.4 UsedNullifier Account

PDA Seeds: ["nullifier", domain, nullifier]

FieldTypeDescription
domain[u8; 32]The bridge domain
nullifier[u8; 32]Unique nullifier hash (prevents replay)
recipientPubkeyThe recipient of the withdrawal
amountu64Amount withdrawn
usedu8Used flag (1 = used)
bumpu8PDA bump seed
_padding[u8; 6]Alignment padding

State Diagram:

 __________________________
| NonExistent |
|__________________________|
|
| WithdrawAttested
v
__________________________
| Used |
| (replay protected) |
|__________________________|

States:

  • NonExistent: Account does not exist - withdrawal can proceed
  • Used: used == 1 - withdrawal has been processed, replay prevented

2. Instructions and State Transitions

2.1 Initialize (Discriminator: 0)

Accounts:

IndexNameWritableSignerDescription
0payerYesYesFee payer
1configYesNoConfig PDA to create
2vaultYesNoVault PDA to create
3system_programNoNoSystem program

Params:

pub struct InitParams {
pub sequencer_authority: Pubkey,
pub domain: [u8; 32],
}

Pre-conditions (Guards):

  • payer must be a signer
  • domain != [0u8; 32]
  • sequencer_authority != Pubkey::default()
  • config_account.key() == derive_config_pda(program_id, domain)
  • vault_account.key() == derive_vault_pda(program_id, domain)
  • config_account.lamports() == 0 (not already funded)
  • config_account.data_is_empty() (not already initialized)
  • vault_account.data_is_empty() (not already initialized)

State Transitions:

Config: NonExistent -> Initialized

  • sequencer_authority = params.sequencer_authority
  • domain = params.domain
  • state_root = [0u8; 32]
  • batch_index = 0
  • is_initialized = 1

Vault: NonExistent -> Initialized

  • domain = params.domain
  • bump = vault_bump

Post-conditions:

  • Config and Vault accounts are created and owned by the bridge program
  • Config.is_initialized == 1
  • Vault.bump != 0

Error Conditions:

ErrorCondition
NotEnoughAccountKeysLess than 4 accounts provided
MissingRequiredSignaturePayer is not a signer
InvalidInstructionDataDomain is all zeros
InvalidArgumentSequencer authority is default pubkey
InvalidSeedsConfig or Vault PDA doesn't match expected
AccountAlreadyInitializedConfig/Vault already has lamports or data

2.2 Deposit (Discriminator: 1)

Accounts:

IndexNameWritableSignerDescription
0depositorYesYesUser depositing SOL
1configNoNoBridge config (read-only for domain)
2vaultYesNoBridge vault receiving SOL
3deposit_receiptYesNoReceipt PDA to create
4system_programNoNoSystem program

Params:

pub struct DepositParams {
pub amount: u64,
pub nonce: u64,
}

Pre-conditions (Guards):

  • depositor must be a signer
  • amount > 0
  • config.is_initialized == 1
  • vault_account.key() == derive_vault_pda(program_id, config.domain)
  • receipt_account.key() == derive_deposit_receipt_pda(program_id, domain, depositor, nonce)
  • receipt_account.data_is_empty() (nonce not already used)

State Transitions:

Vault: lamports += amount

DepositReceipt: NonExistent -> Initialized

  • depositor = depositor.key()
  • domain = config.domain
  • amount = params.amount
  • nonce = params.nonce
  • ts = clock.unix_timestamp
  • is_initialized = 1

Post-conditions:

  • SOL transferred from depositor to vault
  • DepositReceipt created with deposit details
  • Log emitted: ZE_DEPOSIT:{depositor}:{amount}:{nonce}

Error Conditions:

ErrorCondition
NotEnoughAccountKeysLess than 5 accounts provided
MissingRequiredSignatureDepositor is not a signer
InvalidInstructionDataAmount is 0
UninitializedAccountConfig not initialized
InvalidSeedsVault or Receipt PDA mismatch
AccountAlreadyInitializedReceipt already exists (nonce reuse)
InvalidArgumentDepositor is default pubkey, domain is zeros

2.3 WithdrawAttested (Discriminator: 2)

Accounts:

IndexNameWritableSignerDescription
0sequencerYesYesAuthorized sequencer
1configNoNoBridge config
2vaultYesNoBridge vault (source of funds)
3recipientYesNoAccount receiving withdrawn SOL
4used_nullifierYesNoNullifier PDA to create
5system_programNoNoSystem program

Params:

pub struct WithdrawAttestedParams {
pub recipient: Pubkey,
pub amount: u64,
pub nullifier: [u8; 32],
}

Pre-conditions (Guards):

  • sequencer must be a signer
  • config.is_initialized == 1
  • sequencer.key() == config.sequencer_authority (authorization)
  • amount > 0
  • vault_account.key() == derive_vault_pda(program_id, config.domain)
  • nullifier_account.key() == derive_nullifier_pda(program_id, domain, nullifier)
  • nullifier_account.data_is_empty() (not already used - replay protection)

Implementation detail: The recipient account (account index 3) is used directly for the SOL transfer. The recipient field inside WithdrawAttestedParams is not validated or used.

State Transitions:

Vault: lamports -= amount

Recipient: lamports += amount

UsedNullifier: NonExistent -> Used

  • domain = config.domain
  • nullifier = params.nullifier
  • recipient = recipient.key()
  • amount = params.amount
  • used = 1

Post-conditions:

  • SOL transferred from vault to recipient
  • Nullifier account created (prevents replay)
  • Logs emitted: withdraw:{amount}, ts:{timestamp}

Error Conditions:

ErrorCondition
NotEnoughAccountKeysLess than 6 accounts provided
MissingRequiredSignatureSequencer is not a signer
UninitializedAccountConfig not initialized
IncorrectAuthoritySequencer is not authorized
InvalidInstructionDataAmount is 0, or nullifier already used (replay)
InvalidSeedsVault or Nullifier PDA mismatch
InvalidArgumentDomain or nullifier is all zeros

2.4 SubmitBatch (Discriminator: 3)

Accounts:

IndexNameWritableSignerDescription
0sequencerNoYesAuthorized sequencer
1configYesNoBridge config to update
2verifier_programNoNoVerifier program for Groth16 proof checks
3vk_accountNoNoVerifying key account used by the verifier
4+recipientsNoNoRecipient accounts for withdrawal intents

Params (Header):

pub struct SubmitBatchHeader {
pub prev_batch_index: u64,
pub new_batch_index: u64,
pub new_state_root: [u8; 32],
pub proof_len: u32,
pub withdrawal_count: u32,
}

Implementation detail: The current on-chain handler skips one extra byte before parsing the header. Clients must include a one-byte padding value after the discriminator so the header parses correctly.

Variable-length data after header:

  • proof: [u8; 256] - Groth16 proof bytes (proof_len must equal 256)
  • public_inputs: [BatchPublicInputs] - 200-byte public input struct
  • withdrawals: [WithdrawalRequest; withdrawal_count] - Withdrawal intents
pub struct BatchPublicInputs {
pub pre_state_root: [u8; 32],
pub post_state_root: [u8; 32],
pub pre_shielded_root: [u8; 32],
pub post_shielded_root: [u8; 32],
pub withdrawal_root: [u8; 32],
pub batch_hash: [u8; 32],
pub batch_id: u64,
}
pub struct WithdrawalRequest {
pub recipient: Pubkey,
pub amount: u64,
}

Pre-conditions (Guards):

  • At least 4 accounts provided
  • sequencer must be a signer
  • config.is_initialized == 1
  • sequencer.key() == config.sequencer_authority
  • header.prev_batch_index == config.batch_index (sequential)
  • header.new_batch_index == config.batch_index + 1 (increment by 1)
  • header.proof_len == 256
  • Public inputs post_state_root == header.new_state_root
  • Public inputs batch_id == header.new_batch_index
  • accounts[4..].len() == header.withdrawal_count (account count matches)
  • For each withdrawal: recipient_account.key() == withdrawal.recipient
  • Instruction data is properly formatted

State Transitions:

Verifier CPI:

  • Calls verifier program with Groth16 proof + public inputs
  • Fails the instruction if the proof is invalid

Config (after successful verification):

  • state_root = header.new_state_root
  • batch_index = header.new_batch_index

Note: No withdrawals are executed - only logged as intents

Post-conditions:

  • Config.state_root updated to new merkle root
  • Config.batch_index incremented
  • For each withdrawal: Log ZE_WITHDRAW_INTENT:{recipient}:{amount}
  • Final log: ZE_BATCH_FINALIZED:{domain}:{batch_index}
  • Groth16 proof verified via verifier CPI

Error Conditions:

ErrorCondition
NotEnoughAccountKeysLess than 4 accounts
MissingRequiredSignatureSequencer not a signer
UninitializedAccountConfig not initialized
IncorrectAuthoritySequencer not authorized
InvalidInstructionDataData too short, bad prev/new batch index, invalid proof length, or public input mismatch
InvalidAccountDataRecipient count mismatch or recipient key mismatch
(Verifier CPI error)Verifier program rejects the proof or inputs

3. Complete Flow Diagrams

3.1 Deposit Flow

 __________________________
| USER SUBMITS DEPOSIT |
| Deposit(amount, nonce) |
|__________________________|
|
v
__________________________
| BRIDGE VALIDATES |
| - User is signer |
| - amount > 0 |
| - Config initialized |
| - Vault PDA valid |
| - Nonce not used |
|__________________________|
|
v
__________________________
| BRIDGE TRANSITION |
| - Transfer SOL to Vault |
| - Create DepositReceipt |
| - Log ZE_DEPOSIT |
|__________________________|
|
v
__________________________
| SEQUENCER CREDITS USER |
| (Indexer consumes log) |
|__________________________|

3.2 Withdrawal Flow (Attested)

 __________________________
| SEQUENCER SUBMITS |
| WithdrawAttested(...) |
|__________________________|
|
v
__________________________
| BRIDGE VALIDATES |
| - Sequencer is signer |
| - Sequencer == authority |
| - amount > 0 |
| - Config initialized |
| - Vault PDA valid |
| - Nullifier not used |
|__________________________|
|
v
__________________________
| BRIDGE TRANSITION |
| - Create UsedNullifier |
| - Transfer SOL to user |
|__________________________|
|
v
__________________________
| USER RECEIVES SOL |
|__________________________|

3.3 Batch Submission Flow

 __________________________
| SEQUENCER SUBMITS BATCH |
| SubmitBatch(...) |
|__________________________|
|
v
__________________________
| BRIDGE VALIDATES |
| - Sequencer is signer |
| - Sequencer == authority |
| - prev_batch == current |
| - new_batch == current+1 |
| - Account count matches |
| - Recipients match |
|__________________________|
|
v
__________________________
| VERIFY PROOF (CPI) |
| - Groth16 proof + inputs |
| - Verifier program |
|__________________________|
|
v
__________________________
| BRIDGE TRANSITION |
| - Update state_root |
| - Increment batch_index |
| - Log withdrawal intents |
| - Emit ZE_BATCH_FINALIZED |
|__________________________|
|
v
__________________________
| LATER: WithdrawAttested |
| for each logged intent |
|__________________________|

4. State Transition Summary Table

AccountFrom StateInstructionTo StateReversible
ConfigNonExistentInitializeInitializedNo
ConfigInitializedSubmitBatchInitialized (updated)No (append-only)
VaultNonExistentInitializeInitializedNo
VaultInitializedDepositInitialized (+lamports)No
VaultInitializedWithdrawAttestedInitialized (-lamports)No
DepositReceiptNonExistentDepositInitializedNo
UsedNullifierNonExistentWithdrawAttestedUsedNo

5. Error States and Recovery

5.1 Error Categories

Instruction-Level Errors:

ErrorCodeRecovery
NotEnoughAccountKeys-Retry with correct accounts
MissingRequiredSignature-Retry with proper signer
InvalidInstructionData-Fix instruction data format
InvalidSeeds-Derive correct PDA addresses
InvalidArgument-Fix parameters (non-zero values)

State-Level Errors:

ErrorCodeRecovery
AccountAlreadyInitialized-Cannot recover - account exists
UninitializedAccount-Initialize account first
IncorrectAuthority-Use authorized sequencer

Replay Protection:

ScenarioPreventionRecovery
Double deposit (same nonce)DepositReceipt PDA existsUse different nonce
Double withdrawalUsedNullifier PDA existsCannot recover - intended behavior
Batch replaybatch_index sequential checkCannot skip or replay batches

5.2 Invariants

  • Config Singleton: Only one Config per domain can exist
  • Vault Singleton: Only one Vault per domain can exist
  • Nonce Uniqueness: Each (domain, depositor, nonce) tuple creates a unique DepositReceipt
  • Nullifier Uniqueness: Each nullifier can only be used once per domain
  • Sequential Batches: Batch index must increment by exactly 1
  • Authority Check: Only the configured sequencer can submit batches or process withdrawals

5.3 Failure Scenarios

ScenarioSymptomMitigation
Insufficient vault balanceWithdrawAttested failsEnsure deposits > withdrawals
Sequencer key compromiseUnauthorized withdrawalsMigrate to new domain (no current upgrade path)
Missed batchCannot submit batch N+2 before N+1Submit batches in order
Orphaned depositDeposit made but L2 doesn't creditOff-chain reconciliation needed

6. Key Design Observations

  • Append-Only State: All state accounts (Config, DepositReceipt, UsedNullifier) are append-only or immutable after creation. This provides strong auditability.

  • No Close/Reclaim: There is no mechanism to close accounts or reclaim rent. DepositReceipts and UsedNullifiers are permanent.

  • Two-Phase Withdrawal: SubmitBatch logs withdrawal intents (ZE_WITHDRAW_INTENT) but does not execute them. WithdrawAttested must be called separately with nullifiers.

  • ZK Verification Active: SubmitBatch performs a CPI to the verifier program and fails if the Groth16 proof is invalid.

  • Single Sequencer: The system has a single point of trust - the sequencer_authority. There is no multi-sig or upgrade mechanism visible in the code.

  • Domain Isolation: Each domain has its own Config, Vault, and derived PDAs. Multiple independent bridges can coexist.

Use these links to jump directly to the implementation files: