Reference / Changelog

Changelog

Notable changes to Secondlayer and its SDKs. Entries dated before August 2026 describe the hosted service as it worked then: per-row read metering, API keys, 402 past the free window. That service is withdrawn; on a self-hosted instance reads are yours and unmetered, and only archive bootstrap/backfill/reindex draws credits.

Running balances survive a reorg

You keep event rows append-only and fold running balances in onRollback on the sink. Same transaction as the rewind: doomed rows are still there, a throw aborts the delete. Remaining facts are height < fork.

Same getContract client on Clarinet simnet

Point createPublicClient at a Clarinet session with simnet() from @secondlayer/stacks/simnet and keep using getContract the way you do on mainnet. The root SDK still does not load clarinet-sdk; that entry is the only one that talks to the VM.

testing() is gone; simnet is a client

testing() no longer generates Clarinet helpers. Import @secondlayer/stacks/simnet, hand it your session, and call getContract. clarinet() now depends on @stacks/clarinet-sdk.

Setup that lands in the right database, and a bootstrap that finishes

secondlayer setup writes DATABASE_URL into .env and hands it, with the archive key and instance token, to the bootstrap and verify it runs, so a fresh box restores into the Postgres it just started instead of whatever your shell pointed at. bootstrap resumes a torn import per dataset (blocks, transactions, events each keep their own mark), --from-block restores verify only what they loaded, and repair --apply rewrites a fixed block together with its transactions and events. secondlayer start is gone; setup brings the stack up.

Verify offline with a key that ships in the release

verify, repair, and bootstrap resolve the archive signing key the same way: --public-key, then ARCHIVE_SIGNING_PUBLIC_KEY, then the key compiled into the CLI. A self-hosted instance never leaves the machine for a key, http:// key endpoints are never consulted, and latest.json pointers must carry a digest and a valid signature. The tradeoff: rotating the built-in key takes a CLI release until the key ceremony lands.

One confirmation gate, and credits that log in as you

Every destructive or metered prompt defaults to no, -y skips it, and without a TTY the command exits 1 naming -y instead of letting a pipe answer. --json never stands in for -y: bootstrap and repair print the quote as CONFIRMATION_REQUIRED and exit 2 so a script can read the price first. Archive credits send your secondlayer login --credits session, never the instance token, so a logged-in operator can quote and fetch. Dumps stay under --to and land via .part, the Postgres password rides in PGPASSWORD, scaffolded .env files are owner-only, and archive fetches retry transient failures with a re-run hint instead of a refusal.

Consumers never skip rows on a reorg

consume() no longer rewinds forward: a fork above your checkpoint is noted, not rolled back, so the rows between the checkpoint and the fork are delivered instead of silently dropped. Fork points are validated before your sink runs, rewinds deeper than maxRollbackDepth (1000 blocks) throw, and the Streams loop polls reorgs.list on empty pages so a fork that lands while you idle at the tip is rolled back too. Cursor.atHeight(0) is now null, and onReorg can hand you a null cursor at genesis.

Subgraph subscribe() works on a token-gated instance

Typed subscribe() is a fetch-based SSE reader that carries your bearer token, reads /v1 keyless on loopback, and reconnects from the last delivered block height. Every path segment you supply is percent-encoded. typed() knows your declared columns, so a table with its own id column filters on your id, not the system row id. new SecondLayer({ verify, verifyDumpsManifest, origin }) reaches sl.streams.

walk() finishes, Streams errors keep their envelope, VersionConflictError is gone

walk() ends only when the server stops advancing next_cursor, retries page fetches like consume(), and rejects batchSize above 1000 up front. Index and REST requests run under requestTimeoutMs (30 s) and accept signal. Every failure status keeps the server's { error, code }, Streams and Index alike; the signing key is cached only after a successful fetch, and a key_id rotation mid-stream refreshes it once. streams.events.subscribe redelivers an event whose handler threw, backs off with jitter, reopens a stale socket, and stops (through onError and a new done promise) on a 401 or bad signature. Breaking: VersionConflictError is removed (nothing threw it; the real 409 is ApiError with code: "OPERATION_IN_PROGRESS"), and context() fields are { value, error? } so a null says why.

/tools is deprecated; use @secondlayer/mcp

@secondlayer/stacks/tools and /tools/btc still import until the next major but get no new tools. ai and zod are optional peer dependencies now, so a project that only reads contracts installs neither. While they last, readContract and getTransaction return Clarity values through cvToJSON instead of crashing on a bigint, and every model-supplied principal, txid, and hash is validated and percent-encoded before a request is built.

Transport timeouts cover the body, nonces come back on a failed send

One timeout covers headers and body, a stalled response rejects with TimeoutError, RequestOptions.signal cancels from your side, and broadcasts go out once. NonceStore.release hands a nonce back when a send fails before the node accepts it, so FeeTooLow no longer strands every later transaction. multicall caps reads in flight. Breaking: indexSource/indexTxSource go through the client transport, default baseUrl to the transport URL (no hosted default), and send Authorization: Bearer.

Tuples hash like the chain, principals and units fail loudly

Tuple fields serialize in the node's byte order so SIP-018 hashes match to-consensus-buff?. The deserializer caps nesting and checks declared counts against the bytes left. Cl.principal and parseContractId reject undecodable addresses, parseUnits refuses exponent-form numbers and silent rounding, and a legacy Bitcoin address with a bad base58check checksum is refused before it becomes a PoX reward address. apiKey no longer appears on transport.config.

Optional /extended JSON on :3999

You can keep /extended clients pointed at your instance. Same Postgres, off by default (EXTENDED_VIEW=1), second bind on :3999. /v1 is unchanged. status, block, tx, per-tx events, address txs, nft transfers, and decoded STX/FT/NFT holdings are live. BNS is empty until BNS_DECODER_ENABLED=true. No nonce; that stays on the node.

PoX-5 lock, reclaim, and eligibility

You can fund an L1 lockup, prove it, spend it, and check a stake before you pay the fee. buildRegisterMetadata produces the P2WSH address and lock script. buildPox5LockProof maps a SIP-044 proof onto registerForBond. reclaim() spends the same script (locktime or early-exit). eligibleStake / eligibleRegisterForBond / eligibleClaimRewards return { ok: true } or a set of Pox5ErrorCode reasons.

Signer calldata is a SIP-005 pox-addr tuple (buildSignerCalldata). pauseRewards is one-way. Import reclaim from @secondlayer/stacks/pox5; it is not on the Stacks wallet client.

Staking post-conditions, SIP-018, and Clarity 4 deploys

You can write the Epoch 3.4 / 4.0 post-conditions the chain actually checks: Pc.ustxToLock(), willPerformPox(), NFT maybe-sent, and postConditionMode: "originator". Hex PCs round-trip with Pc.fromHex. Contract deploys default to versioned Clarity 4, not an unversioned Clarity 1 payload.

SIP-018 structured-data hashing matches the spec (hashStructuredData / signStructuredData). signMessage({ domain }) previously omitted the inner hashes, so those signatures would not verify on-chain. The pox-5 grant helper was already correct.

pox-5 adds Pox5ErrorCode (parse (err u7) to a name) and getPoxInfo(), which reads sbtcContract from /v2/pox so you do not hardcode the token principal.

getDataVar({ contract, variableName }) reads a private data-var off the node (GET /v2/data_var) without a full callReadOnly.

One command replaces the five-command setup

secondlayer setup replaces the old five-command, one-manual-copy-paste onboarding path. Secrets get generated, docker-compose.yml and .env get written straight into your target directory, the stack comes up, and verified history gets restored and checked, all from one command.

Interactive by default: a real terminal UI, built on @opentui/react. Without a TTY, or with --yes, it skips the prompts and runs the same steps from flags instead, --network, --node-mode, and --against (unless --skip-bootstrap) are required explicitly in that mode, so an agent drives it exactly as well as a human at a terminal.

