Skip to main content

What are Rounds?

Rounds are the core mechanism by which captaind batches off-chain payments. Each round:
  1. Collects payment requests from multiple users
  2. Constructs a shared VTXO tree
  3. Creates an on-chain funding transaction
  4. Issues new VTXOs to participants
Rounds run at regular intervals (default: every 10 seconds), allowing users to submit payments asynchronously.

Round Lifecycle

States

Each round progresses through several states:

Phases

1. Attempt Initiation (< 1ms)

  • Generate one-time cosign key
  • Generate round attempt challenge
  • Broadcast RoundAttempt event to clients
RoundAttempt {
    round_seq: 123456,
    attempt_seq: 0,
    challenge: [random bytes],
}

2. Receiving Payments (default: 2 seconds)

Clients submit payment requests:
SubmitPaymentRequest {
    inputs: [VtxoIdInput],      // VTXOs to spend
    outputs: [SignedVtxoRequest], // New VTXOs to create
    unlock_preimage: bytes,      // For tree signing
}
Server validates:
  • ✅ VTXOs exist and are unspent
  • ✅ User owns input VTXOs (ownership proof)
  • ✅ Input amount ≥ output amount + fees
  • ✅ Output amounts ≥ dust limits
  • ✅ Correct number of nonces provided
Interactive vs Non-Interactive:
  • Interactive: User submits during this phase (requires signatures)
  • Non-Interactive (hArk): Pre-registered participations from database

3. Constructing VTXO Tree (< 100ms)

  • Aggregate all outputs into a VTXO tree structure
  • Add dummy VTXOs if needed (minimum 2 leaves)
  • Calculate aggregate nonces for each internal node
  • Build round funding transaction
  • Lock wallet UTXO for round input
Tree Structure:
        Root (funding UTXO)
           /     \
       Node1     Node2
      /    \    /    \
   VTXO1 VTXO2 VTXO3 VTXO4
Radix = 4 (configurable via nb_round_nonces).

4. Sending VTXO Proposal (< 10ms)

Broadcast VtxoProposal to clients:
VtxoProposal {
    round_seq: 123456,
    attempt_seq: 0,
    unsigned_round_tx: Transaction,
    vtxos_spec: VtxoTreeSpec,
    cosign_agg_nonces: Vec<AggregatedNonce>,
}
Clients verify the proposal and prepare signatures.

5. Receiving VTXO Signatures (default: 2 seconds)

Clients submit partial MuSig2 signatures:
SubmitVtxoSignaturesRequest {
    cosign_pubkey: bytes,
    signatures: [PartialSignature],
}
Server validates each signature:
  • ✅ Pubkey is a participant in this round
  • ✅ Correct number of signatures
  • ✅ Signatures verify against aggregate nonces
Retry Logic: If signatures don’t arrive in time:
  1. Ban non-signing users from this round
  2. Restart from phase 2 with remaining users
  3. Reuse the same round funding UTXO
  4. Increment attempt_seq
Maximum retries: Until all input VTXOs are banned.

6. Combining Signatures (< 50ms)

  • Aggregate all partial signatures
  • Verify combined signatures
  • Finalize VTXO tree with witnesses

7. Signing On-chain Transaction (< 100ms)

  • Sign round funding transaction with BDK wallet
  • Commit wallet state (marks UTXOs as spent)
  • Persist wallet changeset to database

8. Broadcasting (< 500ms)

  • Submit signed transaction to Bitcoin network
  • Broadcast RoundFinished event to clients:
RoundFinished {
    round_seq: 123456,
    attempt_seq: 0,
    cosign_sigs: Vec<Signature>,
    signed_round_tx: Transaction,
}

9. Persisting (< 500ms)

  • Store round record to database
  • Mark input VTXOs as spent
  • Insert new output VTXOs
  • Store virtual transactions
  • Update round participations
Database Transaction: All-or-nothing commit.

Round Results

  • Success: Round completed and broadcast
  • Empty: No payments received, round skipped
  • Abandoned: All retries exhausted, no signers left
  • Error: Fatal error occurred (database, wallet, etc.)

Configuration

Timing Parameters

# How often to start new rounds
round_interval = "10s"

# Time for users to submit payments
round_submit_time = "2s"

# Time for users to submit signatures
round_sign_time = "2s"
Tuning Tips:
  • Shorter round_interval = better UX, higher resource usage
  • Longer round_submit_time = more inclusive, slower rounds
  • Longer round_sign_time = tolerates network lag, slower rounds

Capacity Parameters

# Maximum VTXO tree size = 4^nb_round_nonces
nb_round_nonces = 8  # = 65,536 max VTXOs per round

# Maximum VTXO amount (optional)
# max_vtxo_amount = "1000000 sat"

Forfeit Parameters

# How long to store forfeit nonces
round_forfeit_nonces_timeout = "30s"

Monitoring Rounds

Admin RPC

Trigger a round manually:
captaind -C config.toml rpc trigger-round

