This is a recommended release focused on index builds that fit ordinary gateways, chain-bound L1 witnesses, and data correctness. Key highlights include index-export's memory held near its work by holding glibc's allocator to two arenas (a 12M-item parquet-l2 band now builds at 3.3 GB instead of 16.8 GB) and DuckDB running one thread by default, avoiding a DuckDB 1.4 crash in large spilled aggregates; a parquet-l2 band size cap that keeps dense ranges as sub-range bands; parquet-l1 layout l1-4, whose witness files bind format-1 and pre-2.0 blocks to the chain, built by index-export; ar-io-node index-census to find items the indexer indexed but ClickHouse never received, and a ClickHouse import fix so the prune no longer drops unexported items; GraphQL pages that no longer lose or repeat transactions where pending and mined rows meet; and security updates to multer, undici, express/body-parser, form-data and ajv. Gateways that subscribe to parquet-l1 need this release to take bands published as l1-4.
Security
- Dependency updates for advisories in the HTTP and fetch stack, none imported by gateway code directly:
multer2.5.0 (DoS via a pending async file, orphaned disk writes),undici7.30.1 (DoS on excessive errors, unboundedEventSourcememory, SRI bypass),express4.22.3 withbody-parser1.20.9 (DoS via trailing bytes) andbody-parser2.4.0 under@apollo/server,form-data3.0.5 (CRLF injection in multipart field names),ajv8.20.0 (ReDoS with$data),fast-uri3.1.9 andstream-json3.7.0.yarn auditof production dependencies shows no high or critical advisory new since Release 86; those left are in build tooling (node-gyp) and SDK response validation (zod).
Added
ar-io-node index-census: check ClickHouse holds every item the indexer indexed. Read-only onbundles.dband ClickHouse: it compares each root's matched item count with the distinct ids ClickHouse holds under it, then lists the missing ids, so lost rows can be re-queued. On turbo-gateway.com it found 1.41 million items unbundled April–June 2026 missing from ClickHouse on both nodes. See docs/cli.md.parquet-l2band size cap (INDEX_EXPORT_L2_MAX_BAND_ITEMS, default 10,000,000). A 100,000-height range is one band only while it holds at most that many items; above it the range stays as its twenty 5,000-height bands. A settled range is counted at the source before it is frozen whole; twenty sub-range bands are rolled into one only while their items sum to the cap or less; and a band already over the cap is split into its sub-range bands, each built, compared with the old band's rows at its own heights and header-checked as a re-settle is, then all published together, each superseding the old band (subscribers keep it until every part is installed). On turbo-gateway.com,ardrive-l2's 1,800,000–1,899,999 band holds 69 million items, and its build peaked at 13 GB of native memory, killed twice in a 16 GB container; its densest sub-range holds 12 million. Sub-range bands are already valid, so subscribers, verifiers and readers need no change. See docs/index-export.md.parquet-l1layoutl1-4: witness files that bind every block of the chain, so a full mainnet band set verifies and imports with no--allow-*flag. A band addswitness_v1_data_roots.parquet(the v1 data root of each format-1 transaction with data) and, below the 2.0 fork,witness_fork_2_0.parquet(each block's transitiontx_rootand the 2.0 seed fold after it), described inband.jsonand added in place without changing the band's id.index-l1-verify --bands-dirandindex-l1-importrecomputetx_rootwith the witnessed roots (a wrong one failstx_root, never falls back to unbound), recompute each pre-2.0 block's transitiontx_rootfrom its transactions, and carry the 2.0 seed fold block to block until it reaches block 422,250'shash_list_merkleor a fold pinned in this build (heights 99,999, 199,999, 299,999, 399,999, 422,249; the first four recomputed by a test from Arweave's own 2.0 checkpoint, the last equal to block 422,250'shash_list_merkle). Every other pre-2.0 header field, and the transaction ids, are bound through each block's "2.0 hash" (witness_fork_2_0.h2), recomputed from the band's fields and folded to pins of Arweave'sgenesis_data/hash_list_1_0: Arweave-checkpointed rather than chain-bound, and said so. A pre-2.0 block is bound only when both folds reach their pins. Underl1-4a format-1 transaction'sdata_rootcolumn must be NULL. A block whose witness is missing stays in itsunboundclass. Newwitnessesandhash_list_1_0checks; the result gainsfoldBoundandformat1Txs.- Every column a band carries has a declared binding (
column-bindings.ts, shown in docs/cli.md), checked by a test. Bands'content_typeandcontent_encodingare recomputed from their tags, andoffset(NULL, or where the data ends in the weave) from the weave, failing the newcanonicalcheck otherwise. The block sample now draws pre-2.0 heights too, attested by--anchor-minmachines (blocksAttested): what binds a pre-2.0 block's transaction order. - An
l1-4band must hold every witness (awitness_fork_2_0row per pre-2.0 height, a v1 root per format-1 transaction with data); any of its blocks left unbound failswitnesses, whatever--allow-*flag is given (--allow-unbound,--allow-unverified-format1andL1_BAND_IMPORT_ALLOW_UNVERIFIEDaccept bands without witnesses only). TheINDEX_EXPORT_L1_WITNESS_MAX_MISSINGknob is gone. - A pre-2.0 range ending at a pinned height is anchored there by its witnessed folds (
anchor.pinned), so pre-2.0 bands verify and import with no--allow-unanchored. --sample-blocks 0, and an undrawn format-1 sample (no sources, or every count 0), need--allow-unsampledinindex-l1-verifyas inindex-l1-import.- Bands store one spelling per value: difficulties as
N.0below the 1.8 fork andNfrom it, no cumulative difficulty below 1.6, the reward pool asN, an unclaimed reward address as empty bytes, a wallet list always, nohash_list_merklebelow 1.6 (thecanonicalcheck). The exporter re-spells whatcore.dbholds into those forms, never changing a value, and refuses a value that isn't a whole number or a block without a wallet list. - Under
l1-4the importer writes the witnessed v1 roots and transitiontx_roots over whatevercore.dbkept. index-l1-verify --bands-dirandindex-l1-importrefuse anl1-1band: its columns were its publisher's gateway's, not canonical. Both known publishers are onl1-3.--sample-format1 0over format-1 transactions with data from the 2.0 fork now needs--allow-unsampled, inindex-l1-import(whatever their witnesses) andindex-l1-verify(new flag there): only the format-1 draw checks a transaction is format 1. Block 422,250'shash_list_merkleis now checked against the fold through 422,249 whenever a range holds both. Readers acceptl1-1tol1-4;--core-dbholds no witnesses and is unchanged. See docs/index-publication.md and docs/cli.md.index-exportbuildsl1-4witnesses for new bands and adds them to publishedl1-3bands in place, from v1 roots in a witness directory and Arweave nodes (INDEX_EXPORT_L1_WITNESS_DIR,INDEX_EXPORT_L1_WITNESS_NODES; unset, the core'sPREFERRED_CHUNK_GET_NODE_URLS, else its default tip and data nodes, each asked through the node pool so a dead node rests instead of costing every chunk a timeout), keeping only what the chain confirms and judging the band with them, as its consumers do, before anything is published. Reported asl1Witnessedandindex_export_runs_total{kind="witness"}. See docs/index-export.md.
Changed
- DuckDB runs one thread by default in index-export and the index CLI. The
duckdbNode binding stops at DuckDB 1.4, whose hash aggregate can crash the process (SIGSEGV, exit 139) merging a large aggregate that spilled while running on more than one thread (duckdb#23757, fixed in 1.5 only). On turbo-gateway.com it killed a band split at its compare (4 of 33 repeats of that compare crashed at two threads, none of 35 at one) and a fullindex-l2-verifyof ardrive-l2 (which passed at one). L2 band builds (INDEX_EXPORT_L2_DUCKDB_THREADS, now 1), L1 band writes, and the databases ofindex-l1-verify,index-l2-verifyandindex-l1-import(new--duckdb-threads, default 1) now run one thread. Results are unchanged; large steps take longer. L1_BAND_IMPORT_ALLOW_UNVERIFIEDis needed only for bands without witnesses (l1-3and older);l1-4bands fill under the strict default.- Wording: a pre-2.0 block hash is described per era (from the 1.6 fork a deep hash over the header fields and the full wallet list; below it a SHA-384 over the header as JSON with the full hash list and wallet list).
- better-sqlite3 is now 12.11.1, up from 9.6.0, which moves the gateway's bundled SQLite from 3.45.3 to 3.53.2. 9.x does not compile on Node.js 24, so this unblocks the move off Node.js 22 before its end of life (2027-04-30). The database file format is unchanged: a gateway can roll back to an earlier release without touching
data/sqlite.
Fixed
- The ClickHouse import no longer prunes items it has not exported. The prune threshold was the newest
indexed_atof a stable data item, but the flush copiesindexed_atfrom when an item was first indexed, so an item unbundled earlier (or re-queued) and flushed while a batch exported landed below the threshold after the export had read its range: it was pruned from SQLite, never reached ClickHouse, and its bundle stayed fully indexed, so nothing retried it. Stable items now carrystable_at, stamped by the flush itself, and the prune follows it. Adds a nullable column tostable_data_itemsinbundles.db(instant; migration2026.10.21T00.00.00). - On-demand data items' signature and owner offsets are stored on the same base as their data. An item found through the root-tx index and read from its bundle had
offsetanddata_offsetstored relative to the root butsignature_offsetandowner_offsetrelative to the item, so GraphQL'ssignatureandownerfor it returned the bundle's own header bytes. - GraphQL pages no longer lose or repeat transactions where pending and mined rows meet (#947). Pending rows are walked by the full sort tuple, NULL heights sort the same in the merged query as in each leg, an ascending walk reaches the pending rows after the mined ones, a new data item's cursor carries its own id, and the second leg is read until the page's look-ahead row is had. In an ascending walk the ClickHouse legs no longer start over once a page ends on a pending row (they hold only mined rows), and pending rows sort and page by data item and id rather than by
indexed_at, which each indexer stamps for itself, so a gateway federating several indexers no longer repeats pending items. A pending-row cursor issued before the upgrade may skip one pending row once, as the order changes under it. Thanks to @saravanan619 (#1096). - The abort-signal middleware no longer assigns
req.signal, which Node 24 defines as a getter: on Node 24 every request would have failed with a 500. The gateway's own signal (aborted only when the client leaves before the response) is kept. - index-export's memory no longer grows to several times its work. The service (and the CLI's verifiers) now run with glibc's allocator held to two arenas and returning large frees to the system (
MALLOC_ARENA_MAX=2,MALLOC_MMAP_THRESHOLD_=131072,MALLOC_TRIM_THRESHOLD_=131072). A build runs DuckDB query after query in one process, and with glibc's defaults the memory each frees stayed in per-thread arenas: building a 12.0M-itemparquet-l2band peaked at 16.8 GB of anonymous memory though no single query used more than 2.6 GB, and denser bands were OOM-killed in 16 GB. The same band now builds at 3.3 GB, about 15% slower, byte-identical. Override withINDEX_EXPORT_MALLOC_ARENA_MAX,INDEX_EXPORT_MALLOC_MMAP_THRESHOLDandINDEX_EXPORT_MALLOC_TRIM_THRESHOLD. parquet-l2layoutl2-2: the stats derive no longer runs a large band out of memory. Each item's first value of a stat tag was taken witharg_min(tag_value, tag_index), an aggregate whose BLOB state DuckDB 1.4.2 cannot spill, so the 69-million-item h1.8M band ofardrive-l2failed at the build's 2 GB limit, both in the in-place upgrade and in a rebuild. It is now the lowesttag_indexper item and tag, joined back for the value: the same rows, and the same band derives in about 6 minutes at 2 GB.- The
--publicationexamples indocs/cli.md,docs/index-publication.md,docs/index-verification.md, theindex-l2-verifyhelp, and the--publicationoption description gave the signed index publication's URL ashttps://<gateway>/ar-io/indexes/publication.json, which returns 404. The gateway serves it athttps://<gateway>/ar-io/indexes; the examples now use that. The local filedata/indexes/published/publication.jsonis unchanged. keccak(pulled in by@dha-team/arbundles, and loaded by every SQLite worker thread) is pinned to 3.0.4. Version 3.0.2 kept one process-wide reference to its class constructor, so a worker thread loading it while another started or exited could abort the whole process (Check failed: node->IsInUse(), a segfault, or on macOSlibc++abi: terminating due to uncaught exception of type Napi::Error).- Shutdown now waits until every SQLite worker thread has exited, and no longer respawns a worker that dies while the gateway is stopping.
Docker images
ENVOY_IMAGE_TAG:9a28d6538aca7fb88d96c862062b13a0ed0fce69CORE_IMAGE_TAG:69b40afceb16267ecd5c27b7ecb79aa1f365c17eCLICKHOUSE_AUTO_IMPORT_IMAGE_TAG:69b40afceb16267ecd5c27b7ecb79aa1f365c17eLITESTREAM_IMAGE_TAG:be121fc0ae24a9eb7cdb2b92d01f047039b5f5e8OBSERVER_IMAGE_TAG:2aa908dc40e0c6fcf3c91bda92565af10876288e