scripts/oss-bootstrap.ts is gone. It minted an older account and API-key credential that no longer matches how self-hosted instances authenticate.

Experimental observer-http block source

A subgraph processor can page raw /new_block bodies from the indexer's internal observer journal instead of Postgres or Streams+Index. Set SUBGRAPH_SOURCE=observer-http and OBSERVER_HTTP_URL at the processor. Default is still Postgres. The route is opt-in on the indexer (OBSERVER_HTTP_EXPORT=1) and is not a public /v1 API.

Publish, unpublish, and the visibility flag are gone

Publishing claimed a name in a hosted global namespace. A self-hosted instance has no such namespace, so the verb had nothing left to mean. There is no shim, the routes 404 and the calls are gone.

  • CLI: subgraphs publish, subgraphs unpublish, and subgraphs deploy --visibility are unregistered. Deploy's success footer always prints the /api/subgraphs read path.
  • SDK: subgraphs.publish() / subgraphs.unpublish() and the SubgraphPublishResult / SubgraphUnpublishResult types are removed. deploy() no longer accepts or returns visibility.
  • MCP: subgraphs_publish and subgraphs_unpublish are no longer registered, and subgraphs_list no longer reports visibility or a publicUrl.
  • HTTP: POST /api/subgraphs/:name/publish and .../unpublish are deleted and 404 in every mode. The PUBLIC_NAME_TAKEN error code retires with them.

Reads are unchanged: rows still come from /api/subgraphs/<name>/<table> on your instance, and the open /v1/subgraphs directory still serves what it served. Generated subgraph specs now default their server URL to http://127.0.0.1:3800 instead of the hosted API, so a spec produced on a self-hosted instance describes that instance.

Deprecated CLI aliases are gone

