Skip to main content

Exit Overview

Exits are how users withdraw funds from Ark back to on-chain Bitcoin. There are two types:
  1. Cooperative Exits (Offboard): Work with the server for efficient on-chain settlement
  2. Unilateral Exits: Independent recovery without server cooperation
Cooperative exits are cheaper and faster, but unilateral exits are your ultimate safety guarantee - you can always recover your funds, even if the server disappears.

Cooperative Exits (Offboard)

How Offboarding Works

The server includes your payment in the next round transaction:
1. Submit offboard request with VTXOs
2. Server includes output in next round
3. Round transaction confirms on-chain
4. Receive Bitcoin at specified address
Advantages:
  • Low fees (shared among round participants)
  • Fast (next round, typically 5-15 minutes)
  • Simple process
  • No VTXO exit chain needed
Trade-offs:
  • Requires server cooperation
  • Subject to round timing
  • Round fees apply (from ArkInfo.fees)

Offboard vs Send On-chain

Two cooperative methods exist: Offboard: Entire VTXO(s) sent on-chain
Input:  1 VTXO (10,000 sats)
Output: On-chain payment (9,500 sats after fees)
Change: None
Send On-chain: Specific amount with change
Input:  1 VTXO (10,000 sats)
Output: On-chain payment (6,000 sats)
Change: 3,500 sats (new VTXO)

Unilateral Exits

Unilateral exits are the core security mechanism of Ark. The implementation is in bark/src/exit/mod.rs.

Exit System Architecture

pub struct Exit {
    tx_manager: ExitTransactionManager,
    persister: Arc<dyn BarkPersister>,
    chain_source: Arc<ChainSource>,
    movement_manager: Arc<MovementManager>,
    exit_vtxos: Vec<ExitVtxo>,
}
Components:
  • Transaction Manager: Constructs and broadcasts exit transactions
  • Persister: Stores exit state across restarts
  • Chain Source: Monitors blockchain confirmations
  • Exit VTXOs: Tracks each VTXO’s exit progress

Exit State Machine

Each exiting VTXO progresses through states:
pub enum ExitState {
    Start(ExitStartState),              // Just marked for exit
    Processing(ExitProcessingState),     // Broadcasting transactions
    AwaitingDelta(ExitAwaitingDeltaState), // Waiting for timelock
    Claimable(ExitClaimableState),      // Ready to claim
    ClaimInProgress(ExitClaimInProgressState),
    Claimed(ExitClaimedState),          // Funds recovered
}

Exit Lifecycle

1. Start Exit

exit.start_exit_for_vtxos(&[vtxo]).await?;
Marks VTXOs for exit:
  • Validates VTXO is above dust limit (330 sats)
  • Stores exit entry in database
  • Changes VTXO state to “Spent”
  • Creates movement record
Dust protection:
if vtxo.amount() < P2TR_DUST {
    return Err(ExitError::DustLimit {
        vtxo: vtxo.amount(),
        dust: P2TR_DUST,
    });
}

2. Initialize Exit

exit.sync_no_progress(&onchain_wallet).await?;
Prepares exit transactions:
  • Constructs full exit transaction chain
  • Tracks transaction IDs (TXIDs)
  • Validates exit path
  • Prepares fee anchors

3. Progress Exit

exit.progress_exits(&wallet, &mut onchain_wallet, fee_rate).await?;
Broadcasting phase:
Progress Step 1: Broadcast root exit transaction
  → Includes CPFP fee anchor
  → Wait for confirmation

Progress Step 2: Wait for exit_delta blocks
  → Relative timelock from parent confirmation
  → Typically 144-2016 blocks

Progress Step 3: Broadcast next level transaction
  → Repeat for each level in tree
  → Apply fee-bumping if needed

Progress Step N: Final VTXO output confirmed
  → State: Claimable

4. Claim Funds

let drain_addr = Address::from_str("bc1p...")?;
let psbt = exit.drain_exits(&claimable, &wallet, drain_addr, fee_rate).await?;
Final claim transaction:
  • Spends claimable exit outputs
  • Sends to recovery address
  • Applies appropriate fees
  • Marks exit as complete

Exit Transaction Structure

Each exit transaction follows this pattern:
pub fn create_exit_tx(
    prevout: OutPoint,
    output: TxOut,
    signature: Option<&Signature>,
    fee: Amount,
) -> Transaction
Transaction layout:
Exit Transaction
Inputs:
  [0] Previous exit output (or chain anchor)
  
