ar-io-node is the gateway's command-line tool. Its first commands build and
check index bands; every other command is a
command of the ar.io CLI from @ar.io/sdk, run with the same arguments.
It follows the ar.io CLI's conventions, so the two behave alike:
- flat, kebab-case command names (
index-band-build,get-gateway); - a command's result is printed as indented JSON on stdout, with exit code
0; - an error's message goes to stderr (its stack trace with
--debug), with exit code1. A check that fails prints its full result, as JSON, on stderr; - logs go to stderr too, so stdout can be parsed.
If you are a script or an agent, read For scripts and agents first.
From the gateway's directory (where .env and docker-compose.yaml are),
tools/ar-io-node runs it in the core image named by CORE_IMAGE_TAG, so the
host needs only Docker:
./tools/ar-io-node --help
./tools/ar-io-node network-help # the ar.io commands it runs for youThe wrapper mounts only what a command needs:
| Commands | Mounted |
|---|---|
index-band-* |
INDEX_SWARM_DATA_PATH (default ./data/indexes) at data/indexes, and the gateway's Docker network (DOCKER_NETWORK_NAME, default ar-io-network), so --gateway-url http://core:4000 reaches the gateway |
index-band-export |
Also SQLITE_DATA_PATH (default ./data/sqlite) read-only at data/sqlite; CLICKHOUSE_URL, CLICKHOUSE_USER and CLICKHOUSE_PASSWORD, passed by name, so not on the command line (docker inspect of the running container shows them); and INDEX_EXPORT_SECRETS_DIR, if set, read-only at /run/secrets/index-export for peers' password files |
index-l1-* |
INDEX_SWARM_DATA_PATH read-only at data/indexes; SQLITE_DATA_PATH at data/sqlite, read-write for index-l1-import and read-only for index-l1-verify and index-l1-audit; and the gateway's Docker network, so --data-from or --anchor-from can name http://core:4000 |
any ar.io command |
Nothing, except the file a --wallet-file (-w) names, read-only. A relative path is taken from where you ran the wrapper |
It runs as your user, so what it writes is yours, and
data/indexes/published/<index> must be writable by you. index-l1-import
is the exception: it runs as the owner of the core.db it writes (root, on
a Docker install), so the files it adds beside the database match it.
Ctrl-C stops a
command; a build interrupted that way leaves its scratch copy in
data/indexes/export/.band-build-*, which the next build removes once nothing
has been written in it for a day (or delete it yourself).
Band commands never take a key. For ar.io commands that sign, prefer
--wallet-file to --private-key: an inline key is visible in ps and
docker inspect.
Settings (CORE_IMAGE_TAG, INDEX_SWARM_DATA_PATH, SQLITE_DATA_PATH, DOCKER_NETWORK_NAME)
come from your shell if set there, else from .env, as for compose. Set
AR_IO_NODE_CLI_IMAGE to run another image. From a checkout, run
node --import ./register.js src/cli/cli.ts <command>.
Inside the container, the data directory is data/indexes and the SQLite
directory data/sqlite. The wrapper rewrites any --input, --band-dir,
--publish-dir, --work-dir, --output, --bands-dir or --core-db that
points inside a mounted directory (absolute, or relative to where you ran it)
to its form in the container, so all of these work:
./tools/ar-io-node index-band-verify --band-dir "$INDEX_SWARM_DATA_PATH/published/root-tx-index/<band>" ...
./tools/ar-io-node index-band-verify --band-dir data/indexes/published/root-tx-index/<band> ...
./tools/ar-io-node index-band-verify --band-dir "$(jq -r .dir build.json)" ... # a build's own "dir"A path outside the data directory is passed through unchanged, and a band
command refuses to write there (it would land inside the container and be
lost). An input file elsewhere goes in on stdin: --input - < records.csv.
The dir a build reports is the container path (data/indexes/...).
Builds one band from CSV records, checks a sample of its headers against their root transactions, and publishes it:
./tools/ar-io-node index-band-build --input - --skip-header \
--publisher <this gateway's wallet> --kind d --height-range 2010500,tip \
--gateway-url https://turbo-gateway.com < records.csvThe band is built under --work-dir and renamed into --publish-dir, where
the index-swarm sidecar publishes it. Both must be on one filesystem (the
defaults are). An existing band is never overwritten: building the same
records again reports "unchanged": true, without checking or writing.
Input: CSV in the columns of tools/generate-cdb64-root-tx-index, plus a
height:
data_item_id,root_tx_id,path,root_data_item_offset,root_data_offset,data_item_size,height
- Only the first two columns are required.
pathmust be empty: bands don't carry nested bundle paths yet. - When one ID appears more than once, the highest
heightwins, and at equal heights a record with offsets beats one without. Any remaining tie is broken the same way whatever the input order, so a rebuild gives the same band id. - A malformed row (an ID that isn't 43 base64url characters, a number that isn't a non-negative integer) fails the build with its line number.
- A well-formed record the band can't use is dropped and counted. That covers offsets that don't frame a header (the header span must be 1 byte to 1 MiB), only one of the two offsets, and a value too large to address. A few per thousand is normal for real data (37 of 4,000 in the test below); the build logs a warning with the count.
| Option | Meaning |
|---|---|
--input <path> |
The CSV, or - for stdin. Required |
--skip-header |
Skip the CSV's first line |
--publisher <wallet> |
The publishing gateway's registered wallet. It names the band (so ids are unique to a publisher) and is not otherwise checked. Required |
--kind <kind> |
d (delta), r (recent), h (history), or another name of up to 8 lowercase letters and digits. Required |
--height-range <from,to> |
The heights the band covers, e.g. 2010500,tip; tip for a band that follows the tip. It is declared in the band's metadata (subscribers use it to order installs); records are not filtered by it. Required |
--supersedes <ids> |
Comma-separated ids of bands this one replaces. Not checked against what exists: the sidecar stops offering the named bands and deletes them after INDEX_SWARM_SUPERSEDE_GRACE_SECONDS, and warns once about an id it doesn't hold (see band metadata) |
--metadata <json> |
A JSON object of extra band metadata |
--publish-dir <path> |
Where the band is published, one directory per band (default data/indexes/published/root-tx-index) |
--work-dir <path> |
Scratch space for the build (default data/indexes/export) |
--gateway-url <url> |
The gateway the header check reads root transactions from. Required unless --skip-header-check. See Choosing --gateway-url |
--read-timeout <ms> |
How long the check waits for each root read (default 30000) |
--skip-header-check |
Publish without the header check |
--sample-size <n> |
Entries the check samples (default 150) |
--dry-run |
Build and check, but publish nothing |
The header check samples entries with offsets and range-reads each one's
root transaction. A band fails if any header is wrong: its signature hash
isn't the ID, it doesn't end where the payload starts, or the offsets run
past the end of the root. It also fails if fewer than 80% of the sample
could be read and passed, or if the band has fewer than 1,000 entries. A failed band is not
published, and the command exits 1 with the result on stderr. A band can pass
with a few entries the gateway couldn't serve: they are listed in errors
and count against the 80%, but aren't evidence the band is wrong.
Runs the header check on a built or installed band:
./tools/ar-io-node index-band-verify \
--band-dir data/indexes/published/root-tx-index/<band> \
--gateway-url https://turbo-gateway.com| Option | Meaning |
|---|---|
--band-dir <path> |
The band's directory (it holds manifest.json). Required |
--gateway-url <url> |
As for index-band-build. Required |
--read-timeout <ms> |
As for index-band-build (default 30000) |
--sample-size <n> |
Entries to check (default 150) |
It prints the result and exits 1 if the band fails.
Writes one record source's records for a height range as CSV, the format
index-band-build reads, with a header line. For checking what a source
gives and for one-off builds; the index-export service merges several
sources itself, by rank, which a CSV can't carry.
./tools/ar-io-node index-band-export --source '{"type":"clickhouse"}' \
--from 2010000 --to 2011000 --output data/indexes/export/records.csv
./tools/ar-io-node index-band-build --input data/indexes/export/records.csv \
--skip-header --publisher <wallet> --kind d --height-range 2010000,2011000 \
--gateway-url https://turbo-gateway.com --dry-run| Option | Meaning |
|---|---|
--source <json> |
One source, as an INDEX_EXPORT_SOURCES entry: {"type":"clickhouse"} (this gateway's, from CLICKHOUSE_*), {"type":"clickhouse","url":…,"user":…,"passwordFile":…} (a peer's), {"type":"sqlite"} (data/sqlite/bundles.db, or "path"), or {"type":"csv","path":…} (an overlay directory). Default: this gateway's ClickHouse if CLICKHOUSE_URL is set, else its SQLite |
--from <height> / --to <height> |
The heights to export, both included. Required |
--output <path> |
The CSV to write, under data/indexes through the wrapper. Written under a .partial name and renamed when complete. Required |
--force |
Replace --output if it exists |
Where each source reads from, through the wrapper:
- This gateway's ClickHouse:
CLICKHOUSE_URLas the container sees it, on the gateway's Docker network (http://clickhouse:8123, notlocalhost). - A peer's ClickHouse: its
urlmust be reachable from the container, andpasswordFileis the container path,/run/secrets/index-export/<file>, readable by your user. The peer's ClickHouse user must be allowed to change settings (readonly=0or2); the queries set their own limits and are refused under areadonly=1profile. - SQLite:
data/sqlite/bundles.db, opened read-only, which needs the gateway running (its-waland-shmfiles present). On a gateway with ClickHouse it holds only what import hasn't taken yet. - An overlay: a directory under
data/indexes. Each file is named for the heights it covers,<from>-<to>.csv, and files may not overlap; anything else in the directory (*.tmp,*.partial, dot-files) is ignored. Write a file under a temp name and rename it into place, and don't preserve an old modification time (cp -p): the file's age is how a stale overlay shows. An export renamed into an overlay directory becomes authoritative over every other source within its heights, so put only a bundler's own records there.
What it exports, from either index:
- Data items only, one record per item: from ClickHouse, the row at the highest height, latest inserted, so a re-bundled item gives its later root.
- Offsets relative to the root, only where proven:
root_parent_offsetplus the item's own, whenroot_parent_offsetmatches the parent's payload. A nested item whoseroot_parent_offsetis 0 or unknown may hold relative offsets (unbundled before #907 was fixed) or absolute ones (resolved on demand). It is tested against its parent's payload. Starting before the payload and fitting it as relative means relative, and the item is repaired (repaired). Fitting it as absolute but running past it as relative means absolute, and it is kept. Items nested deeper are placed only along an unbroken chain ofroot_parent_offsets. Anything unproven gives the item's root without offsets, counted inunrepairedby reason (ambiguous,no_parent,inconsistent,deep). - Left out and counted in
dropped: L1 transactions, items without a root or whose root is the item itself, and items of size 0.
ClickHouse queries are read-only and bounded: 2 threads, 4 GB with a GROUP BY spilling to disk past 2 GB, 600 s, low priority, and cancelled if the client goes away. They read a thousand blocks at a time, and a window that still runs out of memory is retried in halves. Each item's parent comes from the same query. SQLite is read along its height index, 5,000 rows per statement, with parents by primary key.
From a real run (a thousand blocks of a gateway's own ClickHouse; the records then passed the header check against turbo-gateway.com, 150 of 150):
{
"output": "data/indexes/export/records.csv",
"source": "clickhouse",
"rank": 0,
"heightRange": [2000000, 2000999],
"rowsRead": 19747,
"records": 19747,
"rootOnly": 0,
"repaired": 0,
"unrepaired": {},
"dropped": {},
"seconds": 0.3
}dropped counts rows left out, by reason: not_data_item, no_root,
root_is_item, zero_size, and for an overlay no_height and
outside_coverage.
Fills this gateway's core.db from installed parquet-l1 bands, so it has
the chain's history without walking it block by block. See
L1 bands for what a band is, and
subscribing for how one arrives.
It runs two ways:
- While the gateway runs, with
--fill. The usual way, and what the gateway does by itself withL1_BAND_IMPORT_ENABLED=true.tools/index-swarm-setup --subscribe <publisher wallet> --l1subscribes to a publisher's L1 bands and sets that. - With the gateway stopped. The fastest way to load a whole chain into an empty database, at the price of the gateway being down while it runs.
Either way, run yarn db:migrate up first (the gateway's entrypoint does):
a core.db without the import ledger is refused. Only one import or fill
runs against a core.db at a time; a second is refused with Another import or fill is running. Through tools/ar-io-node, the command runs as
whoever owns the core.db it writes (root, on a Docker install), so the
files it adds beside the database match it.
./tools/ar-io-node index-l1-import --fill \
--bands-dir data/indexes/installed/parquet-l1 \
--core-db data/sqlite/core.dbA fill writes only the heights core.db lacks at or below the top of
stable_blocks (before the gateway's first flush, just below its lowest
unflushed block). That is everything beneath what the block importer
writes itself, so nothing the gateway indexed is rewritten and nothing it
is about to write is touched. The one exception is a range an interrupted
import or fill left unfinished, which is cleared and written again whole. The same command covers a gateway that began
mid-chain, a hole in the middle of what a gateway holds, and a new gateway:
with L1_BAND_IMPORT_ENABLED=true, an empty core.db and no
START_HEIGHT, the block importer starts at the chain tip and the history
beneath it arrives from the bands.
It writes a batch of rows per transaction and pauses after each, so the gateway's own writers, which wait up to 30 seconds for the lock, get in between. That makes it slower than an import with the gateway stopped; in return the gateway indexes and serves throughout. Each band is still read whole and checked against its digests, even when only part of it is written.
A band published before its range was fully mined (the publisher's tip
band) fills only as far as the blocks it holds. Heights no installed band
holds yet come back as uncovered: bands still downloading, and the
heights between the newest band and where a new gateway began, which the
publisher's next tip band holds, usually within a day.
Interrupting a fill is safe. Each range is recorded in the ledger before
its first row, and a range left unfinished is cleared and written again by
the next run, although its blocks are already there. The gateway's worker
runs a fill every L1_BAND_IMPORT_INTERVAL_SECONDS (default 600), so a run
that stops is retried and new bands are filled as they arrive; a run with
nothing to fill writes nothing to core.db. Its runs are counted in
l1_band_import_runs_total{result} (filled, idle, failed), with
l1_band_import_rows_total and l1_band_import_uncovered_heights.
This is for a core.db the gateway has not run on yet: a new gateway's,
just migrated. The command refuses a core.db another writer holds rather
than racing it, and refuses one that holds unstable (new_*) blocks,
which an import would be written beneath. A gateway that has run always
holds some: it moves blocks to stable_* only once they are at least 18
deep.
So for a gateway that already runs, use --fill.
docker compose -f docker-compose.yaml -f docker-compose.override.yaml stop core
./tools/ar-io-node index-l1-import \
--bands-dir data/indexes/installed/parquet-l1 \
--core-db data/sqlite/core.db
docker compose -f docker-compose.yaml -f docker-compose.override.yaml up -d --no-deps coreName every compose file you normally use: passing -f at all turns off
loading docker-compose.override.yaml automatically.
Bands are imported in height order, lowest first. The run stops at the first band that fails, because bands must land as a contiguous run — the block importer rewinds across a gap and gives up after 18 blocks. What was imported before the failure is kept and recorded, so running the command again carries on from there.
Filling in history beneath a gateway that began mid-chain is
--fill's job, with the gateway running. Stopped, the command would refuse
such a gateway's core.db for its unstable blocks. --from and --to
remain for a database with none, such as a copy whose new_* rows were
cleared: by default the run continues upward from the highest height it
holds, and these bound it instead:
# core.db holds 1,000,000 upward; fill in everything below it
./tools/ar-io-node index-l1-import --bands-dir ... --core-db ... --from 0 --to 999999This is safe because a band clears and rewrites only its own height
range, so importing below what is held cannot disturb it. --to means a
backfill need not redo the part you already have. The run's own
contiguity is still enforced: a gap inside it is refused.
Mind the hole. A backfill done in stages leaves heights uncovered
between runs, and the block importer rewinds across a gap. The command works
out what will still be missing, from every run of heights core.db actually
holds rather than its lowest and highest, so a gap left between two
earlier backfills is seen. It warns, and carries the gaps in the result as
holes. It does not refuse — staged backfill would be impossible if
it did — so check holes is empty before starting the gateway.
Two things --from does not do. It will not redo a band the ledger
already records as imported, which is skipped as already_imported. And
it does not repair missing transactions inside heights you already
hold: core.db reaching the tip means nothing plans, whatever is missing
underneath.
| Option | |
|---|---|
--bands-dir |
A directory of bands: as the sidecar installs them (<band id>~<generation>), or as a publisher writes them (by band id). Of two generations of one band, the newer is read. Required |
--core-db |
The gateway's core.db. Required |
--fill |
Fill only what core.db lacks beneath what the gateway writes itself, with the gateway running |
--from |
Start here instead of continuing above what core.db holds. With --fill, fill nothing below it |
--to |
Stop after the band covering this height. With --fill, fill nothing above it |
--max-bands |
Import at most this many bands, then stop. With --fill, at most this many ranges |
--cache-mib |
SQLite page cache for the import, in MiB (default 1024) |
Running it again when there is nothing to do is a no-op: each band is
recorded in parquet_l1_imports as it lands, and a band already held is
skipped.
Interruption is safe. A band is written to the ledger before its first
row and completed after its last. Importing a band clears its height range
first, so a band a crash left part way is imported again over whatever it
managed to write. Progress is read from that ledger, not from
stable_blocks: a band writes all of its blocks long before its
transactions, so how far stable_blocks reaches says nothing about how
much of a band landed. Every write is idempotent, so a band imported twice
leaves core.db exactly as importing it once does.
Before it starts the command estimates the space the run needs: about
300 bytes per row, plus 4 GiB held back for the write-ahead log and for the
gateway afterwards. It refuses rather than running out of disk inside a
write transaction. If it refuses, free space or import in stages with
--max-bands.
While a band lands it logs Importing a band to stderr once a minute,
naming the table it is on and the rows it has written of the rows the band
holds. The largest bands take over half an hour, so this is how you tell a
slow import from a stuck one.
Give it a page cache. --cache-mib is the biggest lever on a long
import. A bootstrap spends its time maintaining indexes, and SQLite's own
default cache is 2 MB: once the indexes outgrow it, every insert becomes
random I/O and the rate falls as the database fills. With an 8 GiB cache a
full bootstrap keeps about four times the rate it falls to with the
default. Set it to whatever the host can spare.
What to expect. A single band into an empty database is fast: the
sparse first 100,000 heights take about 10 seconds, and the busiest about
half an hour. A full bootstrap is slower than that suggests, because the
rate falls as the database grows. The whole chain takes about 15 hours on
12 cores and leaves a core.db of about 120 GB, the same size as a gateway
that indexed the chain itself. Allow about 2 GB of WAL beside the database
while a band lands. --max-bands splits the import across several runs;
the ledger makes each one pick up where the last stopped.
No ANALYZE or VACUUM is wanted afterwards. The rows are written in
primary-key order into tables the migrations already analysed, and a
VACUUM of a 120 GB database would rewrite the lot for nothing. If the
process is killed outright, the -wal file beside core.db is not a
problem to clear by hand — the next process to open the database replays
it.
Afterwards. The block importer continues from the highest height
imported. Transactions that arrive this way never emit TX_INDEXED, so
historical bundles are not unbundled unless you also run with
BACKFILL_BUNDLE_RECORDS. A transaction a block lists that its band did
not carry is written to missing_transactions, and the gateway backfills
it the usual way.
{
"coreDb": "data/sqlite/core.db",
"bandsDir": "data/indexes/installed/parquet-l1",
"haveTo": 99999,
"imported": 1,
"rows": 125179,
"missingTransactions": 0,
"skipped": [
{ "heightRange": [0, 4999], "reason": "covered_by_a_wider_band" }
],
"bands": [
{ "heightRange": [0, 99999], "result": "imported", "rows": 125179, "seconds": 10 }
],
"seconds": 10.4
}haveTo is the highest height core.db holds when the run ends. A band's
result is imported or refused; a refused one also carries reason,
and refused appears at the top level. A refused band fails the
command: the bands below it are imported and kept, but the result goes
to stderr and the exit code is 1, so a script notices. Run it again to
carry on from what landed. skipped lists the bands the run had
no use for, each with a reason: already_imported,
covered_by_a_wider_band (a tip band inside a whole one),
below_what_core_db_holds, or above_the_requested_range (past --to).
holes appears only when the run leaves heights uncovered, and is the
one field to check before starting the gateway.
With --fill:
{
"coreDb": "data/sqlite/core.db",
"bandsDir": "data/indexes/installed/parquet-l1",
"mode": "fill",
"ceiling": 2019421,
"filled": 1,
"rows": 3301442,
"missingTransactions": 0,
"uncovered": [],
"ranges": [
{ "heightRange": [1900000, 1999999], "heights": [1900000, 1917899], "result": "imported", "rows": 3301442, "seconds": 1380 }
],
"seconds": 1384.6
}ceiling is the highest height the run could write, -1 when the gateway
holds no blocks yet. Each of ranges is one band's heights that were
missing: heightRange is the band's, heights what it wrote. rows
also counts every owner (wallets) row of the band: owners carry no
height, so each is written unless core.db already has it, and counted
either way. uncovered lists the heights at or below the ceiling that
core.db lacks and no installed band holds yet.
As without --fill, a refused range fails the command and carries
reason, with the result on stderr.
Checks an L1 index against the weave size the chain itself commits to, and against its own internal arithmetic. Read-only, so the gateway can stay up.
Two views of the same index. --core-db is the operator's: does this
gateway's own database hold a faithful chain? --bands-dir is the
consumer's: a directory of bands can be checked with no gateway and
no database at all, which is what the format being content-addressed and
signed is for. Anyone deciding whether to install someone else's bands —
another gateway, an analyst, an agent — can establish they are faithful
before importing a byte, or without ever importing.
# the consumer's view: no gateway involved
./tools/ar-io-node index-l1-verify --bands-dir data/indexes/installed/parquet-l1It reads the Parquet in height windows, so the whole chain (24 bands in
October 2026) never has to fit in memory. The block checks take about half a minute per
500,000 blocks; with every block's tx_root and every band's lookups
checked, the whole chain from a --bands-dir took about an hour on one
host (2026-10-11). A set of bands that overlaps, or leaves a
hole, is refused rather than partly checked — a consumer holding a broken
set has a different problem from one holding a faithful one.
With --bands-dir, every band of layout l1-3 also has its
lookup files checked (the
lookups check): each must hold exactly the rows its band's tables give,
and the rows its band.json describes. A lookup is derived, so this proves
it neither points anywhere its tables don't nor answers what they wouldn't.
Bands of older layouts carry none and are counted in lookupsSkipped.
--skip-lookups leaves the check out.
Checking the largest lookups needs more than the 2 GB DuckDB is given, so
it spills to a folder of its own under the system temp directory
(TMPDIR, /tmp by default; inside the image, the container's), at most
16 GB, removed when the run ends or fails. A run killed outright (SIGKILL,
or Ctrl-C outside the wrapper) can leave ar-io-node-l1-verify-* behind;
delete it. Give it a few GB of free space there.
Both views drive the same chain rules, in chain.ts, over different
readers, and a differential test runs the two over one chain and requires
identical verdicts and counts. A rule reimplemented twice is a rule that
drifts.
./tools/ar-io-node index-l1-verify --core-db data/sqlite/core.dbWhy this is worth running. Above the 2.0 fork (height 422,250) a
block's tx_root recomputes from its transactions, so each block's
transaction set proves itself. Below the fork tx_root is empty, and a
pre-2.0 block's identity hash cannot be recomputed from an index at all. It
commits to the full wallet list at that height, to the recall block's whole
binary, and to every transaction's bytes, data and signature included. Modern Arweave nodes do not attempt it either — they take
everything below the fork from a hardcoded block index.
What is left is an accounting identity, and it is exact. A pre-2.0 block
added its transactions' data_size to the weave, so for every height
weave_size(h) - weave_size(h-1) = block_size(h) = sum of data_size
Those differences telescope, and the first post-2.0 block commits its own
weave_size in the segment its identity hash is taken over. So a single
trusted block hash pins the total size of every transaction beneath it.
| Option | |
|---|---|
--core-db |
The gateway's core.db. Required, unless --bands-dir is given |
--bands-dir |
A directory of bands to check instead, with no gateway and no database |
--from |
Lowest height to check. Defaults to the lowest held |
--to |
Highest height to check. Defaults to the fork, or the highest held if lower |
--anchor-from |
Comma-separated nodes to ask for the anchor block hashes. Omitted, nothing outside the index vouches for it |
--anchor-min |
How many sources must answer per anchor height (default 2) |
--skip-tx-root |
Leave out the tx_root recomputation, the one check that reads every transaction |
--skip-lookups |
With --bands-dir, leave out the check of each band's lookup files |
An index reaching below the fork is checked up to the fork and no
further: above it tx_root is the stronger proof, and reading on costs
tens of millions of rows for nothing. An index starting above the fork is
read whole. Measured: 422,251 blocks in 2.7 seconds.
tx_root. Above the 2.0 fork the command recomputes each block's
tx_root from its transactions. A block holding a
format-1 transaction with data is counted out, because that leaf is the
root of data an index does not store; those are
index-l1-audit's job.
This is the only check that reads every transaction rather than a sum
over them, and it dominates the cost: on the whole chain it is about 40
minutes against three and a half for everything else. --skip-tx-root
leaves it out for a quick pass, and the result reports
streamedPassSeconds beside seconds so the two are visible
separately.
The chain binding. hash_list_merkle is a running commitment to every
block hash below it, and the command rebuilds that recurrence the whole
way — including folding heights 0 to 94,998 for the fork-1.6 seed. Only
the fork-2.0 seed is unrebuildable from stored fields. That is what makes
one trusted block hash pin every indep_hash beneath it. Without it
the chain is only as strong as previous_block, which an index that
fabricated the whole thing self-consistently would satisfy just as well.
--anchor-from gets that hash from somewhere independent and is the only
part of this command that asks anyone else:
./tools/ar-io-node index-l1-verify --core-db data/sqlite/core.db \
--anchor-from http://15.235.234.171:1984,http://208.69.78.61:1984,http://148.113.226.53:1984Prefer raw Arweave nodes (port 1984) over gateways: they are a different
implementation from this one, so their agreement is worth more. Any
gateway's /peers lists them. Each anchor height needs --anchor-min
sources to answer (default 2), and every source that answers must agree.
One source is a single point of trust, which is what the anchor exists to
remove, so a single reachable source fails the check. Sources that
cannot be reached are reported but do not count as disagreement, and
repeats of the same URL — including ones differing only by a trailing
slash — count once. The command works offline without it, and says so
rather than implying anything vouches for the index.
Both sides of the fork are anchored automatically: the fork-2.0 seed breaks the recurrence, so heights below it are anchored at 422,249 and heights above at the top of the range.
What it proves. Given the identity hash of the anchor block, no transaction below the fork can have been invented, dropped, or had its size changed, and the index is contiguous, correctly linked, and bound to that anchor block by block.
What it does not prove. Membership of a particular pre-2.0 block.
The intermediate weave_size values are not committed individually, so
transactions could in principle be moved between pre-2.0 blocks with
those values adjusted to match. Closing that needs agreement between
independent gateways, which is corroboration rather than proof. Do not
describe pre-2.0 membership as cryptographically verified.
Above the weave offset where Arweave began padding each transaction to a
chunk boundary (STRICT_DATA_SPLIT_THRESHOLD, 30,607,159,107,830) the
identity stops holding. Those blocks are counted as skipped rather than
reported as wrong, and a range with any skipped block is not reported as
anchored. So a whole-chain run shows anchored: false by design, and it
is the default range that carries the anchored accounting.
On the whole chain the accounting identity holds up to height 812,969,
fork 2.5, where the strict data split begins; tx_root covers the blocks
above it, except those left for index-l1-audit. A whole-chain run takes
about 45 minutes, nearly all of it tx_root.
{
"coreDb": "data/sqlite/core.db",
"heightRange": [0, 422250],
"blocks": 422251,
"anchored": true,
"anchorHeight": 422250,
"weaveSize": "407672420044",
"accountedFor": "407672420044",
"merkleChecked": 422249,
"merkleSkipped": 1,
"accountingChecked": 422250,
"accountingSkipped": 0,
"ok": true,
"checks": [
{ "name": "contiguous", "ok": true, "detail": "..." },
{ "name": "linked", "ok": true, "detail": "..." },
{ "name": "hash_list_merkle", "ok": true, "detail": "..." },
{ "name": "tx_root", "ok": true, "detail": "..." },
{ "name": "anchor_hash", "ok": true, "detail": "422249: 3 sources agree; ..." },
{ "name": "block_size", "ok": true, "detail": "..." },
{ "name": "weave_accounting", "ok": true, "detail": "..." },
{ "name": "anchor", "ok": true, "detail": "..." }
],
"seconds": 2.7
}With --core-db the result also carries txRootChecked, txRootSkipped
and txRootSeconds; streamedPassSeconds, the time of the pass over the
blocks without the tx_root check (seconds is the whole run); and with
--anchor-from, anchorSources: each node
asked, the height asked for, and the hash it gave or its error. Given both
--core-db and --bands-dir, the command checks core.db.
With --bands-dir the result names the directory (bandsDir) and the
number of bands instead of a core.db, and carries no anchor fields
(--anchor-* are ignored). It has txRootChecked and txRootSkipped, and
lookupsChecked and
lookupsSkipped (bands whose lookups were checked, and bands of a layout
without them). Its checks include lookups, whose failures name the
band's first height and, in found, the band and the file.
ok is every check passing. anchored says whether the top of the range
commits its own weave size, and whether every block in it could be
accounted for — without that, the checks only show the range is
self-consistent. weaveSize and accountedFor are strings, because the
weave is larger than a JSON number holds exactly. A failing check carries
failures (at most 20, each naming a height, what was found and what was
expected) and more for the rest. When ok is false the command fails:
the same JSON goes to stderr and the exit code is 1, so a script cannot
read a broken index as a good one.
Checks the blocks index-l1-verify has to skip.
tx_root does not prove transaction ids, and a
format-1 transaction's leaf is the root of its data, which an index
does not store. So a post-fork block holding a v1 transaction with data
cannot be checked from the index alone: about 13% of the blocks above the
fork. Ids are bound by the block's own identity hash, which no index can
recompute, so this command checks each v1 id against the signature it must
be the SHA-256 of. That makes it a real transaction's id, without proving
the transaction was in this block.
This closes the gap by fetching that data, deriving each data_root, and
recomputing tx_root.
./tools/ar-io-node index-l1-audit --core-db data/sqlite/core.db \
--data-from https://arweave.net --sample 200| Option | |
|---|---|
--core-db |
The gateway's core.db. Opened read-only |
--data-from |
An Arweave node or gateway to fetch transaction data from. Required |
--sample |
How many blocks to audit (default 100) |
--from, --to |
Bound the height range |
It samples rather than sweeps. The full set is about 912,729 transactions and 111 GB; a few hundred blocks costs a few hundred requests and bounds the error rate. Blocks are chosen at random, so a publisher cannot know in advance which will be looked at.
The data source does not have to be trusted, and what makes that true
is that both halves are required. The data has to reproduce a tx_root
the block's identity hash commits to, and the signature has to hash
to the id the index holds. Forging either means a preimage, so a bad
source can cry wolf but cannot hide a fault.
Neither half is enough alone. tx_root does not bind ids, so in a block
holding one transaction — where the sort order cannot betray a changed
id — a source serving the original data under a tampered id would
reproduce tx_root exactly. That is why a missing signature is
unavailable and not a skip: a source must not be able to pass by
declining to answer.
It must still be the transaction's own data, which is why this reads
the data field of /tx/{id} and not the data route. /raw/{id} and
/{id} resolve content: a 1,614-byte transaction came back as an
8,847-byte SVG, with a 200. The fetched length is checked against
data_size and a disagreement is reported as unavailable, so a bad
source is never recorded as a bad index.
A sample of 1,000 blocks fetches about 550 MB and takes about 25 minutes. With no mismatch it bounds the error rate at 0.3% (95% upper bound); the cost of a tighter bound is roughly linear in the sample.
{
"coreDb": "data/sqlite/core.db",
"heightRange": [422250, 2014815],
"population": 208812,
"sampled": 150,
"matched": 150,
"mismatched": 0,
"unavailable": 0,
"bytesFetched": 94600000,
"errorRateUpperBound95": 0.02,
"ok": true,
"blocks": [
{ "height": 453548, "result": "match", "transactions": 1, "derived": 1, "bytesFetched": 1 }
]
}population is how many blocks in range the index cannot check alone, and
sampled how many of them were. blocks lists every block that did
not match, and at most 20 that did (matchesListed), because the counts
carry the result and a thousand matches is tens of kilobytes nobody
reads. A block's result is match, mismatch
or unavailable (with a reason). Any unavailable block fails the
run and suppresses the rate: a source that withheld exactly the blocks
it had corrupted would otherwise hand back a clean-looking audit of
whatever it chose to serve. errorRateUpperBound95 appears when nothing mismatched — it is the
rule of three, 3/n, and says how little a clean sample rules out.
mismatchRate replaces it once something does. The command exits 1 on any
mismatch, and also when nothing could be audited, because a run that
checked nothing proves no more than not running it.
Every ar-io-node command is non-interactive and answers in one shape, so a
script or an agent can drive it without parsing prose.
| Success | Failure | |
|---|---|---|
| Exit code | 0 |
1 (130 when interrupted with Ctrl-C) |
| stdout | Exactly one JSON value, the command's result, written in full before the process exits | Empty |
| stderr | Log lines (info:, warn:) only |
Log lines, then either one line of error text, or the result as JSON: for a band refused by the header check, a band or range index-l1-import refused, a failed index-l1-verify or a failed index-l1-audit |
- Read stdout, branch on the exit code. Never parse stderr for success; log lines may change.
--help,--versionandnetwork-helpprint text, not JSON. Each command's--helpends with an example and this contract.--versionprints the@ar.io/sdkversion thear.iocommands run.- No prompts. Band commands never ask for confirmation and never take a
key. (
ar.iocommands that write do prompt unless given--skip-confirmation; see their help.) - Safe to retry. A band's id is derived from its content, publisher,
kind and heights, so building the same records again gives the same id and
reports
"unchanged": truewithout checking or writing. A build that fails or is interrupted publishes nothing. - Paths: pass the
dira build reports straight back as--band-dir; see Paths. - No network except range reads of root transactions from
--gateway-urlduring the header check,index-band-export's reads from the ClickHouse it is given,index-l1-verify --anchor-from's block hash fetches (/block/height/<n>), andindex-l1-audit --data-from's transaction data fetches. Band commands make no Solana RPC calls.
From a real run (4,000 records from a gateway's own index, checked against turbo-gateway.com):
{
"id": "d-h2000000-tip-f5b1208c-0dac038f25bc",
"dir": "data/indexes/published/root-tx-index/d-h2000000-tip-f5b1208c-0dac038f25bc",
"published": true,
"unchanged": false,
"dryRun": false,
"records": 3963,
"rootOnly": 0,
"duplicates": 0,
"dropped": 37,
"sizeDropped": 0,
"heightRange": [2000000, null],
"supersedes": [],
"contentDigest": "…",
"headerCheck": {
"status": "passed",
"passed": true,
"reasons": [],
"totalRecords": 3963,
"checked": 150,
"ok": 149,
"wrong": [],
"errors": [{ "id": "…", "error": "timeout of 30000ms exceeded" }]
}
}| Field | Meaning |
|---|---|
id |
The band's id |
dir |
Where the band is, as a container path; null on a dry run of a band not already published |
published / unchanged |
published when this run published it; unchanged when an identical band was already there. The band is in place when either is true |
records |
Entries written. rootOnly of them have no offsets; duplicates lost to a better record for the same ID |
dropped / sizeDropped |
Records refused as invalid (normal in small numbers), and records whose size was dropped while their offsets were kept |
headerCheck.status |
Always present: passed, failed, already-published (an identical band was there; nothing was checked) or skipped (--skip-header-check). The other fields are present only when the check ran |
A band the check refuses exits 1 and prints this object on stderr, with
published: false, headerCheck.status: "failed" and a rejected array of
reasons.
{ "bandDir", "passed", "reasons", "totalRecords", "checked", "ok", "wrong", "errors" }.
wrong lists entries whose header doesn't match (id, reason); errors
lists entries the gateway couldn't serve (id, error), which count against
the 80% pass ratio but are not evidence the band is wrong. Exit 1, with the
object on stderr, when passed is false.
if out=$(./tools/ar-io-node index-band-build --input - --skip-header \
--publisher "$WALLET" --kind d --height-range "$FROM,tip" \
--gateway-url https://turbo-gateway.com < records.csv); then
dir=$(jq -r .dir <<<"$out") # in place: .published or .unchanged
./tools/ar-io-node index-band-verify --band-dir "$dir" \
--gateway-url https://turbo-gateway.com >/dev/null
else
echo "band refused or failed; see stderr" >&2
fiThe check range-reads up to 150 root transactions. A gateway that has to
fetch them from the network itself can time out on many of them. The band is
then refused for being under 80% checked, not for being wrong; the reason
says how many couldn't be read, and why. Use a gateway that already
holds the roots, typically the one whose index produced the records, or
https://turbo-gateway.com; or raise --read-timeout. With 4,000 recent
records on a gateway without those roots cached:
--gateway-url |
--read-timeout |
Result | Time |
|---|---|---|---|
http://core:4000 |
30 s (default) | Refused: 108 of 150 passed, 42 reads timed out | 3 min 47 s |
http://core:4000 |
120 s | Passed: 149 of 150 | 7 min 8 s |
https://turbo-gateway.com |
30 s (default) | Passed: 149 of 150 | 40 s |
| stderr says | Cause | Fix |
|---|---|---|
--input is required (or --publisher, --kind, --height-range, --band-dir) |
A required option is missing | Pass it |
error: unknown option '--x' |
A misspelt or unsupported option | Check --help |
--gateway-url is required unless --skip-header-check |
No gateway for the check | Pass --gateway-url, or --skip-header-check to publish unchecked |
--input X cannot be read; through tools/ar-io-node, pipe it on stdin … |
The file isn't there, or is outside the data directory | --input - < X, or put X under INDEX_SWARM_DATA_PATH |
--band-dir X is not a band (no manifest.json) |
Not a band directory, or outside the data directory | Use a path under INDEX_SWARM_DATA_PATH, or a build's dir |
Line N: data_item_id is not a 43-character ID (a header line? use --skip-header) |
A header line, or a malformed ID | --skip-header, or fix line N |
Line N: nested bundle paths are not supported in bands yet |
The path column is set |
Leave it empty |
Invalid --height-range: … |
Not <from>,<to> or <from>,tip, or to below from |
Fix the range |
Invalid sample-size: … (or read-timeout) |
Not a positive integer | Fix the value |
--metadata is not valid JSON (or must be a JSON object) |
Pass a JSON object | |
--publish-dir … is outside data/indexes … (or --work-dir) |
Through the wrapper, only the data directory is mounted | Use a directory under it |
… checked entries passed, under 80%; N could not be read … |
The gateway couldn't serve enough roots | See Choosing --gateway-url |
N of M checked entries are wrong |
Offsets that don't point at the item's header | Fix the source of the records; don't publish |
… exists but is not a band … |
Something else is at the band's path | Remove it |
workDir … and publishDir … must be on the same filesystem |
The final rename can't cross filesystems | Put both under one mount (the defaults are) |
ar-io-node: … predates this tool |
CORE_IMAGE_TAG names an image without the CLI |
Upgrade, or set AR_IO_NODE_CLI_IMAGE |
Any command ar-io-node doesn't own runs as the ar.io CLI of the
@ar.io/sdk the gateway depends on (ar-io-node --version shows which), with
the same arguments, output and exit code:
./tools/ar-io-node get-gateway --address <wallet>
./tools/ar-io-node help get-gateway # that command's help
./tools/ar-io-node network-helpThey are the SDK's commands, unchanged: they use the SDK's own network
defaults and flags (--mainnet, --rpc-url and the program-id options), not
the gateway's settings, and the gateway itself never runs them. Check a
command's help for which network it targets and what it needs.