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") 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-proofproves 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-bitcoincaps 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/stacksThe 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:
| Source | Use |
|---|---|
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:
| Function | Wraps |
|---|---|
get-tx-output | get-bitcoin-tx-output? — parse one output |
verify-merkle | verify-merkle-proof — membership under a supplied root |
header-merkle-root | slice the merkle root out of an 80-byte header |
was-tx-mined | composed: 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.