Chain / sBTC settlement

sBTC settlement

Accepted isn't received. A withdrawal-accept event only means the signers committed to a Bitcoin sweep; your instance verifies that sweep_txid against your bitcoind (the bundled full-node profile or an external BITCOIN_RPC) and surfaces the result on the decoded sBTC feed.

Deposits need no such check: completed-deposit fires only after the signers see the BTC confirmations.

Every peg-out lifecycle carries a settlement object. GET /v1/index/sbtc/withdrawals/:request_id:

{
  "request_id": 42,
  "status": "ACCEPTED",
  "settlement": {
    "sweep_txid": "abcd…",
    "btc_confirmations": 7,
    "settlement_confirmed": true,
    "btc_block_height": 880321,
    "confirmed_at": "2026-06-01T00:00:00.000Z"
  }
}

settlement_confirmed is true past the confirmation threshold, false while the sweep is still shallow, null when there's no settlement record yet (no sweep observed, not a denial).

GET /v1/index/sbtc/withdrawals carries settlement_confirmed per row and as a filter:

# only peg-outs whose BTC sweep is confirmed
curl "http://127.0.0.1:3800/v1/index/sbtc/withdrawals?settlement_confirmed=true"

# the complete "not done" bucket — not yet confirmed, or no sweep yet
curl "http://127.0.0.1:3800/v1/index/sbtc/withdrawals?settlement_confirmed=false"

sbtc.events.consume() is the checkpointed sweep over the raw peg log: all six topics, or one:

await sl.index.sbtc.events.consume({
  topic: "withdrawal-accept", // server-side; omit for every topic
  fromCursor: await db.loadCheckpoint(),
  fromHeight: 0,
  onBatch: async (events, _envelope, ctx) => {
    for (const e of events) await db.upsertPegEvent(e); // typed columns, no payload parsing
    await db.saveCheckpoint(ctx.cursor);
    return ctx.cursor;
  },
  onReorg: async (r) => db.deleteFromHeight(r.fork_point_height),
});

sbtc.deposits.consume() is the same loop over completed deposits alone.

Withdrawals have no consume

A withdrawal row is a lifecycle aggregate keyed by request_id, and it mutates as the peg-out moves REQUESTED → ACCEPTED → settled. A checkpoint only moves forward, so a consumer would commit a row as REQUESTED and never see it change. Mirror the append-only events above and derive status from the withdrawal-* topics; read withdrawals.get(id) when you want the assembled lifecycle.

Subscribe to sbtc_withdrawal_swept_confirmed and skip the polling.

import { trigger } from "@secondlayer/sdk";

await client.subscriptions.create({
  url: "https://your-app.com/webhooks/sbtc-settlement",
  triggers: [trigger.sbtcWithdrawalSweptConfirmed()],
});
  • Scope with requestId / sweepTxid.
  • Fires once per sweep, when btc_confirmations first crosses the threshold (default 6).
  • Forward-only: settlements confirmed after you subscribe, never a backfill.
  • A Bitcoin reorg that un-confirms a sweep never re-fires it; the read self-corrects.

Delivery is the standard chain.{type}.apply envelope; event carries the settlement specifics:

{
  "action": "apply",
  "trigger": "sbtc_withdrawal_swept_confirmed",
  "event": {
    "topic": "withdrawal-swept-confirmed",
    "request_id": 42,
    "sweep_txid": "abcd…",
    "btc_confirmations": 6,
    "btc_block_height": 880321,
    "confirmed_at": "2026-06-01T00:00:00.000Z",
    "amount": "100000",
    "sender": "SP…"
  }
}

See Subscriptions for delivery formats, signature verification, and the full trigger list.

What this proves, and what it doesn't

Settlement is verified against your instance's bitcoind, not a custody guarantee: a signer set still controls the pegged UTXO.