Reference / Bitcoin SPV

Bitcoin SPV

Prove a Bitcoin payment happened — inside a Stacks contract, with no oracle. @secondlayer/stacks/bitcoin does the off-chain proof prep so the SIP-044 native built-ins can verify a Bitcoin transaction on-chain.

SIP-044 / Clarity 6

Off-chain today, on-chain at Epoch 4.0

The off-chain surface — proof construction, Clarity codecs, byte handling — works today against live Bitcoin data. The on-chain calls hit the SIP-044 native built-ins, which exist once Clarity 6 / Stacks Epoch 4.0 is active. Until then you run the full round-trip in Clarinet ≥ 3.21 simnet (no node).

SIP-044 ("Clarity 6") lets a contract natively verify that a Bitcoin transaction was mined — SPV (Simplified Payment Verification), the technique Bitcoin light clients use. A contract checks "BTC tx T paid Z sats to address Y in a confirmed block" without trusting an indexer or oracle.

The node owns the on-chain half. This module owns the off-chain half.

  • On-chain (the node): get-bitcoin-tx-output? parses one output of a serialized BTC tx; verify-merkle-proof proves a tx is committed in a block. They run only inside a contract and demand precisely-shaped data — the right merkle proof, internal byte order, witness stripped.
  • Off-chain (this module): construct that data — parse the tx, build the merkle proof, encode the exact Clarity args — and decode the results. You never run a node by hand or reverse a hash.

It trust-minimizes verification, not custody.

A Stacks contract can act on a proven Bitcoin fact — no oracle, no signer attestation.

  • BTC-settled escrow / OTC — release sBTC, an NFT, or a loan only once a real BTC payment is proven on-chain.
  • BTC-L1 collateral — prove a borrower's Bitcoin UTXO exists on L1 instead of trusting a price/existence oracle (Zest / Granite-style lending).
  • Atomic BTC ↔ sBTC / Runes swaps — native SPV is uncapped, so multi-output Runes/BTC txs that the old clarity-bitcoin caps blocked now verify.
  • Trust-minimized sBTC — deposits proven on-chain, not just asserted by the signer set (aligns with SIP-028).
  • Proof-of-payment receipts — any flow that needs "this BTC tx paid Y sats to Z" as a contract-checkable fact.
bun add @secondlayer/stacks

The Bitcoin module is a subpath import — it tree-shakes out if you don't use it.

import { buildTxProof, verifyBitcoinPayment } from "@secondlayer/stacks/bitcoin";

verifyBitcoinPayment composes the whole flow: build the proof from a source, decode the funded output, run the on-chain check, and assert your expectations.

import { createPublicClient, http } from "@secondlayer/stacks";
import { mainnet } from "@secondlayer/stacks/chains";
import {
  buildTxProof,
  bitcoinRpcSource,
  esploraSource,
  fallbackProofSource,
  verifyBitcoinPayment,
} from "@secondlayer/stacks/bitcoin";

const client = createPublicClient({ chain: mainnet, transport: http() });

// Trustless by default: your own node first, hosted fallback second.
const source = fallbackProofSource([
  bitcoinRpcSource({ url: "http://127.0.0.1:8332", auth: { username: "u", password: "p" } }),
  esploraSource({ url: "https://blockstream.info/api" }),
]);