Outputs:
  [0] Next level output (or final VTXO output)
  [1] Fee Anchor (P2A, amount based on fee rate)
Fixed weight: Each exit transaction is exactly 124 vBytes:
pub const EXIT_TX_WEIGHT: Weight = Weight::from_vb_unchecked(124);

Exit Delta (Timelock)

The exit_delta parameter (from ArkInfo) enforces a relative timelock:
pub vtxo_exit_delta: BlockDelta  // Typically 144-2016 blocks
Purpose:
  • Gives server time to refresh VTXOs cooperatively
  • Prevents spam attacks on chain
  • Allows orderly shutdown if server goes offline
Example timeline (exit_delta = 144):
Block 800,000: Parent tx confirms
Block 800,144: Exit tx becomes spendable (+ exit_delta)
Block 800,144+: Can broadcast next level
Script implementation:
pub fn delayed_sign(delay_blocks: BlockDelta, pubkey: XOnlyPublicKey) -> ScriptBuf {
    let csv = Sequence::from_height(delay_blocks);
    Script::builder()
        .push_int(csv.to_consensus_u32() as i64)
        .push_opcode(OP_CSV)
        .push_opcode(OP_DROP)
        .push_x_only_key(&pubkey)
        .push_opcode(OP_CHECKSIG)
        .into_script()
}

Fee Management

CPFP Fee-Bumping

All exit transactions include a fee anchor:
pub fn fee_anchor_with_amount(fee: Amount) -> TxOut {
    TxOut {
        script_pubkey: ScriptBuf::new_p2tr_tweaked(/* ... */),
        value: fee,
    }
}
CPFP process:
  1. Exit transaction broadcasts with low fee
  2. Create child transaction spending fee anchor
  3. Child pays for both (package relay)
  4. Miners include entire package

Fee Rate Selection

pub async fn progress_exits(
    fee_rate_override: Option<FeeRate>,
) -> Result<()>
Fee selection strategy:
  • No override: Use chain_source.fee_rates().fast
  • With override: Use specified rate
  • RBF bumping: Must exceed previous fee by increment
Cost calculation:
let total_cost = exit_depth × EXIT_TX_WEIGHT × fee_rate;

// Example: depth 3, 10 sat/vB
total_cost = 3 × 124 vB × 10 sat/vB = 3,720 sats

Exit Transaction Packages

The system tracks related transactions together:
pub struct ExitTransactionPackage {
    pub exit: TransactionInfo,         // Main exit transaction
    pub child: Option<ChildTransactionInfo>,  // CPFP child if needed
}

pub struct TransactionInfo {
    pub txid: Txid,
    pub tx: Transaction,
}
Package features:
  • Atomic broadcast (both or neither)
  • Fee coordination
  • RBF replacement tracking
  • Status monitoring

Exit Monitoring

Checking Exit Status

let status = exit.get_exit_status(
    vtxo_id,
    include_history: true,
    include_transactions: true,
).await?;
Returns:
pub struct ExitTransactionStatus {
    pub vtxo_id: VtxoId,
    pub state: ExitState,
    pub history: Option<Vec<ExitState>>,  // State transitions
    pub transactions: Vec<ExitTransactionPackage>,
}

Exit Progress Status

pub struct ExitProgressStatus {
    pub vtxo_id: VtxoId,
    pub state: ExitState,
    pub error: Option<ExitError>,  // If progress failed
}

Claimable Tracking

let claimable = exit.list_claimable();
let pending_total = exit.pending_total();
let has_pending = exit.has_pending_exits();

Common Exit Scenarios

Scenario 1: Server Goes Offline

1. Server stops responding
2. VTXOs approaching expiry
3. Start unilateral exit for all VTXOs
4. Wait for confirmations + exit_delta
5. Claim funds to recovery wallet
Timeline (exit_delta = 144):
T+0:     Start exits, broadcast first level
T+10min: First level confirms (1 conf)
T+24h:   Exit delta expires (144 blocks)
T+24h:   Broadcast second level
T+24.5h: Second level confirms
T+48h:   Exit delta expires again
T+48h:   Broadcast final level
T+48.5h: Funds claimable

Scenario 2: Emergency Exit Single VTXO

// Exit just one specific VTXO
let vtxo = wallet.get_vtxo(vtxo_id)?;
exit.start_exit_for_vtxos(&[vtxo]).await?;

