.md

Verify

Trust, and verify.

Where Secondlayer can prove an answer, it serves the proof: a hash, a merkle path, a signature, or a replay you run yourself. A check that passes leaves you trusting a smaller, named thing. A check that can't run says so, and is never a pass.

Every layer has a check. Each one leaves you trusting a smaller, named thing.

WebhooksA delivery came from usverifySecondlayerSignature(rawBody, headers, key)The signing key: proves who sent it, not that the chain agrees
SubgraphsServed rows equal a replaysecondlayer verify subgraph <name> --replayThe checkpoint; state subgraphs only
IndexA transaction is in its blockGET /v1/index/transactions/:tx_id/proofThe signer set in the proof, unless you pass your own
StreamsEach frame is signedthe SDK, on every frameThe signing key, unless you pin it
ArchiveYour history matches the archive; bootstrap and repair hash every partition firstsecondlayer verify raw --against <latest.json>The archive key, and the range's assurance level
Any blockA block, its transactions, its state writes and its Bitcoin anchorsecondlayer verify block <height>The checkpoint shipped in the release

One command walks every link from the checkpoint to the block's state. It works at any height, including below the checkpoint.

Try it in your browser, no install:

Runs on this page against the public proofs API. No key, no account.

The same check as secondlayer verify block.

~/secondlayerexit 0
$secondlayer verify block 9137005
✓ block 9,137,005 2e5962b00b8f243e1a4b5a1b7ff3076618ce5e737e30c019bc61e461c92f57ff
✓ bitcoin headers 967,680..970,269, proof-of-work and retargets checked
✓ burn height 970,269 00000000000000000001abf5e92c4e771c041ff3a9fde450c2291775641a801c
✓ cycle 144, signer set proven forward from checkpoint cycle 143
✓ signatures 2,866 / 4,000 weight signed, threshold 2,800
✓ txs 2 transactions match the tx merkle root
✓ state root 8103be4b31c6549fe58dd453d9ea357286c874ac0f4f81f4326791e8191f0cd5 matches the witness
✓ diff 18 written (unnamed), 1 carried, 5 internal
· rows not checked (pass --rows)
✓ Block 9,137,005 is proven. Trusted: only the checkpoint (Stacks 8,956,304, Bitcoin 967,680).

verifyTransactionProof recomputes the txid, merkle root, block hash, and signer weight from one proof.

$curl "$SECONDLAYER_API_URL/v1/index/transactions/0x<tx_id>/proof" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY"

Proof levels

A 200 returns the raw transaction, raw header, merkle path, and (only when the reward set could be resolved) consensus.

LevelChecks
anchoredtxid to merkle path to the header's tx_merkle_root, plus header self-consistency. Any Stacks node can corroborate it.
consensusAlso recovers the header's signer signatures and confirms at least 70% of the reward cycle's signer weight signed.
type TransactionProofVerifyResult = {
level: "anchored" | "consensus";
txidMatches: boolean;
includedInHeader: boolean;
headerSelfConsistent: boolean;
signerWeightBps?: number;
thresholdMet?: boolean;
rewardSetSource?: "provided" | "embedded";
^"embedded" trusts Secondlayer for the signer set.
ok: boolean;
errors: string[];
};

With no reward set, consensus uses the one embedded in the proof, which trusts Secondlayer for the signer set alone. fetchRewardSet resolves it from your own stacks-node (/v3/stacker_set/{cycle}) instead:

import { verifyTransactionProof, fetchRewardSet } from "@secondlayer/sdk";
const rewardSet = await fetchRewardSet({
nodeUrl: "https://your-stacks-node:20443",
cycle: proof.consensus.reward_cycle,
});
if (!rewardSet) throw new Error("your node has no reward set for that cycle");
const trustless = verifyTransactionProof(proof, { rewardSet });
// trustless.rewardSetSource === "provided"
Status / codeMeaning
404 PROOF_UNAVAILABLEThe transaction, or the block containing it, was not found.
503 PROOF_TX_SET_INCOMPLETEThe server couldn't reproduce the block's tx_merkle_root from its stored transactions, so it refuses to emit a proof that wouldn't verify.
503 PROOF_NODE_UNAVAILABLEThe signed-header source (a stacks-node) was unreachable. Retryable.

The transaction verifier uses Node's crypto (via @secondlayer/shared), so it is server-side only.

Bitcoin transactions

verifyBitcoinTx follows a transaction from its bytes to its txid, merkle root and block header, then checks that header against the checkpoint's Bitcoin chain, with no contract call.

import { BlockVerifier, SecondlayerProofSource } from "@secondlayer/verify";
const sweepTxid = "f80a188d012dbb733748db003b987cc98fc4a636cbd071734decb298c1bad80b";
const verifier = new BlockVerifier({ source: new SecondlayerProofSource() });
const result = await verifier.verifyBitcoinTx(sweepTxid, {
expect: { address: "bc1q9fkjx3s9ufz90k4jzgmdkzlyt22h0tp3x92ua9", amount: 398_000n },
});
// result.ok, result.height, result.matched (the output index)

To read the proof from your own Bitcoin node, spread a source over it. The node needs txindex=1, and the verifier re-checks every byte, so an Esplora endpoint works the same way.

