Skip to main content
The offboard module provides functionality for cooperatively exiting VTXOs to on-chain Bitcoin addresses. This enables users to cash out of the Ark with server cooperation, creating an on-chain transaction.

Overview

Offboarding allows users to:
  • Exit to on-chain: Convert VTXOs to regular Bitcoin UTXOs
  • Batch exits: Server can batch multiple offboards in one transaction
  • Cooperative exits: Faster and cheaper than unilateral exits
  • Forfeit signatures: Users sign forfeits before receiving funds
The offboard process uses forfeit transactions to protect the server from partial exit attacks.

OffboardRequest

Specifies the destination and parameters for an offboard.
lib/src/offboard.rs
pub struct OffboardRequest {
    pub script_pubkey: ScriptBuf,
    pub net_amount: Amount,
    pub deduct_fees_from_gross_amount: bool,
    pub fee_rate: FeeRate,
}
Fields:
  • script_pubkey: Destination scriptPubkey (where funds will be sent)
  • net_amount: Target amount in satoshis
  • deduct_fees_from_gross_amount: If true, fees come from input; if false, added on top
  • fee_rate: Fee rate used for calculating the offboard fee

Methods

pub fn validate(&self) -> Result<(), InvalidOffboardRequestError>
Validate that the offboard has a standard (relay-able) output script.
pub fn to_txout(&self) -> TxOut
Convert the request into a transaction output.

Errors

lib/src/offboard.rs
pub enum InvalidOffboardRequestError {
    // Non-standard output (won't relay)
}

OffboardForfeitContext

Context for signing and validating forfeit transactions.
lib/src/offboard.rs
pub struct OffboardForfeitContext<'a, V> {
    input_vtxos: &'a [V],
    offboard_tx: &'a Transaction,
}
Generic V: Any type that implements AsRef<Vtxo>

Creating Context

pub fn new(
    input_vtxos: &'a [V],
    offboard_tx: &'a Transaction,
) -> Self
Create context with input VTXOs and the offboard transaction. Panics if input_vtxos is empty.

Validating Offboard Transaction

pub fn validate_offboard_tx(
    &self,
    req: &OffboardRequest,
) -> Result<(), InvalidOffboardTxError>
Validate that the offboard transaction matches the request. Checks:
  • Offboard output (vout 0) matches request scriptPubkey and amount
  • Connector output (vout 1) has sufficient value for forfeit connectors
    • Required: 330 sats * number_of_inputs

Offboard Transaction Errors

lib/src/offboard.rs
pub enum InvalidOffboardTxError {
    // String-based error with detailed message
}

User: Signing Forfeits

pub fn user_sign_forfeits(
    &self,
    keys: &[impl Borrow<Keypair>],
    server_nonces: &[PublicNonce],
) -> OffboardForfeitSignatures
Sign forfeit transactions for all input VTXOs. Panics if:
  • Wrong number of keys or nonces
  • validate_offboard_tx() would have returned an error
Always call validate_offboard_tx() first!

OffboardForfeitSignatures

lib/src/offboard.rs
pub struct OffboardForfeitSignatures {
    pub public_nonces: Vec<PublicNonce>,
    pub partial_signatures: Vec<PartialSignature>,
}
User’s forfeit signatures to send to the server.

Server: Checking and Finalizing Forfeits

pub fn check_finalize_transactions(
    &self,
    server_key: &Keypair,
    connector_key: &Keypair,
    server_pub_nonces: &[PublicNonce],
    server_sec_nonces: Vec<SecretNonce>,
    user_pub_nonces: &[PublicNonce],
    user_partial_sigs: &[PartialSignature],
) -> Result<Vec<Transaction>, InvalidUserPartialSignatureError>
Check user’s partial signatures and finalize forfeit transactions. Panics if:
  • Wrong number of nonces or signatures
  • validate_offboard_tx() would have returned an error
Returns:
  • Vector of fully signed forfeit transactions (one per input VTXO)

Invalid User Signature Error

lib/src/offboard.rs
pub struct InvalidUserPartialSignatureError {
    pub vtxo: VtxoId,
}
Indicates which VTXO had an invalid partial signature.

Forfeit Transaction Structure

Forfeits protect the server by ensuring users can’t perform partial exit attacks.

Single Input Forfeit

Inputs:
  0: VTXO output (user + server MuSig2)
  1: Connector output (from offboard tx)

Outputs:
  0: Server pubkey (vtxo_amount + connector_dust)
  1: Fee anchor (P2A)
Purpose: If user broadcasts the offboard tx but not the full round, server can claim the VTXO using this forfeit.

Multiple Input Forfeits

When multiple VTXOs are spent:
Offboard Tx

Connector Output

[Intermediate Connector Tx]  ← Deterministic fanout
  ↓ ↓ ↓
Individual Connectors
  ↓ ↓ ↓
[Forfeit Tx 1] [Forfeit Tx 2] [Forfeit Tx 3]
Each forfeit uses its own connector from the intermediate transaction.

Offboard Transaction Output Indices

lib/src/offboard.rs
pub const OFFBOARD_TX_OFFBOARD_VOUT: usize = 0;
pub const OFFBOARD_TX_CONNECTOR_VOUT: usize = 1;
Offboard transaction structure:
  • Output 0: User’s offboard destination (from OffboardRequest)
  • Output 1: Connector for forfeit transactions (330 sats × number of inputs)
  • Output 2+: Optional (change, other destinations)