No shim, old spellings error instead of quietly working. One door per capability now:

  • subgraphs cancelsubgraphs stop
  • streams pullstreams dumps
  • subgraphs codegencodegen subgraph (or codegen prints for --payloads)
  • subgraphs clientcodegen client
  • index codegencodegen index
  • contracts generate / contracts gencodegen contracts (the contracts group held nothing else, so it's gone too)

codegen subgraph now defaults --target to kysely, matching every other codegen surface. The retired alias defaulted to prisma, pass --target prisma if you relied on that.

Deprecated SDK method names are gone

Each call has one name now: subscriptions.recentDeliveries() is subscriptions.deliveries(), subscriptions.requeueDead() is subscriptions.requeue(), subgraphs.get() is subgraphs.status(). subscriptions.get(), contracts.get(), and the standalone getSubgraph() export are untouched.

INSTANCE_TOKEN is read as your primary credential

Precedence is now an explicit apiKey option, then INSTANCE_TOKEN, then SL_API_KEY, matching what the docs have always said. Before this, exporting INSTANCE_TOKEN authenticated as nobody with no error, the quietest way a documented happy path can fail.

SL_API_KEY still works, nothing breaks, but following the docs now works too. Setting both to different values warns once on stderr and INSTANCE_TOKEN wins; secondlayer init writes identical values, so it never warns.

MCP

Deprecated tool aliases are gone and subgraphs_get is renamed, every tool name is now <product>_<verb> with one spelling: scaffold_from_contractsubgraphs_scaffold, get_contract_abicontracts_get_abi (last cycle's alias is retired for real now), subgraphs_getsubgraphs_status (matching sl.subgraphs.status() and secondlayer subgraphs status). Update any pinned tool name, the server no longer registers the old one.

INSTANCE_TOKEN is also read as your primary credential here, see the SDK entry above, the precedence change applies wherever the MCP server resolves a token.

Account tools (account_whoami, account_create_key) register only when the server is pointed at https://api.secondlayer.tools. A self-hosted instance does not show them. Docs and client examples lead with INSTANCE_TOKEN; SL_API_KEY remains a legacy alias.

POST /api/keys from an API key always mints an account key. Requesting streams or index on that path is a 400. Dashboard sessions can still mint scoped keys. Existing scoped rows still authenticate.

Hosted subgraphs and subscriptions are retired

/api/subgraphs and /api/subscriptions now 404 wherever the archive used to run them. Deploying handler code and delivering webhooks was never something a shared box should have been doing on your behalf. Self-host and nothing changes: it's still your subgraph, your subscription, your infrastructure end to end.

Pay-per-call is gone

The x402 rail is removed. Reads on your instance were never metered by us, and the rail shipped a hardcoded price list you couldn't override, the opposite of "you are the merchant." Removed from the SDK: withX402, createX402Client, payAndRetry, buildSignedX402Payment, readX402Challenge, readX402Receipt, selectOffer, resolveAccountNonce, X402SpendGuardError, and the @secondlayer/sdk/x402 subpath. Also gone: @secondlayer/stacks/x402, @secondlayer/shared/x402, the /v1/x402/* routes, and wallet-owned deploys with their 7-day expiry. Archive credits are unchanged and remain the only thing that costs money.

Archive fetches now quote before they charge

Point secondlayer bootstrap or secondlayer repair at the official archive and you see the price before anything is charged: partitions, dollars, and your balance, in the same confirm prompt you already use. Fetches pull signed partitions over short-lived presigned URLs and every partition is digest-checked before a row is written. Your first six repair bundles each month are free. Your own mirrors and local archives never touch the gate, and secondlayer verify stays free with no account, always. The tradeoff is plain: restoring from our archive costs credits; everything you run yourself costs nothing.

Uninstall without losing your index

secondlayer uninstall stops the stack and leaves your data where it is. Containers, networks, and the handler cache come down; the index, chainstate, secrets, and backups stay. It prints the plan and changes nothing until you pass --apply.

secondlayer uninstall            # dry run: what would go, what would stay
secondlayer uninstall --apply

Wiping the volumes is a separate, guarded decision: --purge refuses to run unless --backup <dir> points at a bundle proving your keys exist somewhere else.

Backup and restore exit when they finish

Both commands close the database pool, so they return to your shell instead of hanging after the work is done. Scripts and CI jobs that waited on them no longer need a timeout.

Back up and restore your instance

secondlayer backup writes an encrypted bundle; secondlayer restore reads it back. Both carry a secrets-key canary, so restoring with the wrong key fails immediately instead of leaving you with subscription secrets nobody can decrypt.

Forward-only installs stop looking broken

An instance now records where its history starts. If you never bootstrapped genesis, the missing prefix reads as deliberate rather than as a gap in every check. Set SECONDLAYER_SYNC_START_HEIGHT to declare it.

One starter, generated from your contract

secondlayer subgraphs create no longer ships five opinionated templates behind --template. It emits a single starter, and the path worth using is --from-contract <id>, which infers sources, schema, and handlers from the contract's observed print events.

Default client hits your local API

new SecondLayer() and createStreamsClient() now talk to http://127.0.0.1:3800. Override with baseUrl or SL_API_URL. Archive dumps stay on the public signed bucket.

One account model: yours

Plans, tiers, projects, and API-key tiers are gone from every surface. Deploys are open on any instance (no trial, no quota, no visibility flag), and the only thing that costs money is archive data: bootstrapping, backfilling, or reading history beyond the recent window, drawn from prepaid archive credits. Removed with them: client.apiKeys, client.projects, index.usage(), subgraphs.publish(), and BYO databaseUrl deploys (typed ORM codegen for drizzle/prisma/kysely stays). Your schema, your Postgres, your instance: the SDK now assumes nothing else.

A console for your instance

Your instance gets a web console (fleet health, subgraph detail, table browsing, webhook delivery logs) as one more container in your compose. docker compose --profile console up -d starts it; secondlayer console opens it. Loopback is open; anything past it takes your instance token, and serving it on your own domain is a reverse-proxy block away. See Run Secondlayer.

Buy archive credits from the CLI

secondlayer credits buy --email you@example.com --pack 25 opens a one-time card checkout. secondlayer credits balance shows the prepaid amount. secondlayer credits refill --below 5 --pack 25 turns on auto-refill; default is off.

One container next to Postgres

Self-host is postgres + secondlayer. secondlayer start --print writes the compose line. NETWORK, DATABASE_URL, NODE_MODE, DATA_DIR, API_PORT, and INDEXER_PORT are the required non-secrets. Optional --profile stacks-node or full-node bundles chain daemons.

secondlayer init --network mainnet
docker compose -f docker/oss/docker-compose.yml up -d
secondlayer verify all --against <manifest>

Point verify at raw, a decoder, or a subgraph

secondlayer verify takes all, raw, decode:<name>, or subgraph:<name>. Default is raw. --quick hashes identity columns. --deep also recomputes semantic digests. --anchor requires a verified archive signature.

secondlayer verify decode:ft_transfer --against ./snapshot.json --deep

A target with no matching archive dataset exits 2: not being able to check is not a pass.

Run the runtime without an account

secondlayer init writes the keys for a machine you operate. secondlayer bootstrap restores verified history. secondlayer observer prints the stanza your Stacks node posts to.

secondlayer init --network mainnet
secondlayer bootstrap --against <manifest>
secondlayer observer

No login. No project. secondlayer instance is gone; those three verbs are top-level.

Mint mock sBTC on your devnet

secondlayer devnet faucet gives you sBTC to test against, locally:

secondlayer devnet faucet --to ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM

No Bitcoin, no regtest, no waiting on someone else's faucet. The sBTC contracts you deployed make you the signer set on your own chain, so minting is just a contract call. Refuses any network but devnet, since elsewhere the signer set isn't yours.

Check your chain data against a signed archive

secondlayer verify tells you whether your history is intact. It compares your database against a signed canonical archive and names the exact height ranges that diverge: missing blocks, broken parent links, duplicate heights, forks left on the losing branch.

secondlayer verify --against https://archive.secondlayer.tools/.../latest.json

It is read-only and nothing leaves your machine: only digests are compared. The full mainnet chain, 8.7M blocks, checks in well under a minute.

When something is wrong, secondlayer repair fixes it. It shows you a plan first, names every height it would change, and refuses any archive object that does not match its signed digest.

secondlayer repair --against <archive>            # plan
secondlayer repair --against <archive> --apply    # fix

And secondlayer bootstrap stands up a fresh instance from the archive instead of replaying the chain from genesis, and it shows you both timings before it starts, then restores and verifies itself.

All three exit with stable codes for CI: 0 clean, 1 divergence found, 2 unable to verify. That last one never reports as success: not being able to check is different from being fine.

secondlayer verify --semantic recomputes each partition's sha256:semantic-v1 digest locally and compares it to the manifest. The default pass hashes identity columns; --semantic also catches divergence in raw_tx, event data, and function_args.

A published attestations/<digest>/node.json is an independent stacks-node audit of that snapshot. coverage is the attested window; stats is exact; mismatches[] is a capped sample. Transactions and events stay db-reconstructive: the node does not expose them.

Mirror the PoX-5 feed in your own database

secondlayer index codegen refused pox5_events with Unknown Index table: the table was missing from the Index read contract, so the one stacking feed that is still growing after the epoch 4.0 activation was the one feed a BYO mirror couldn't hold. (The closed pox-4 era was covered the whole time.)

All three targets now emit it:

secondlayer index codegen --target kysely --tables pox5_events

You get Pox5Events for Kysely, pox5Events for Drizzle, and a model Pox5Events keyed on cursor for Prisma, 21 columns including the decoded data payload.

Filter FT transfers by asset from the MCP tool

index_ft_transfers takes assetIdentifier, so an agent can ask for one token's transfers directly:

index_ft_transfers({ assetIdentifier: "SP…sbtc-token::sbtc-token" })

The filter was already on the API, the SDK, and the index_nft_transfers twin; only the FT tool's schema was missing it, which left agents scanning the full feed and paying per row to find one asset.

sbtc.deposits.consume stops pretending to accept fields

index.sbtc.deposits.consume({ fields: [...] }) type-checked and then ignored you: the consume loop built its page request field by field and never forwarded the projection, so you got full rows back either way. The option is gone, so passing it is a compile error instead of a silent no-op. For a projected read, use sbtc.deposits.list or .walk, which have always applied it.

The same gap was fixed for index.events.consume earlier; every Index consume now forwards its filters from one place, so a filter can't be dropped between what you asked for and what the loop requests.

Contract names with underscores work everywhere

my_contract is a legal Clarity contract name, but the principal validation rejected it, so passing SP….my_contract as a principal argument threw, both in @secondlayer/stacks and in the contract interfaces the CLI generates. Those contracts are now usable.

The same change tightens validation in the other direction. A principal carrying more than one dot segment (SP….token.extra) used to slip through: generated code truncated it to SP….token and called a different contract than you asked for, and the ABI guards passed it along. It throws now. Filter builders also check the contract name against the Clarity grammar, so a typo'd id fails where you write it instead of quietly matching zero rows.

parsePrincipal and CONTRACT_NAME_REGEX are exported from @secondlayer/stacks/utils if you want the same check.

Generated files keep the old behavior until you re-run secondlayer generate.

subgraphs.reindex() takes just a name

reindex(name) no longer accepts fromBlock or toBlock. The API stopped supporting a ranged reindex: it answers 400 REINDEX_RANGE_NOT_SUPPORTED, so the old signature type-checked and then failed at runtime.

Reindex is always the whole subgraph: dropped and rebuilt from your startBlock to chain tip. For a specific range, use subgraphs.backfill(), which never drops anything.

The subgraphs_reindex MCP tool drops the same two parameters.

Reindex no longer takes a block range

secondlayer subgraphs reindex drops --from-block and --to-block, and the API rejects either with 400 REINDEX_RANGE_NOT_SUPPORTED.

A reindex drops your subgraph's tables and rebuilds them. The range only ever controlled the rebuild, never the drop, so a narrow range didn't reindex those blocks, it kept them and threw away the rest. The subgraph then looked fine: status back to active, cursor at chain tip, no gaps recorded, nothing logged. Only counting rows against another source showed the history was gone.

Reindex is now always the whole subgraph: every row dropped, rebuilt from your startBlock to chain tip. The confirmation prompt says so plainly. For a specific block range, use secondlayer subgraphs backfill --from-block <n> --to-block <n>. It never drops anything, and it already refuses handlers that would double-count on a replay.

Watch the mempool for one function, not a whole contract

/v1/index/mempool takes function_name. Watching a privileged function on a busy contract used to mean pulling every pending call to it and filtering client-side; now you ask for the one you care about:

curl http://127.0.0.1:3800/v1/index/mempool \
  -G -d "contract_id=SP….my-dao" -d "function_name=execute-proposal"

It composes with sender, type, and the cursor like every other filter. secondlayer index mempool --function-name <name> and functionName on the SDK's mempool.list/walk pass it through. A (contract_id, function_name) index backs it, which also puts an index behind plain contract_id filtering for the first time.

Sorted reads stay fast on deep pages

Indexed columns now get a (column, _id) index alongside the single-column one, matching how /v1 orders sorted reads. On a low-cardinality column, a deep page went from ~17.5 ms and 8,557 buffer reads to ~0.09 ms and 14: Postgres walks the index in final order instead of re-sorting each tie group.

New tables get it on deploy. Existing tables pick it up on their next reindex or schema change, not on a plain redeploy.

Deploy refuses a subgraph source that isn't committed

secondlayer subgraphs deploy now checks whether your definition file is committed to git before it deploys. If it's untracked or has uncommitted changes, you get a prompt in a terminal and a hard failure in scripts and CI, because a deployed definition whose source isn't in version control exists only as a database row, with nothing to review, diff, or restore from.

Pass --allow-uncommitted to deploy anyway; it prints a line saying the check was skipped, so the choice is visible in CI logs. Deploys from outside a git repo, and --dry-run, are unaffected.

Block processing is now replay-proof across restarts

Your subgraph's accumulator tables are guarded against a class of drift that processor restarts could cause: if two catch-up walkers ever overlap on the same block (a restart mid-walk, a leadership handoff), only one commit stands. The block's writes and its checkpoint advance travel in one transaction with a conditional cursor, so a duplicate application aborts cleanly and a stale walker can no longer rewind progress and trigger re-walks. Reorg rewinds still work exactly as before; they take a dedicated path that serializes across processes.

None of this changes your handler code. ctx.increment and friends behave identically; the guarantee is in the runtime underneath them.

Staking post-conditions come through on PoX-5 transactions

/v1/index/transactions and /v1/index/mempool now return the SIP-045 staking and pox post-conditions that arrived with epoch 4.0. Before this, a stake call's post-conditions came back as an empty array, the same answer you'd get for a transaction that genuinely carried none.

Both types sit on post_conditions alongside stx/ft/nft: staking carries a principal, an amount (a string, like every other amount), and the fungible condition code; pox carries a principal and one of will_not_perform, may_perform, or will_perform. IndexPostCondition in the SDK gains both members. Post-conditions are decoded from raw_tx at read time, so every staking transaction already indexed reads correctly now, with no reindex and no migration.

The writing section gets a front page and a proper post frame

/writing now leads with the newest post and its signature figure rendered live as the feature art (the figures are the thumbnails); older posts drop into a two-column ledger beneath. Posts gain a right-hand meta rail on wide screens: number, date, reading time, and a figure index that jumps straight to each figure, and every post now ends with a previous/next pair and the feed, so the writing hands you the next piece instead of just stopping. Margin sidenotes moved to the left margin to make room.

Writings: long-form posts with an interactive figure library

The site grows a /writing section for long-form, mechanism-first guides that don't fit the docs register. The first post, Why your indexer resumes from the same block, walks how consume loops, sinks, and checkpoints work: what the three heights on the health endpoint mean, what a poll costs, and why the cursor parks on a quiet feed.

Posts are built from a 34-component figure library: annotated payloads, position tracks, sequence diagrams, hand-built SVG charts with collision-managed labels, and one interactive explorer per post, all on the site's tokens in both themes, with reduced-motion and keyboard support throughout. The whole vocabulary is browsable at /writing/figures. There's an RSS feed too.

blocks_behind now means backlog, not event age

Your consumer on a quiet contract used to report tens of thousands of blocks "behind" while fully caught up: the number measured how old the last matching event was, and it only grew. Now ctx.blocksBehind (and consumerHealth's blocks_behind) is tip - scannedHeight: the highest block the sweep has verified, where an empty page counts as the server confirming nothing matches up to the tip. A caught-up tail reads ~0. The new ctx.scannedHeight carries the verified position; ctx.height still marks the last delivered row. Capped server scans only claim the cursor they returned, finalizedOnly stops at the finalized boundary, and reorgs roll the verified position back below the fork.

Bring any database: sinks are now an open surface

Three sinks ship: kyselySink, drizzleSink (Postgres and SQLite, with your schema typing ctx.tx), and bunSqliteSink (one file, zero dependencies, no docker), and the machinery behind them is yours to build on. createSink(driver) from @secondlayer/sdk/sinks/core owns the transaction sequences that make a sink correct; your driver is seven methods of dialect. The contract's 13 invariants are documented on the interface and mechanically probed by a conformance kit at @secondlayer/sdk/sinks/testing, covering torn batches, cursor replay, inclusive >= rollback, and lock contention. It is the same kit every shipped sink passes in CI. Write your own sink.

Append-only stores (ClickHouse, parquet) declare capabilities: { finalizedOnly: true }: the consume loop refuses to follow the unfinalized tip at startup, loudly, instead of corrupting at the first fork. The Postgres advisory lock also moves to an int8 hash, so two unrelated sink ids can no longer collide into a false "another consumer" 409.

The type spelling you meant now works on both feeds

/v1/streams/events?event_type=print and /v1/index/events?types=print both work now: each surface accepts the other's spelling for a single type. Ask the Index for a type set and it refuses with the reason: Index pagination is keyed per event type, by design (a design we wrote down this week). The CLI takes both flags on both commands.

Streams consumers also close two gaps: the top-level consume() iterator takes the labelled filters map, replay() can narrow its live tail, and a literal types: ["ft_transfer"] now narrows row types at compile time the way labels always did. And subgraph factory sources (a router plus pools created after you deploy) can now be authored through the on.* filter vocabulary; every non-subgraph projection refuses the field loudly.

Filter renames stop pretending to be capability gaps

on.stxLock({ lockedAddress }).toIndexParams() works now: the Index stores the locked address as the row's sender, so the field is a rename, not something the surface can't do. Same for caller on contract-call reads: the endpoint filters by tx sender, which is the caller. And trait with contractId fails at projection time with the fix in the message, instead of as a server 400 after the request already left.

Two silent-wrong-results bugs are also gone: streams.events.stream() now forwards the labelled filters map (it used to silently deliver the full firehose, billed per row), and a wildcard inside a contractId array is refused at projection like scalar wildcards always were, instead of reaching the wire as a literal match that returns zero rows.

One day of dogfooding, four fixes

We ran the whole surface from a fresh repo with one API key, the way you would. What broke got fixed the same day:

  • on.ftTransfer({ assetIdentifier }) projected a filter the Index refused: the server accepted asset_identifier for nft event reads but not ft. It now works on all six token event types, and ftTransfers.list takes assetIdentifier directly.
  • pox5/events and sbtc/deposits 500'd, and transactions returned a corrupt cursor, when fields omitted the index column, because pagination machinery was reading projected rows. Cursors and reorg spans now always come from the raw rows.
  • /v1/subgraphs/<name>/<table>?_fields=… without _id returned a full page with next_cursor: null, silently dropping the rest of your rows. _id now drives the cursor server-side whether or not you asked for it.

Field projection reaches the transfer feeds

const { events } = await sl.index.ftTransfers.list({
  contractId,
  fields: ["amount", "sender", "recipient"],
});
events[0].tx_id; // ✗ not requested, not fetched, not in the type

ft-transfers and nft-transfers, almost certainly the highest-traffic Index reads, now take fields, with the same contract as everywhere else: requested columns come back, a misspelled one is refused, and the row type narrows to match. cursor/block_height always survive, plus event_type because it is the row's discriminant. Omitting block_time also lets the server skip the blocks join entirely.

We previously wrote here that these rows were "5-8 columns" and not worth projecting. That was a measurement error: we counted only the type-specific columns and missed the 12-column base they extend. A transfer row is 17 columns wide, wider than the withdrawals we did project. Corrected.

blocks alone stays unprojected, now for a checked reason: it is 8 columns read off a single table: block_time is a column of the row itself, so unlike the event feeds there is no join for a projection to skip.

Field projection across the Index surface

const { withdrawals } = await sl.sbtc.withdrawals.list({
  fields: ["amount", "status"],
});
withdrawals[0].sender; // ✗ not requested, not fetched, not in the type

Deposits, withdrawals, transactions, and pox-5 events all take fields now, each narrowing its row type to what you asked for. Which columns always survive differs per resource, on purpose: a withdrawal is keyed by request_id and has no block_height, a transaction keeps tx_id, and a pox-5 event keeps topic because it is the row's discriminant. Dropping those would leave a row you cannot paginate or identify.

blocks, ft_transfers, and nft_transfers did not take fields in this release. The "5-8 columns" rationale originally published here undercounted the transfer rows; see the correction above. Both transfer feeds have since gained fields.

Field projection reaches sBTC deposits

const { deposits } = await sl.sbtc.deposits.list({
  confirmed: true,
  fields: ["bitcoin_txid", "output_index", "amount", "tx_id", "block_height"],
});
deposits[0].sender; // ✗ not requested, not fetched, not in the type

Projection shipped on /v1/index/events and stayed there, so every sibling resource returned full rows whether or not you wanted them. The parse-and-strip logic is now shared, which keeps the two rules that matter identical wherever it is applied: a misspelled field is refused rather than quietly ignored, and cursor/block_height always survive so pagination and reorg reporting cannot be projected away.

A reorg is confirmed before it overwrites anything

A block arriving for a height we already hold no longer replaces it on sight. The node's event observer emits competing blocks at the same height routinely, so a hash mismatch says "someone built a different block here", not "the chain moved", and only the second justifies orphaning indexed data. Guessing picked the losing fork twice in one week.

The contender is now staged and the next block decides: a block names exactly one parent, so whichever side it names is the one the chain kept. Cost is one block of latency on a genuine reorg; the alternative was sitting on the wrong chain until someone noticed.

Integrity asks whether the chain joins up

findBrokenLinks reports canonical heights whose block does not descend from the one below it, surfaced as a chain_unlinked status. The previous check only asked whether every height was present, which a chain on a losing fork answers yes to.

Orphaned blocks the canonical chain still links to are now reclaimed automatically, using local data only: if the canonical block at h + 1 names an orphaned block at h as its parent, that block is on-chain by definition.

Status reports progress, not just liveness

/public/status reported subgraph_processor: ok through a seventeen-hour stall: the loop was ticking and heartbeating while making no progress. It now also checks how far the furthest-behind subgraph trails the tip.

Decoded rows and their checkpoint commit together

Every decoder wrote its rows and then its checkpoint as two separate statements; a crash in between re-delivered the batch on resume, survivable only because the writes happened to be idempotent upserts. They now share one transaction. For BNS this also pulls its bns_names projections into the same commit, which previously could lag the events they derive from.

on is the filter union (breaking)

import { on } from "@secondlayer/stacks";

on.ftTransfer({ assetIdentifier: `${SBTC}::sbtc-token` }).toIndexParams({ limit: 50 });

The name used to hold a second, older set of factories keyed on snake_case database column names and typed Record<string, …>, so a typo compiled and silently matched nothing. They had no consumers, and they are gone, along with the Filter / FilterClause / FilterOperator / FilterPrimitive / SubscriptionFilterSpec / BnsAction / FactoryTarget / PoxFunction types.

on at the package root is now the same typed union as @secondlayer/stacks/filters: per-event-type fields, and explicit projections onto every surface. Using on.transferTo(...), on.bnsName(...), or on.poxStack(...)? Build the filter with on.ftTransfer({ … }) and project it onto the surface you're calling.

Field projection narrows everywhere it applies

events.walk({ fields }) now narrows its row type the way events.list already did; it was forwarding fields to the wire, so it yielded stripped rows while the type promised every column. The callable index.events({ fields }) shorthand narrows too. events.consume no longer accepts fields at all: the consume loop never forwarded it, so it type-checked and was then dropped.

The docs, as text an agent can read

  • /llms-full.txt: every docs page in one markdown file, sidebar-ordered, each passage tagged with the page it came from.
  • Any page as markdown: append .md to a docs URL (/docs/streams.md). Generated from the MDX itself, so it can't drift.
  • ?mode=agent now works. It was advertised in llms.txt but only ever set from localStorage, so the URL did nothing. Agent mode also stopped replacing the page, so you get the prompt deck and the reference content.
  • AGENTS.md ships inside @secondlayer/sdk with the five facts you can't infer from the types: reads need no key, cursors are opaque, reorg rollback is >= not >, rows and cursor commit in one transaction, and walk() is not reorg-safe.

MCP

codegen_index_schema emits an ORM schema for the Index tables in-conversation, from the same generator as secondlayer codegen index. get_contract_abi becomes contracts_get_abi (the unprefixed name filed itself under a phantom "get" product in the capability listing); the old name keeps working until the next major.

Read contract state from a handler

import { readContractAt } from "@secondlayer/subgraphs";

const token = readContractAt(ctx, contractId, SIP010, {
  cache: "contract-constant",
});
const decimals = await token.read.getDecimals({}); // bigint, typed from the ABI

Some facts aren't in any event (a token's decimals, a pool's reserves), so amount stayed a bare integer nobody could render. Now a handler can ask the contract, with the ABI's camelCased read methods and no call (a handler indexes the chain, it never writes to it).

  • Pinned to the block being processed. The call carries that block's index_block_hash, so a handler is a pure function of its block and a reindex produces byte-identical rows. A block whose id was never persisted throws rather than silently reading at the node's tip.
  • Cached in Postgres, keyed on the block id, so a reorg-replaced block can't inherit the orphaned fork's answer. Pass cache: "contract-constant" for values that genuinely can't change and it is fetched once, ever; that is what makes a full backfill affordable.
  • Unit tests stay offline: createTestContext(schema, { reads }) stubs reads by "<contract>.<function-name>".
  • Self-hosting needs STACKS_NODE_RPC_URL on the subgraph processor: the node you already run, not a new service.

The sip-010-balances starter now labels tokens with their real symbol and decimals.

Track two things in one loop

await sl.streams.events.consume({
  filters: {
    peg: { types: ["ft_transfer"], assetIdentifier: `${SBTC}::sbtc-token` },
    treasury: { types: ["stx_transfer"], sender: TREASURY },
  },
  on: {
    peg: (events) => credit(events),      // narrowed to ft_transfer
    treasury: (events) => debit(events),  // narrowed to stx_transfer
  },
});

A flat filter is one AND-ed set, so two unrelated concerns meant two consume loops, two cursors, and two checkpoints, or fetching the union and discarding half of it. Name each concern and the server ORs them into a single scan.

  • Each label's declared types narrows its handler, so event.payload is typed without an event_type guard, and adding a label to filters is a compile error until on handles it.
  • One cursor covers every label. The position is computed over the block's full event set, so you can add or drop labels between restarts and keep the checkpoint.
  • Over REST it is one JSON query param, and each event carries the labels it matched. SSE takes the same param, so subscribe narrows the same way.

Ask for the columns you want

const { events } = await sl.index.events.list({
  eventType: "ft_transfer",
  fields: ["recipient", "amount"],
});
events[0].asset_identifier; // ✗ not requested, not fetched, not in the type

The server projects the SELECT, so an unrequested column is physically absent, and now the row type says so, making a read of one a compile error instead of undefined at runtime.

  • Omitting block_time lets the server skip the blocks join entirely. It is not a stored column but a to_timestamp() off a join taken on every read, so this is a planner-level saving on wide sweeps.
  • cursor, block_height, and event_type always come back, because pagination and the union discriminant don't depend on what you asked for. Reorg reporting is unaffected too.
  • It does not change your bill: Index meters per row, not per field.

Run a handler before you deploy

secondlayer subgraphs test subgraphs/bns-names.ts --from 167484 --to 167600
secondlayer subgraphs test subgraphs/bns-names.ts --offline

Real chain events, your local handler code, no deploy. The first run records a cassette so later runs are offline and free; changing a source filter discards it rather than passing against data your subgraph would no longer request. If events arrive and your handlers write nothing, it fails, and that is the shape of the field-mapping bug that ships a 0-row subgraph.

For unit tests, @secondlayer/subgraphs/testing gives you the same context the runtime uses, backed by memory, so read-your-writes, increment deltas, and upsert merging behave exactly as they do in production.

Index a contract set, including one that grows

contractId takes an array, so a router plus its pools is one source and one handler. For pools created after you deploy, a factory discovers addresses from another source's events:

swaps: {
  type: "print_event",
  topic: "swap",
  factory: { from: "registry", field: "data.pool" },
}

A pool discovered in block N receives its own block-N events, and the discovered set rolls back on a reorg like any other chain-derived state.

Hoistable schemas, and balances that don't lose updates

defineSchema() lets you pull the schema out of the call and keep helpers fully typed. The sip-010-balances starter no longer reaches for ctx: any; it moves balances with ctx.increment, which applies a delta atomically instead of a read-modify-write that silently loses same-block updates.

Source filters are now validated as a real discriminated union, so a field that a source type doesn't support fails at deploy instead of matching nothing forever.

prints could only describe flat fields, so a nested payload had to be declared "jsonb". That is how a handler reading flat data.name type-checked, deployed, and decoded to null on every event while BNS-V2 emitted name as a nested tuple: zero rows chain-wide, while the subgraph tailed happily at the tip.

prints: {
  "name-register": {
    name: { tuple: { name: "text", namespace: "text" } },
    owner: "principal",
    memo: { type: "text", optional: true },
  },
}
// event.data.name.namespace → string
// event.data.memo           → string | undefined
  • Declaring prints also turns on runtime validation: an event that does not match is skipped and logged instead of written as nulls. It never throws, since a block that can't advance would wedge your checkpoint.
  • Deploy now refuses (rather than warns) when a handler on a prints-declaring source reads a field no observed event carries.
  • The abi on a source is validated at deploy against the real Clarity ABI shape. A raw Hiro or Clarinet ABI used to pass and then mis-decode event.input forever; the error now names the fix.

One verb for generating code

secondlayer codegen contracts | subgraph | index | client | prints replaces six entry points under three verbs. --target means the same thing with the same default everywhere; previously secondlayer subgraphs codegen -o db.ts wrote Prisma while the identical-looking secondlayer index codegen -o db.ts wrote Kysely. Old paths still work, print a deprecation notice, and keep their original defaults so your output doesn't change.

  • Scaffolds now emit the contract's as const ABI and reference it, so handlers read event.input.tokenId instead of event.args[0] as bigint.
  • Generated row types are derived from the schema instead of a second hand-written map that answered uint with number, which didn't compile against the client it shipped beside, and truncated uint128.

Sinks: your indexer is one file now

kyselySink owns the three hard parts of a durable indexer (checkpoint persistence, rows+cursor atomicity, and reorg rollback), so your handler is just the writes:

const sink = kyselySink(db, { id: "sales", tables: ["sales"], height: "block_height" });

await new Index().contractCalls.consume({
  contractId: MARKETPLACE, functionName: "purchase-asset", fromHeight: 0,
  sink,
  signal: shutdownSignal(),
  onBatch: (calls, _env, ctx) =>
    ctx.tx.insertInto("sales").values(calls.map(toSale)).execute(),
});
// no onReorg, no checkpoint table, no SIGTERM block
  • Rows and cursor commit in one transaction; a crash mid-batch aborts both and the batch is simply re-read. Reorg rollback is automatic and unconditional, so forgetting onReorg can no longer skip reorgs silently.
  • ctx.tx is a fully typed Transaction<DB>; a table missing the height column fails loudly at startup, not silently at reorg time.
  • consumerHealth() answers your platform's liveness probe (page recency, never lag, since a genesis backfill is millions behind and healthy) and shutdownSignal() finishes the in-flight batch on SIGTERM.
  • On Streams, decoded: true delivers events as the same flat rows Index serves; the eleven guard+decode pairs are deprecated.
  • The flagship sales-index example dropped from 167 lines across three files to one 61-line file, and CI keeps it that way.

Write the filter once: on.*

One chain event used to be spelled differently on every surface: eventType on Index, types arrays on Streams, a sources entry on subgraphs, a trigger object on subscriptions, with three incompatible minAmount types among them. The new @secondlayer/stacks/filters module is the single spelling, projected explicitly to each surface:

import { on } from "@secondlayer/stacks/filters";

const usdc = on.ftTransfer({ assetIdentifier: USDC, minAmount: 1_000_000n });

sl.index.events.list(usdc.toIndexParams({ limit: 100 }));
sl.streams.events.consume({ ...usdc.toStreamsParams(), onBatch });
sl.subscriptions.create({ name, url, triggers: [usdc.toChainTrigger()] });
defineSubgraph({ sources: { usdc: usdc.toSubgraphSource() } });
  • A surface a filter can't reach is a missing method: a compile error, not a runtime surprise. A field a surface can't express throws at projection time with a message naming the surface that supports it.
  • Amounts are bigint end to end and stringify at exactly one boundary, so a spread bigint can never crash JSON.stringify again.
  • Typos fail at construction: a contract id where an asset identifier belongs, or a malformed principal, is an immediate error instead of a silent zero-row query.

Add an index without losing your data

Flipping indexed: true (or search) on a column of a populated subgraph now builds the index in place with CREATE INDEX CONCURRENTLY, so it no longer counts as a breaking change, so it no longer costs a DROP SCHEMA and a full reindex. The most routine operation in an indexer's life is now a few seconds of DDL instead of hours of backfill.

  • uniqueKeys changes are no longer invisible to deploys: additions run ALTER TABLE … ADD CONSTRAINT in place, and removals are correctly refused as breaking. Previously a uniqueKeys addition reported success, created nothing, and every later upsert failed against a constraint that didn't exist.
  • Handler-only redeploys now warn that new logic applies from the current tip: rows already indexed keep the previous handler's output unless you reindex.
  • The CLI confirms destructive deploys before sending the request. The old prompt fired after the server had already dropped your data.

findMany({ fields }) returns what it says

Requesting specific fields now narrows the row type to exactly those columns. The server always projected the SELECT, so unrequested fields were physically absent while TypeScript promised they existed, so reading one is now a compile error instead of a runtime undefined.

A 429 no longer kills your backfill

Both consume loops now retry the page fetch on rate limits, server errors, and network failures, honoring the server's Retry-After, so one transient error hours into a genesis sweep no longer costs you the process. Retry wraps only the fetch: your onBatch/onReorg code and real 4xx mistakes still fail fast.

  • Tune with retryCount / retryDelay (the same knobs as @secondlayer/stacks transports); watch with the onError observer.
  • One error family: RateLimitError, AuthError, and ValidationError are now ApiError subclasses thrown by every surface, so a single instanceof check works around Index, platform, and Streams calls alike.
  • Errors carry retryable, retryAfterSeconds, a docsUrl pointing at the fix, and walk() for cause-chain inspection.

finalizedOnly consumers can no longer skip events

Returning envelope.next_cursor from a finalizedOnly consumer's onBatch now throws instead of silently committing past events that were never delivered. The filtered unfinalized tail is re-read once it settles; a cursor above it would have dropped those events forever, with reorg handling skipped in that mode. The loop now catches the one wrong return it can detect.

  • fetchImpl is honored by every platform client, so you can inject a fetch for tests, proxies, or an x402-wrapped payer without monkey-patching the global.
  • x402 payments and proof verification no longer touch Buffer, so both work on edge runtimes like Cloudflare Workers.

Read PoX-5 events at /v1/index/pox5/events

The decoded pox-5 log is now queryable. One row per print event across all 19 topics, with the full decoded tuple in data, so nested shapes like btc-lockup, bond-rewards, and bond-periods come through intact rather than flattened away.

  • Filter by topic, staker, signer, signer_manager, bond_index, or reward_cycle, and page with the same <block_height>:<event_index> cursor the rest of Index uses.
  • ?confirmed=true clamps the window to the finality boundary, so settlement consumers only see rows past the reorg margin.
  • Every response carries tip and a reorgs[] array for the page's height range, so you can reconcile anything you already committed from a fork.

PoX-5 events, decoded from activation block one

The indexer now ships a PoX-5 decoder alongside the existing PoX-4 dataset. Every print event the new pox-5 boot contract emits, all 19 topics, from stake and register-for-bond through claim-rewards and signer grants, lands in a queryable pox5_events dataset with typed columns for stakers, signers, bonds, amounts, and cycle windows, plus the full event tuple as JSON.

  • Nothing to configure: the decoder is on by default and idles until the Epoch 4.0 fork activates at Bitcoin block 960,230. Your first pox-5 event is decoded the moment it exists.
  • Self-hosting? Raw events are always stored, so upgrading after the fork backfills the dataset with zero data loss, and no re-sync from your node.
  • @secondlayer/stacks now exports POX5_EVENT_TOPICS, Pox5EventTopic, and POX5_CONTRACT_ID_MAINNET for building your own event tooling against the same verified topic set.

Mirror the sBTC peg with a checkpointed consumer

index.sbtc.events.consume() and index.sbtc.deposits.consume() bring the decoded peg feed the same cursor commit, reorg rewind, and progress context every other Index loop has. Server-side topic filtering survives pagination, so following one topic no longer means pulling all six.

  • Building a durable sBTC index used to mean consuming raw print events off the registry contract and matching topics yourself, losing the typed columns.
  • withdrawals has no consume on purpose: the row is a lifecycle aggregate that mutates as a peg-out settles, and a forward-only cursor would commit it once and never see it change. Consume the append-only events instead; see sBTC settlement.

Follow a whole protocol on one cursor

contractId now takes an array on index.events and index.contractCalls. sBTC is four contracts; a v3v4 migration is two. One consumer, one checkpoint, one answer to "how far is my index complete".

  • Up to 20 ids, still mutually exclusive with trait; reach for trait when you mean "every contract of a standard", since it picks up contracts deployed after you ship.

Rows match the event type you asked for

index.events.list, .walk, and .consume are generic over the eventType literal, so consume({ eventType: "ft_transfer" }) hands onBatch an IndexFtTransfer[] with amount and sender reachable directly.

  • The if (e.event_type !== "ft_transfer") continue line every handler opened with, written only to satisfy the compiler, is gone.
  • A non-literal eventType still yields the union, so dynamic callers are untouched.

Every batch tells you how far along it is

onBatch now hands you height, tipHeight, and blocksBehind alongside cursor, on both the Index and Streams consumers. Answering "is my indexer still moving" no longer means tracking it yourself.

  • height survives empty pages, so a consumer caught up at the tip keeps reporting its position instead of dropping to nothing.
  • After a reorg it rolls back with the chain, to the last block still canonical, the case a hand-rolled at(-1) gets wrong.
  • Wire it straight into a health check: see Deploy for why your process needs one and what to gate it on.

Prove a Bitcoin payment without naming a contract

verifyBitcoinPayment resolves the reference spv-adapter on mainnet, so contract is now optional. Build a proof, pass it, get verified, with no adapter principal to look up, and no verifier contract of your own to deploy first.

  • The adapter is a read-only wrapper over the SIP-044 built-ins, so it goes live with the Epoch 4.0 fork at Bitcoin block 960,230. Reads before the fork still need Clarinet simnet.
  • Stacks testnet has no Epoch 4.0, so the built-ins don't exist there. Pass an explicit contract on testnet, and the thrown error now says exactly that instead of telling you to wait for an activation that isn't coming.
  • SPV_ADAPTER_CONTRACTS and getSpvAdapter(network) expose the principal if you'd rather pin it yourself.

One constant for the Epoch 4.0 activation height

EPOCH_4_ACTIVATION_BURN_HEIGHT_MAINNET is Bitcoin block 960,230. Epoch 4.0 carries both SIP-044 (the Bitcoin SPV built-ins) and SIP-045 (pox-5 Bitcoin Staking) share one fork and one height, so the bitcoin and pox5 modules read it from the same place and can't drift apart.

  • Only mainnet has a fixed height. Elsewhere, read it from the node with getPox5Activation or pass it explicitly.

Clients pick up SL_API_KEY from your environment

Export SL_API_KEY and new Index() uses it, the same variable the CLI and MCP server already read. No more constructing a client keyless by accident, then hitting 402 PAYMENT_REQUIRED the first time you reach past the free 24-hour window.

  • An explicit apiKey still wins. Pass apiKey: "" to force keyless reads on a machine that has a key exported.
  • Exported as resolveApiKey if you want the same precedence in your own code, and guarded for browsers and edge runtimes where process is undefined.

Name any decoded event, not just the union

IndexEvent is a union of eleven shapes, and until now you could name the union but none of its members. Every variant is exported from the package root: IndexFtTransfer, IndexNftTransfer, IndexStxTransfer, IndexStxMint, IndexStxBurn, IndexStxLock, IndexFtMint, IndexFtBurn, IndexNftMint, IndexNftBurn, and IndexPrint.

  • Write the handler signature you actually want, function onTransfer(e: IndexFtTransfer), instead of taking IndexEvent and re-narrowing inside every function.
  • The consumeIndexFeed types come with it: IndexFeedItem, IndexFeedEnvelope, and IndexFeedFetcher are all importable from @secondlayer/sdk, so you can type a custom fetcher or hold a feed item in a variable without reaching into a subpath.
  • Purely additive: nothing was renamed or removed.

PoX-5 events from sl.index.pox5

The decoded pox-5 feed has a typed client. sl.index.pox5.events.list() for a page, .walk() to stream every event across pages without writing cursor logic yourself.

  • Filters map to the endpoint one-to-one: topic, staker, signer, signerManager, bondIndex, rewardCycle, plus confirmed to stay behind the finality boundary.
  • amount_ustx and amount_sats stay strings, so stacking amounts past Number.MAX_SAFE_INTEGER survive the round trip intact.
  • Types are importable from the package root: IndexPox5Event, IndexPox5EventTopic, Pox5EventsEnvelope, and the list/walk param shapes.

Chain webhooks decode and narrow from one import

decodeChainWebhook is now reachable from the SDK root. Verify a delivery's signature, hand the same raw body to the decoder, and switch on data.trigger with your event fully narrowed, with no hand-rolled parser and no guessing which fields a print_event versus a contract_call delivery carries.

  • The result type comes with it: import type { ChainWebhookDelivery } from @secondlayer/sdk alone. Every piece the union composes is exported too: the per-trigger Chain*Data shapes, ChainEventEnvelope, ChainTxLevelEvent, ChainReorgRollbackDelivery, and the sBTC deposit and withdrawal payloads.
  • The decoder validates as it narrows. A body that isn't a chain.* delivery, or whose type disagrees with data.trigger, throws instead of quietly handing you a mistyped object.
  • WebhookHeaderInput and StandardWebhooksHeaders are exported as well, so you can type your own framework adapter around verifyWebhookSignature.

PoX-5 calls, fully typed

Every client.pox5.* action and read now runs through the module's committed pox-5 ABI: arguments are checked at compile time, so a wrong name or type is a tsc error rather than a failed transaction, and reads return plain JS values instead of raw Clarity envelopes.

  • Reads decode for you: uint becomes bigint, tuples become camelCase objects, and an optional read returns null when the record doesn't exist. getStakerInfo(...) hands you { amountUstx, firstRewardCycle, numCycles, signer } | null directly.
  • One breaking change: the 12 individual reads no longer return ClarityValue. getStakerState is unchanged.
  • The ABI is machine-extracted from the pox-5 boot contract and guarded by a simnet drift test, so a stacks-core contract change fails our build, not your integration.

Typed Clarity narrowing, cheaper signing

cvToJSON returns any, which switches the compiler off at the exact boundary where Clarity meets your code. Five new helpers (cvToBigInt, cvToString, cvToBuffer, cvToBoolean, cvToPrincipal) narrow a ClarityValue to a typed primitive or throw with a message naming the mismatch, so a wrong assumption fails at the call site instead of three functions later.

  • Signing is cheaper: transaction serialization is memoized per object, so multi-sig and sponsored flows no longer re-serialize the same transaction on every signer step. Wire bytes are unchanged.
  • Multi-sig metadata survives the wallet round-trip: _multisig signer keys set at build time carry through provider signing, so auto-detection keeps working after stx_signTransaction returns.
  • cvToJSON and cvToValue are untouched, so existing code keeps working.

PoX-5 Bitcoin Staking, two weeks before the fork

Build against SIP-045 today: the new @secondlayer/stacks/pox5 module ships every staking flow (bonds, STX staking, L1 lockup scripts, signer-key grants), pinned against the final pox-5 contract in stacks-core 4.0.0. Your integration gates itself: client.pox5.isActive() asks the chain, so code you ship now simply switches on when Bitcoin block 960,230 lands (~July 29).

  • 13 wallet actions that inherit fee tiers, nonce management, and typed errors; getStakerState() reads a staker's whole position in one batched call.
  • Off-chain tooling works pre-activation: CLTV lockup scripts and P2WSH addresses, SIP-018 signer grants, cycle and bond-phase math.
  • Not a hand-transcribed ABI: scripts and grant hashes are byte-compared against the actual boot contract in Clarinet simnet, and every action is pinned to the contract interface through the real signing path.

See the PoX-5 staking docs.

More node data, fewer silent failures

New actions round out the read surface, and a fixed-up transport catches errors it used to swallow: getContractSource, getRawBlock, getAccountHistory, getMempoolStats, and getNftHoldings join the SDK's action set, and every read now throws instead of quietly treating a failed request as real data.

  • getContractSource and getRawBlock read straight from a stacks-node (Clarity source, raw block headers) for cases the indexed extended API can't serve.
  • getAccountHistory, getMempoolStats, and getNftHoldings, promoted out of the AI-tools layer into first-class actions, also available on client.*.
  • The HTTP transport throws a typed error on any failed request and now retries rate limits (429), not just server errors, so a 404 body no longer gets parsed as if it were a successful response.

Bitcoin addresses without the Bitcoin baggage

Derive your paired BTC account, the bc1q…/bc1p… addresses Leather and Xverse show next to your Stacks address, from the same mnemonic, with zero new dependencies. And ask the chain, not a config file, where sBTC deposits go: client.sbtc.getSignersAddress() derives the signers' taproot address from the on-chain registry, encoded for whatever network your client points at.

  • mnemonicToBitcoinKeys covers BIP84 (native segwit) and BIP86 (taproot) paths, validated against the official spec vectors.
  • Pubkey→address helpers (publicKeyToP2trAddress, taprootTweakPubkey) ship in @secondlayer/stacks/bitcoin: pure derivation, no tx signing.
  • Network-aware end to end: mainnet, testnet, and regtest encodings, so a devnet integration never shows you a mainnet address by mistake.

Send, price, and confirm transactions in one flow

Pass fee: 'min' | 'low' | 'mid' | 'high' to any send action and await the result with client.waitForTransactionReceipt({ txid, confirmations }), with no more hand-rolled fee estimates or polling loops. 'min' is the node's relay floor (1 uSTX per byte), computed offline, and it's also the automatic fallback when the node can't produce an estimate, so your broadcast goes through instead of erroring.

  • waitForTransactionReceipt returns a normalized receipt with the decoded Clarity result, rejects with typed errors on abort/drop/timeout, and recounts confirmations if a reorg moves your transaction.
  • Status sources are pluggable: the default reads any extended-API host; indexTxSource() reads Secondlayer's index and gets the chain tip in the same response, so N-confirmation waits cost one request per poll.
  • sendTransaction({ wait: 2 }) broadcasts and confirms in one call; x402 settlement can now await the payment landing before reporting it.
  • Broadcast rejections are typed: BroadcastError.reason narrows across all 26 stacks-node rejection strings, with the node's reason_data attached.

Watch mode and dependency types for contract codegen

secondlayer contracts generate --watch regenerates your typed clients the moment a .clar file, your config, or Clarinet.toml changes, so leave it running next to your editor. And the clarinet() plugin now types the contracts you depend on, not just the ones you wrote: everything under [project.requirements] gets a typed client by default (includeRequirements: false to opt out).

  • Generated output now ships named type aliases per function (TokenTransferArgs, TokenTransferResult) and a TypedAbi-branded ABI const, so hovers and type errors name your types instead of dumping expanded conditionals.
  • Generated imports target real @secondlayer/stacks subpaths; previously emitted root imports didn't resolve.
  • Breaking: the actions() codegen plugin is gone. It generated code against an API surface that no longer exists; the generated contract objects and getContract cover the same ground, typed.

Reorg-safe contract lookups

GET /v1/contracts/:contractId no longer serves contracts that were reorged out of the canonical chain. A reorg flips the registry row non-canonical, and the moment the deploy reappears on the new fork, discovery restores it automatically, so your fetched ABIs survive the round trip.

  • Trait discovery (/v1/contracts?trait=) and as-of-block trait resolution already excluded reorged-out contracts; the by-id read now agrees with them.
  • The burnchain reward tables (burn_block_rewards, burn_block_reward_slots) drop their never-enforced canonical column; replace-per-height is the documented reorg contract for burnchain data. If your secondlayer index codegen mirror includes these tables, re-run codegen and drop the column; it was always true, so nothing is lost.

Named types on hover, unsigned transactions on demand

Your generated ABIs now carry their own type names. getContract picks up the TypedAbi brand emitted by secondlayer contracts generate, so hovering contract.call.transfer shows TokenTransferArgs, cmd-clickable and readable in errors, instead of a wall of inferred conditionals. Hand-written as const ABIs keep working exactly as before.

  • New contract.buildCall.* namespace builds unsigned transactions for wallet-signs-later flows; it never broadcasts, and fee and nonce auto-resolve when you omit them. Plus publicKeyToAddress in @secondlayer/stacks/utils.
  • jsToClarityValue takes pre-built ClarityValues anywhere, and buffer args accept hex strings, Uint8Array, or tagged { type, value } objects.
  • Read methods on (response ok err) outputs now type as the ok value, so no more unknown leaks out of typed reads.

SIP-045 staking post-conditions, ready before the fork

@secondlayer/stacks@2.10.0 speaks the Epoch 4.0 wire format ahead of the Bitcoin Staking hard fork (SIP-045, targeted ~July 29). The transaction codec decodes and encodes the two new post-condition types (0x03 Staking and 0x04 PoX), byte-identical to the reference implementation, so your decoders don't break the day pox-5 transactions hit the chain.

  • Protect staking calls with staking-postcondition (principal + amount bound, evaluated on stake, register-for-bond, stake-update) and pox-postcondition (will-not-perform / may-perform / will-perform, evaluated on unstake, announce-l1-early-exit, and other non-locking PoX calls).
  • The deserializer now fails loud on unknown post-condition types instead of silently misreading everything after them, so malformed or future-format transactions surface as errors, not corrupt data.
  • ClarityVersion.Clarity6 ships for Epoch 4.0 contract deploys (SIP-044).

Type names mirror stacks.js, so post-conditions written for either SDK are portable. See the Stacks SDK docs.

Releases before July 2026 live in the changelog archive.