// "release only when a real BTC payment to <addr> for <amount> is proven on-chain"
const result = await verifyBitcoinPayment(client, {
  txid: "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16",
  source,
  vout: 0,
  contract: "SP….spv-adapter", // the reference adapter (or your own verifier contract)
  expect: { address: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", amount: 5_000_000_000n },
});
// → { verified, mined, output, proof }

verified is true when the tx is mined and every expect field matches. By default the adapter authenticates the proof's header against the chain (was-tx-mined); pass proof directly instead of txid + source if you already built one.

Adapter not yet deployed

contract points at a deployed adapter that exposes the built-ins. The reference spv-adapter deploys to testnet/mainnet when Epoch 4.0 activates. Until then, run the on-chain side in Clarinet simnet, or point contract at your own verifier.

The proof sources are trustless by construction — buildTxProof re-verifies every claim a source makes (txids hash correctly, the index points at the tx, the proof folds to the header's merkle root). A wrong or hostile source fails loudly.

import { buildTxProof, esploraSource } from "@secondlayer/stacks/bitcoin";

const source = esploraSource({ url: "https://blockstream.info/api" });
const proof = await buildTxProof(source, {
  txid: "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16",
  vout: 0,
});
// proof: { rawTx, txidInternal, vout, merkle: { siblings, txIndex, txCount }, header, height }

Three sources, all interchangeable:

SourceUse
bitcoinRpcSource({ url, auth })Your own Bitcoin Core node (-txindex). Trustless, the default.
esploraSource({ url })Any Esplora REST endpoint — blockstream, mempool.space, self-hosted. Hosted fallback.
fallbackProofSource([primary, …])Chain them: try your node, fall back to hosted.

Lower-level building blocks are exported too: parseBitcoinTx, parseBlockHeader, stripWitness, doubleSha256, reverseBytes, buildMerkleProof, merkleRoot.

The codecs turn a proof into the exact built-in arguments — internal byte order, flat args, tx-count (not tree depth). Getting this wrong is the foot-gun the module exists to absorb.

import {
  encodeMerkleProofArgs,
  decodeTxOutput,
  parseOutputScript,
} from "@secondlayer/stacks/bitcoin";

// `(leaf, root, tx-index, tx-count, (list 24 (buff 32)))` — never reversed, never a tuple.
const args = encodeMerkleProofArgs({ leaf: proof.txidInternal, root, proof: proof.merkle });

// Decode `get-bitcoin-tx-output?`'s `{ script, amount, txid }` tuple, then read the script.
const out = decodeTxOutput(resultCV);          // { script, amount: bigint, txid }
const spk = parseOutputScript(out.script);     // { type: "p2pkh" | "p2wpkh" | "p2tr" | …, address?, data? }

The built-ins are callable only from inside a contract, never over RPC. So bitcoinVerifier binds a thin read-only wrapper that exposes them. Secondlayer ships a reference spv-adapter — no state, no admin, no custody:

FunctionWraps
get-tx-outputget-bitcoin-tx-output? — parse one output
verify-merkleverify-merkle-proof — membership under a supplied root
header-merkle-rootslice the merkle root out of an 80-byte header
was-tx-minedcomposed: authenticate the header against get-burn-block-info?, then prove inclusion — atomically

The whole contract — read-only, no state, no custody. Copy it into your own Clarinet project, or point at your own verifier with the same shape. (GitHub is the source of truth; requires clarity_version = 6 / epoch = "4.0".)

;; spv-adapter -- a thin, read-only reference wrapper over the SIP-044 (Clarity 6)
;; Bitcoin SPV built-ins. The built-ins are callable only from within a Clarity
;; contract, not over RPC; this contract exposes them as read-only functions so
;; the @secondlayer/stacks `bitcoinVerifier` (and any integrator) can reach them.
;;
;; Byte order: 32-byte hashes (txids, merkle roots, siblings) are INTERNAL order
;; (raw double-SHA-256), matching the built-ins. The only display-order value is
;; the block hash from `get-burn-block-info? header-hash`, reversed before compare.

(define-constant ERR-BAD-HEADER (err u1)) ;; header not canonical at that height
(define-constant ERR-BAD-SLICE (err u2))  ;; header merkle-root slice failed

;; Reverse a 32-byte buffer (internal <-> display order).
(define-private (prepend-byte (b (buff 1)) (acc (buff 32)))
  (unwrap-panic (as-max-len? (concat b acc) u32))
)
(define-read-only (reverse-buff32 (input (buff 32)))
  (fold prepend-byte input 0x)
)

;; The merkle root committed by an 80-byte header: bytes [36, 68), internal order.
(define-read-only (header-merkle-root (header (buff 80)))
  (match (slice? header u36 u68)
    sliced (as-max-len? sliced u32)
    none)
)

;; Parse one output of a serialized BTC tx.
(define-read-only (get-tx-output (tx (buff 4096)) (vout uint))
  (get-bitcoin-tx-output? tx vout)
)

;; Prove tx inclusion under a SUPPLIED root (membership only — not chain-authenticated).
(define-read-only (verify-merkle
    (leaf (buff 32)) (root (buff 32))
    (tx-index uint) (tx-count uint) (siblings (list 24 (buff 32))))
  (verify-merkle-proof leaf root tx-index tx-count siblings)
)

;; Full check: authenticate the caller's 80-byte header against the chain's record
;; at `height` (`get-burn-block-info? header-hash`), extract its root, and prove
;; inclusion — atomically.
;;   (ok true)  header canonical AND tx included (mined)
;;   (ok false) header canonical but tx not included
;;   (err u1)   header not the canonical block at `height`
;;   (err u2)   malformed header length
;; Note: an out-of-range `height` (before the chain launched, or newer than the
;; node's last-processed burn block — a very recent tx) also yields (err u1).
;; Flash blocks (a BTC block with no Stacks block) are covered — get-burn-block-info?
;; header-hash is indexed by burn height, not a gap.
(define-read-only (was-tx-mined
    (header (buff 80)) (height uint) (leaf (buff 32))
    (tx-index uint) (tx-count uint) (siblings (list 24 (buff 32))))
  (let (
        (root (unwrap! (header-merkle-root header) ERR-BAD-SLICE))
       )
    (if (is-eq (get-burn-block-info? header-hash height)
               (some (reverse-buff32 (sha256 (sha256 header)))))
        (ok (verify-merkle-proof leaf root tx-index tx-count siblings))
        ERR-BAD-HEADER)
  )
)

It's read-only — a verification query. To gate value on a proof (escrow, collateral, swaps), call the same built-ins from a define-public in your own contract so the check runs as a committed, consensus-enforced transaction.

Point bitcoinVerifier at the reference adapter or your own contract with the same shape:

import { bitcoinVerifier } from "@secondlayer/stacks/bitcoin";

const verifier = bitcoinVerifier(client, { contract: "SP….spv-adapter" });
const mined = await verifier.wasTxMined(proof);

The off-chain half above runs against live Bitcoin from a bare bun add @secondlayer/stacks — no chain needed. The built-ins themselves run inside a Clarity contract, so to exercise them set up a Clarinet project (≥ 3.21 — it boots simnet at Epoch 4.0, so the built-ins resolve and execute locally, no node).

Register a verifier contract at Clarity 6 / Epoch 4.0 in Clarinet.toml:

[contracts.spv-adapter]
path = "contracts/spv-adapter.clar"
clarity_version = 6
epoch = "4.0"

Use the spv-adapter shown above (or your own verifier) as contracts/spv-adapter.clar. Then drive it from TypeScript with @stacks/clarinet-sdk and this module's encoders — encodeMerkleProofArgs shapes the exact verify-merkle-proof arguments and decodeTxOutput reads the get-bitcoin-tx-output? tuple back. A worked end-to-end example lives in the secondlayer repo.

The exact Epoch 4.0 activation burn height is selected only after the SIP-044 vote, so there's no constant to hardcode. isClarity6Active reads the node's current burn height and compares — you supply the activation height once it's known.

import { isClarity6Active } from "@secondlayer/stacks/bitcoin";

const live = await isClarity6Active(client, { activationBurnHeight: 0 /* set post-vote */ });

SPV trust-minimizes verification, not custody

verify-merkle-proof proves a tx is committed under a block's merkle root; was-tx-mined additionally authenticates that the header is the canonical block at its height. Neither moves or guards funds — they prove a Bitcoin fact to a Stacks contract, which then decides what to do. was-tx-mined errors only when the supplied header isn't the canonical Bitcoin block the Stacks node recorded at that height — a wrong or reorged header, or a height out of range (before the chain launched, or newer than the node's last-processed burn block, so a very recent tx may need to wait). Flash blocks — Bitcoin blocks that produced no Stacks block — are not a gap: get-burn-block-info? header-hash is indexed by burn height, so it covers them.

See also Verification for trustless inclusion proofs on the Stacks side, and the SDK reference.