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, andsubgraphs deploy --visibilityare unregistered. Deploy's success footer always prints the/api/subgraphsread path. - SDK:
subgraphs.publish()/subgraphs.unpublish()and theSubgraphPublishResult/SubgraphUnpublishResulttypes are removed.deploy()no longer accepts or returnsvisibility. - MCP:
subgraphs_publishandsubgraphs_unpublishare no longer registered, andsubgraphs_listno longer reportsvisibilityor apublicUrl. - HTTP:
POST /api/subgraphs/:name/publishand.../unpublishare deleted and 404 in every mode. ThePUBLIC_NAME_TAKENerror 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 cancel→subgraphs stopstreams pull→streams dumpssubgraphs codegen→codegen subgraph(orcodegen printsfor--payloads)subgraphs client→codegen clientindex codegen→codegen indexcontracts generate/contracts gen→codegen contracts(thecontractsgroup 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_contract → subgraphs_scaffold, get_contract_abi → contracts_get_abi (last cycle's alias is retired for real now), subgraphs_get → subgraphs_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 --applyWiping 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 --deepA 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 observerNo 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 ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGMNo 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.jsonIt 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 # fixAnd 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_eventsYou 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 acceptedasset_identifierfor nft event reads but not ft. It now works on all six token event types, andftTransfers.listtakesassetIdentifierdirectly.pox5/eventsandsbtc/deposits500'd, andtransactionsreturned a corrupt cursor, whenfieldsomitted 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_idreturned a full page withnext_cursor: null, silently dropping the rest of your rows._idnow 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 typeft-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 typeDeposits, 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 typeProjection 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
.mdto a docs URL (/docs/streams.md). Generated from the MDX itself, so it can't drift. ?mode=agentnow works. It was advertised inllms.txtbut 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.mdships inside@secondlayer/sdkwith 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, andwalk()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 ABISome 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_URLon 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
typesnarrows its handler, soevent.payloadis typed without anevent_typeguard, and adding a label tofiltersis a compile error untilonhandles 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
subscribenarrows 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 typeThe 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_timelets the server skip theblocksjoin entirely. It is not a stored column but ato_timestamp()off a join taken on every read, so this is a planner-level saving on wide sweeps. cursor,block_height, andevent_typealways 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 --offlineReal 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.
Print payloads are typed all the way down
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
printsalso 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
abion a source is validated at deploy against the real Clarity ABI shape. A raw Hiro or Clarinet ABI used to pass and then mis-decodeevent.inputforever; 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 constABI and reference it, so handlers readevent.input.tokenIdinstead ofevent.args[0] as bigint. - Generated row types are derived from the schema instead of a second
hand-written map that answered
uintwithnumber, which didn't compile against the client it shipped beside, and truncateduint128.
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
onReorgcan no longer skip reorgs silently. ctx.txis a fully typedTransaction<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) andshutdownSignal()finishes the in-flight batch on SIGTERM.- On Streams,
decoded: truedelivers 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
bigintend to end and stringify at exactly one boundary, so a spread bigint can never crashJSON.stringifyagain. - 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.
uniqueKeyschanges are no longer invisible to deploys: additions runALTER TABLE … ADD CONSTRAINTin place, and removals are correctly refused as breaking. Previously auniqueKeysaddition reported success, created nothing, and every laterupsertfailed 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/stackstransports); watch with theonErrorobserver. - One error family:
RateLimitError,AuthError, andValidationErrorare nowApiErrorsubclasses thrown by every surface, so a singleinstanceofcheck works around Index, platform, and Streams calls alike. - Errors carry
retryable,retryAfterSeconds, adocsUrlpointing at the fix, andwalk()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.
fetchImplis 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, orreward_cycle, and page with the same<block_height>:<event_index>cursor the rest of Index uses. ?confirmed=trueclamps the window to the finality boundary, so settlement consumers only see rows past the reorg margin.- Every response carries
tipand areorgs[]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/stacksnow exportsPOX5_EVENT_TOPICS,Pox5EventTopic, andPOX5_CONTRACT_ID_MAINNETfor 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
printevents off the registry contract and matching topics yourself, losing the typed columns. withdrawalshas noconsumeon 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 v3 → v4 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 fortraitwhen 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") continueline every handler opened with, written only to satisfy the compiler, is gone. - A non-literal
eventTypestill 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.
heightsurvives 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
contracton testnet, and the thrown error now says exactly that instead of telling you to wait for an activation that isn't coming. SPV_ADAPTER_CONTRACTSandgetSpvAdapter(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
getPox5Activationor 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
apiKeystill wins. PassapiKey: ""to force keyless reads on a machine that has a key exported. - Exported as
resolveApiKeyif you want the same precedence in your own code, and guarded for browsers and edge runtimes whereprocessis 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 takingIndexEventand re-narrowing inside every function. - The
consumeIndexFeedtypes come with it:IndexFeedItem,IndexFeedEnvelope, andIndexFeedFetcherare 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, plusconfirmedto stay behind the finality boundary. amount_ustxandamount_satsstay strings, so stacking amounts pastNumber.MAX_SAFE_INTEGERsurvive 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/sdkalone. Every piece the union composes is exported too: the per-triggerChain*Datashapes,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 whosetypedisagrees withdata.trigger, throws instead of quietly handing you a mistyped object. WebhookHeaderInputandStandardWebhooksHeadersare exported as well, so you can type your own framework adapter aroundverifyWebhookSignature.
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:
uintbecomesbigint, tuples become camelCase objects, and an optional read returnsnullwhen the record doesn't exist.getStakerInfo(...)hands you{ amountUstx, firstRewardCycle, numCycles, signer } | nulldirectly. - One breaking change: the 12 individual reads no longer return
ClarityValue.getStakerStateis unchanged. - The ABI is machine-extracted from the
pox-5boot 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:
_multisigsigner keys set at build time carry through provider signing, so auto-detection keeps working afterstx_signTransactionreturns. cvToJSONandcvToValueare 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.
getContractSourceandgetRawBlockread straight from a stacks-node (Clarity source, raw block headers) for cases the indexed extended API can't serve.getAccountHistory,getMempoolStats, andgetNftHoldings, promoted out of the AI-tools layer into first-class actions, also available onclient.*.- 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.
mnemonicToBitcoinKeyscovers 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.
waitForTransactionReceiptreturns 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.reasonnarrows across all 26 stacks-node rejection strings, with the node'sreason_dataattached.
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 aTypedAbi-branded ABI const, so hovers and type errors name your types instead of dumping expanded conditionals. - Generated imports target real
@secondlayer/stackssubpaths; 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 andgetContractcover 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-enforcedcanonicalcolumn; replace-per-height is the documented reorg contract for burnchain data. If yoursecondlayer index codegenmirror includes these tables, re-run codegen and drop the column; it was alwaystrue, 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. PluspublicKeyToAddressin@secondlayer/stacks/utils. jsToClarityValuetakes 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 theokvalue, so no moreunknownleaks 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 onstake,register-for-bond,stake-update) andpox-postcondition(will-not-perform/may-perform/will-perform, evaluated onunstake,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.Clarity6ships 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.