import { SecondlayerProofSource } from "@secondlayer/verify";
import { bitcoinRpcSource, buildTxProof } from "@secondlayer/stacks/bitcoin";
const bitcoin = bitcoinRpcSource({
url: "http://localhost:8332",
auth: { username: "stacks", password: process.env.BITCOIN_RPC_PASSWORD ?? "" },
});
const source = {
...new SecondlayerProofSource(),
getBitcoinTxProof: (txid: string) => buildTxProof(bitcoin, { txid }),
};

Pair it with a state proof of sBTC completed-withdrawal-sweep to follow a withdrawal from Stacks state to its Bitcoin output.

A block below the checkpoint skips the Bitcoin and signer links. The checkpoint's header commits to every ancestor by hash, so an ancestry link ties the block to it, by parent links or one MARF proof.

  • The skipped links read not needed: the block is hash-chained to the checkpoint.
  • txs reads not checked: no transaction bytes for an epoch 2.x block or when the source served the header only.
ExitMeaning
0Every link holds
1A link is broken; the first is named
2Could not check

The tradeoff: a lying source can fail a link but never pass one, and the price is work. The first check syncs Bitcoin headers from the checkpoint, and each block fetches a state witness.

FlagEffect
--node <url>Read blocks and MARF proofs from your Stacks node instead of the API
--checkpoint <file>Trust your own checkpoint JSON instead of the one built into the release
--rowsAlso check the indexed vm_events rows against the diff. Billed as Index rows; everything else is free
--jsonFull result on stdout

@secondlayer/verify is the same check as a library.

$bun add @secondlayer/verify

verifyBlock never throws for bad source data: it returns ok: false and the first broken link.

verify-block.tsts
import { SecondlayerProofSource, verifyBlock } from "@secondlayer/verify";
// No key needed; add apiKey for higher rate limits.
const source = new SecondlayerProofSource({
baseUrl: process.env.SECONDLAYER_API_URL!,
});
const result = await verifyBlock(9_137_005, { source });
^Never throws on bad source data.
if (!result.ok) throw new Error(result.failures[0]?.message);
^The first broken link.
console.log(result.blockId, result.stateRoot, result.diff?.writes.length);

With your own node, blocks and MARF proofs come from its RPC. Witnesses, burn preimages and Bitcoin headers still come from the API, checked the same way. Use BlockVerifier to verify many blocks from one checkpoint and reuse the synced headers and signer sets.

import {
BlockVerifier,
NodeRpcProofSource,
SecondlayerProofSource,
} from "@secondlayer/verify";
const api = new SecondlayerProofSource({ baseUrl, apiKey });
const source = { ...api, ...new NodeRpcProofSource({ nodeUrl: "http://localhost:20443" }) };
const verifier = new BlockVerifier({ source });
const result = await verifier.verify(9_137_005);

Pass rows: true to also check the indexed vm_events rows (billed as Index rows).

  • result.transactions holds the block's checked transactions; result.writes its named writes.
  • The library uses fetch and pure-JS crypto (@noble), with no Node built-ins.

The manifest states a level per range. Most of the chain is db-reconstructive.

A published node.json upgrades only its coverage window.

LevelMeans
db-reconstructiveExports from one repeatable-read snapshot restore deterministically. Does not prove no historical callback was omitted.
node-attestedBlock identities also match an independently synced stacks-node. Transactions and events stay db-reconstructive.
observer-attestedDigests match independently retained raw observer history.

The HTTP proofs behind every check, and what can't be proven yet.

Proofs API

The raw material verify block reads. Free, never metered, no key needed.

On the hosted API the limits are per IP, and an account key doubles them:

RoutePer IPWith an account key
Any proofs route10/s20/s
Witnesses1/s2/s

Answers addressed by a hash never change: they carry an ETag and Cache-Control: immutable.

On your own box the witness, burn and Bitcoin-header routes need a proof sidecar: Self-host.

RouteReturns
/v1/proofs/block/height/{height}Signed block by height
/v1/proofs/block/{index_block_hash}Signed block by id
/v1/proofs/witness/{index_block_hash}State witness the header's state root must hash to
/v1/proofs/writes/{height}Every written leaf of the block, named
/v1/proofs/marf/{path}MARF inclusion proof for a key at a block
/v1/proofs/burn/{consensus_hash}Burn block a consensus hash commits to
/v1/proofs/bitcoin-headersRaw Bitcoin headers from a height
/v1/proofs/bitcoin-tx/{txid}Bitcoin transaction, merkle branch and header
/v1/proofs/epoch2-header/{index_block_hash}Epoch 2.x header by id

The transaction proof above is /v1/index/transactions/{tx_id}/proof.

What it does not cover yet

GapEffect
Print events and transaction resultsStacks headers don't commit to them, so no block check covers them.
Which transaction made a writeThe transaction root binds each transaction's bytes, not which one wrote a leaf or whether it succeeded.
events and none subgraphsOnly state subgraphs can be replayed. Rows anyone can recompute.
The checkpointTrusted as-is. Pass your own to change that.
Bitcoin reorgsHeaders are checked for valid work, not against a competing chain, and reorgs past the synced tip are not followed.
__MARF_* keys from a node aloneA node serves no proof for them, so with NodeRpcProofSource alone, blocks more than 16 below a trusted block fail ancestry as unavailable.
Epoch 2.x transactionsSkipped with a note.
Unnamed writesWithout state_writes for the block, writes are proven, not named.
Edit on GitHub