Logs

Round lifecycle events:
{"msg":"RoundStarted","round_seq":123456}
{"msg":"ReceivedRoundPayments","round_seq":123456,"input_count":5,"output_count":8}
{"msg":"SendVtxoProposal","round_seq":123456,"attempt_seq":0}
{"msg":"ReceivedRoundVtxoSignatures","round_seq":123456,"attempt_seq":0}
{"msg":"BroadcastRoundFundingTx","round_seq":123456,"txid":"abc123..."}
{"msg":"RoundFinished","round_seq":123456,"attempt_seq":0}

Metrics

OpenTelemetry metrics:
  • bark_round_seq: Current round sequence number
  • bark_round_state: Current round state (enum)
  • bark_round_attempt: Current attempt within round
  • bark_round_input_volume: Total input sats
  • bark_round_input_count: Number of input VTXOs
  • bark_round_output_count: Number of output VTXOs
  • bark_round_step_duration: Time spent in each phase

Round Events Stream

Clients subscribe to the round event stream via gRPC:
rpc ListenRoundEvents(ListenRoundEventsRequest) 
    returns (stream RoundEvent);
Event Types:
  1. Attempt: Round started, provides challenge
  2. VtxoProposal: VTXO tree proposed
  3. Finished: Round completed with transaction
Reconnection Behavior: New subscribers receive the last event first, allowing mid-round joins:
if let Some(last_event) = server.rounds.last_event() {
    stream.send(last_event).await?;
}
while let Some(event) = events.recv().await {
    stream.send(event).await?;
}

Common Issues

Empty Rounds

Symptom: Rounds complete with no payments Causes:
  • No users connected
  • Users not submitting payments
  • Round interval too short for users to prepare
Solutions:
  • Increase round_interval
  • Check client connectivity
  • Verify users have spendable VTXOs

Abandoned Rounds

Symptom: RoundAbandoned after retries Causes:
  • All users failed to sign
  • Network issues preventing signature delivery
  • Client bugs
Solutions:
  • Increase round_sign_time
  • Check server network latency
  • Update client software

Slow Rounds

Symptom: Rounds taking > 10 seconds Causes:
  • Database slow (many VTXOs)
  • Large VTXO tree (many outputs)
  • Slow Bitcoin Core RPC
Solutions:
  • Optimize PostgreSQL (indexes, vacuum)
  • Reduce nb_round_nonces
  • Use faster hardware (SSD)
  • Reduce max_output_vtxos

Failed Broadcasts

Symptom: Round completes but tx not in mempool Causes:
  • Bitcoin Core connectivity issues
  • Transaction rejected (fees, conflicts)
  • Mempool full
Solutions:
  • Check Bitcoin Core logs
  • Verify round tx meets policy rules
  • Increase fee rates
  • Wait and let TX Nursery rebroadcast

Advanced Topics

Non-Interactive Participations (hArk)

Users can pre-register payments that don’t require signing: Database Storage:
SELECT * FROM round_participation 
WHERE round_id IS NULL;  -- Pending participations
Processing: After interactive phase, server includes all pending hArk participations. Use Cases:
  • Async payments when user offline
  • Server-initiated rebalancing
  • Automated liquidity management

Round Sequence Numbers

Round sequence (RoundSeq) is a monotonically increasing timestamp:
let epoch = UNIX_EPOCH + Duration::from_secs(1741015334);
let round_seq = SystemTime::now().duration_since(epoch).unwrap().as_secs();
Properties:
  • Globally unique (assuming server time is accurate)
  • Sortable
  • Human-readable (Unix timestamp offset)

Forfeit Nonces

For the deprecated forfeit mechanism (replaced by watchman): Storage:
forfeit_nonces: Mutex<TimedEntryMap<VtxoId, HarkForfeitNonces>>
Expiry: Automatically cleaned up after round_forfeit_nonces_timeout.

Wallet UTXO Locking

Prevents double-spending the same UTXO across rounds:
pub struct WalletUtxoGuard {
    utxo: OutPoint,
    wallet: Arc<Mutex<PersistedWallet>>,
}

impl Drop for WalletUtxoGuard {
    fn drop(&mut self) {
        // Unlock UTXO when guard is dropped
    }
}
Reuse Logic: If a round retries, the same UTXO guard is passed forward.

Best Practices

Set up alerts for:
  • High abandonment rate (> 10%)
  • Slow round durations (> 2x expected)
  • Empty round rate (> 50%)
  • High traffic: Increase nb_round_nonces, decrease round_interval
  • Low traffic: Increase round_interval to batch more payments
  • Variable traffic: Use default settings and monitor
  • Log banned VTXOs to identify problematic users
  • Consider manual intervention for repeated failures
  • Implement client-side retry logic
  • Regularly VACUUM and ANALYZE
  • Monitor table sizes (rounds grow indefinitely)
  • Consider archiving old rounds (future feature)

Build docs developers (and LLMs) love