Usage Examples

User: Creating an Offboard Request and Signing Forfeits

let user_key = Keypair::new(SECP256K1_CONTEXT, &mut rng);
let input_vtxos: Vec<Vtxo> = // ... VTXOs to spend

// 1. Create offboard request
let offboard_req = OffboardRequest {
    script_pubkey: destination_address.script_pubkey(),
    net_amount: Amount::from_sat(90_000),
    deduct_fees_from_gross_amount: true,
    fee_rate: FeeRate::from_sat_per_vb(10).unwrap(),
};

// Validate the request
offboard_req.validate()?;

// 2. Send request to server, receive offboard_tx and server_nonces

let offboard_tx: Transaction = // ... from server
let server_nonces: Vec<PublicNonce> = // ... from server

// 3. Create forfeit context
let forfeit_ctx = OffboardForfeitContext::new(
    &input_vtxos,
    &offboard_tx,
);

// 4. Validate the offboard transaction
forfeit_ctx.validate_offboard_tx(&offboard_req)?;

// 5. Sign forfeits
let user_keys = vec![&user_key];  // One key per input VTXO
let forfeit_sigs = forfeit_ctx.user_sign_forfeits(
    &user_keys,
    &server_nonces,
);

// 6. Send forfeit_sigs to server
// forfeit_sigs.public_nonces
// forfeit_sigs.partial_signatures

// Server will finalize forfeits and broadcast offboard tx

Server: Processing Offboard and Finalizing Forfeits

let server_key: Keypair = // ...
let connector_key: Keypair = // ... for spending connectors

let offboard_req: OffboardRequest = // ... from user
let input_vtxos: Vec<Vtxo> = // ... user's VTXOs

// 1. Construct offboard transaction
let offboard_tx = Transaction {
    version: bitcoin::transaction::Version(3),
    input: vec![
        // ... user's VTXOs as inputs
    ],
    output: vec![
        offboard_req.to_txout(),  // vout 0: user's destination
        TxOut {                    // vout 1: connector
            value: P2TR_DUST * input_vtxos.len() as u64,
            script_pubkey: connector_spk,
        },
        // ... optional change output
    ],
};

// 2. Generate server nonces
let (server_sec_nonces, server_pub_nonces): (Vec<_>, Vec<_>) = 
    (0..input_vtxos.len())
        .map(|_| musig::nonce_pair(&server_key))
        .unzip();

// 3. Send to user:
// - offboard_tx
// - server_pub_nonces

// ... receive user_forfeit_sigs from user ...

// 4. Create forfeit context
let forfeit_ctx = OffboardForfeitContext::new(
    &input_vtxos,
    &offboard_tx,
);

// 5. Validate (should match what user validated)
forfeit_ctx.validate_offboard_tx(&offboard_req)?;

// 6. Check and finalize forfeits
let forfeit_txs = forfeit_ctx.check_finalize_transactions(
    &server_key,
    &connector_key,
    &server_pub_nonces,
    server_sec_nonces,
    &user_forfeit_sigs.public_nonces,
    &user_forfeit_sigs.partial_signatures,
)?;

// 7. Store forfeit transactions and broadcast offboard
// Store forfeit_txs in database (safety net against partial exits)
// Broadcast offboard_tx to network

Handling Multiple Inputs

let vtxo1: Vtxo = // ... 50k sats
let vtxo2: Vtxo = // ... 30k sats  
let vtxo3: Vtxo = // ... 20k sats

let input_vtxos = vec![vtxo1, vtxo2, vtxo3];
let total_input = Amount::from_sat(100_000);

// Offboard 95k (5k for fees)
let offboard_req = OffboardRequest {
    script_pubkey: destination_spk,
    net_amount: Amount::from_sat(95_000),
    deduct_fees_from_gross_amount: true,
    fee_rate: FeeRate::from_sat_per_vb(5).unwrap(),
};

// Connector output needs 3 × 330 = 990 sats minimum
let connector_amount = P2TR_DUST * 3;

let offboard_tx = Transaction {
    // ... 
    output: vec![
        offboard_req.to_txout(),        // 95k sats
        TxOut {                          // 990 sats connector
            value: connector_amount,
            script_pubkey: connector_spk,
        },
        TxOut {                          // 4010 sats change
            value: total_input - offboard_req.net_amount - connector_amount,
            script_pubkey: change_spk,
        },
    ],
};

// Create intermediate connector tx for individual forfeits
// (this happens automatically in check_finalize_transactions)

// Each forfeit will spend from the intermediate connector tx
// forfeit_txs[0] spends intermediate connector output 0 + vtxo1
// forfeit_txs[1] spends intermediate connector output 1 + vtxo2  
// forfeit_txs[2] spends intermediate connector output 2 + vtxo3

Important Notes

Users must sign forfeits before the server broadcasts the offboard transaction. Without forfeits, users could perform partial exit attacks.
Forfeit transactions are a safety mechanism. They’re only broadcast if a user tries to cheat by exiting early from a round.
The connector output must have exactly 330 sats × number_of_inputs. This provides one dust output per forfeit transaction.
Always validate the offboard transaction with validate_offboard_tx() before signing forfeits. This ensures the transaction matches what you requested.
The check_finalize_transactions method consumes server secret nonces. They cannot be reused. Generate fresh nonces for each offboard attempt.

Build docs developers (and LLMs) love