loop {
    exit.sync(&wallet, &mut onchain_wallet).await?;
    exit.progress_exits(&wallet, &mut onchain_wallet, None).await?;
    
    if exit.get_exit_vtxo(vtxo_id).unwrap().is_claimable() {
        break;
    }
    
    sleep(Duration::from_secs(600)).await;  // Check every 10 min
}

let drain_addr = onchain_wallet.new_address()?;
let psbt = exit.drain_exits(
    &[exit.get_exit_vtxo(vtxo_id).unwrap()],
    &wallet,
    drain_addr,
    None,
).await?;

onchain_wallet.broadcast_psbt(psbt).await?;

Scenario 3: Batch Exit All Funds

// Exit entire wallet
exit.start_exit_for_entire_wallet().await?;

let all_claimable_height = exit.all_claimable_at_height().await;
println!("All funds claimable at height: {:?}", all_claimable_height);

Error Handling

Common exit errors:
pub enum ExitError {
    DustLimit { vtxo: Amount, dust: Amount },
    InsufficientConfirmedFunds { required: Amount, available: Amount },
    VtxoNotClaimable { vtxo: VtxoId },
    ClaimMissingInputs,
    ClaimFeeExceedsOutput { needed: Amount, output: Amount },
    // ... more variants
}
Error recovery strategies:
VTXO is too small to exit profitably. Consider:
  • Combining with other VTXOs in a round first
  • Spending off-chain to another user
  • Waiting for lower fee rates
Not enough on-chain funds for CPFP. Solutions:
  • Wait for existing UTXOs to confirm
  • Deposit more funds to on-chain wallet
  • Use lower fee rates (slower confirmation)
Exit not ready yet. Check:
  • Has parent transaction confirmed?
  • Has exit_delta passed since parent confirmation?
  • Is exit state progressed properly?

Exit Best Practices

DO:
  • Monitor VTXO expiries and exit well before expiry
  • Keep on-chain wallet funded for CPFP
  • Test exit process on testnet/signet first
  • Store exit state persistently
  • Verify all exit transactions before broadcast
  • Use fee-bumping when necessary
DON’T:
  • Exit dust VTXOs during high fees
  • Clear exit state without claiming funds
  • Assume exits will be fast
  • Forget about exit_delta timelock
  • Broadcast without validating signatures
  • Rely solely on cooperative exits

Exit Performance Metrics

Typical timelines:
Exit DepthConfirmationsExit Delta (blocks)Total Time (estimate)
1 (board)1144~24 hours
3 (round)3144 × 3~72 hours
5 (arkoor)5144 × 5~120 hours
Assuming 10-minute blocks and 144-block exit_delta Cost comparison (10 sat/vB):
Exit TypeTransactionsWeightCost
Cooperative0 (included in round)~140 vB~200 sats (shared)
Unilateral Depth 11124 vB1,240 sats
Unilateral Depth 33372 vB3,720 sats
Unilateral Depth 55620 vB6,200 sats

Exit Transaction Validation

Before broadcasting, validate:
vtxo.validate(&chain_anchor_tx)?;

for exit_tx in vtxo.transactions() {
    // Check signatures
    // Verify output scripts
    // Confirm fee anchors
    // Validate amounts
}
Validation checklist:
  • ✓ Chain anchor confirms on-chain
  • ✓ All signatures valid
  • ✓ Exit delta correctly enforced
  • ✓ Fee anchors present
  • ✓ Output amounts match expectations
  • ✓ No unexpected spend paths
  • ✓ All outputs are standard

Emergency Exit Procedure

If the server becomes malicious or unresponsive:
  1. Immediate Action
    exit.start_exit_for_entire_wallet().await?;
    
  2. Monitor Progress
    // Run in loop until complete
    exit.sync(&wallet, &mut onchain).await?;
    exit.progress_exits(&wallet, &mut onchain, Some(high_fee_rate)).await?;
    
  3. Claim When Ready
    let claimable = exit.list_claimable();
    if !claimable.is_empty() {
        let psbt = exit.drain_exits(&claimable, &wallet, recovery_addr, None).await?;
        broadcast(psbt).await?;
    }
    
  4. Verify Recovery
    • Check recovery address receives funds
    • Verify amounts match expectations (minus fees)
    • Archive exit records for auditing

Further Reading

Ark Protocol

Overall protocol architecture

VTXOs

Understanding VTXO structure

Build docs developers (and LLMs) love