ar.io Logoar.io Documentation

Verifying Shared Indexes

A signed publication proves who published an index and that the files you hold are the ones they signed. It does not prove the index is right: that every row is where it says, that every location points at the item it names, that the lookups match the tables. The ar-io-node CLI has one read-only check per index kind that proves those things from the bands alone. Anyone holding a set of bands can run it: a subscriber after installing them, a publisher before publishing, or an agent asked whether an index is sound.

Release Requirement: index-l1-verify and index-band-verify need gateway Release 85 or later. index-l2-verify, and reading roots from Arweave nodes (--arweave-nodes), need Release 86. Checking l1-4 bands, whose witness files bind every block of the chain, needs Release 87: with them a whole set of L1 bands verifies with no --allow-* flag. See What index-l1-verify Checks.

Which Check, for Which Index

IndexHoldsCheck it withProvesDoes not prove
root-tx-index (CDB64)each data item's root transaction and offsetsindex-band-verifysampled entries' headers sit at their recorded offsets, in their root transactionsentries not sampled; root-only entries' roots
parquet-l1blocks, L1 transactions, their tags and ownersindex-l1-verify, then index-l1-auditwith --anchor-from: the top block, its header recomputed and served by two machines, equals the index; from there, every post-2.0 block's hash, weave size and tx_root (through hash_list_merkle), its links and sizes, and its transactions' sizes and data roots (tx_root rebuilt, with an l1-4 band's witnessed v1 data roots); with an l1-4 band's witnesses, every pre-2.0 block's hash, weave size and transition tx_root (through the 2.0 seed fold, to a pinned fold or block 422,250's hash_list_merkle); every table, file and lookup against band.json (and, with --publication, against what the publisher signed); every row non-null, placed, counted and owned; a keyed sample of headers and signatures. index-l1-audit closes the format-1 gap tx_root can'tblocks it reports unbound (failing unless accepted): under l1-3 (and with --core-db), every block below the 2.0 fork, and post-2.0 blocks holding a format-1 transaction with data; under l1-4, only blocks whose witness is missing; without --anchor-from, anything about the chain (it fails unless --allow-unanchored); transaction ids, their order, every transaction field and the block fields the recurrence doesn't carry, except for the share of forged rows sample.detects states
parquet-l2 (for example ardrive-l2)the ANS-104 data items one selection takes, with tags, owners and locationsindex-l2-verifyevery band's id against its contents; the set has no overlaps or holes; every table row by row; every location's layout and parent link; the lookups; a sample of locations against the chainthat every item the selection should take is there (completeness is the publisher's to show, by rebuilding the band from its source)

Every command follows the CLI contract: the result as JSON on stdout and exit 0; on failure the full result on stderr and exit 1. Every option is in the ar-io-node CLI reference.

Run It

Get the Bands

Download the index over BitTorrent or by digest, one folder per band, and drop any folder a newer band replaced. A subscribing gateway already holds its bands under data/indexes/installed/<index>/.

Run the Check

With only Docker, from a directory holding the bands:

IMAGE=ghcr.io/ar-io/ar-io-core:latest
docker run --rm -u "$(id -u):$(id -g)" -v "$PWD/ardrive-l2:/bands:ro" \
  -w /app --entrypoint /nodejs/bin/node "$IMAGE" \
  /app/dist/cli/cli.js index-l2-verify --bands-dir /bands \
  --publisher 34LYvMptiDvBP5sqfh1oAd6Q4qFsy4PWaZ1HTFmML7h5

--publisher is the address the publication names: with it, each band's id is also checked to carry that publisher's tag. In a gateway checkout the wrapper mounts the paths for you:

./tools/ar-io-node index-l2-verify --bands-dir data/indexes/installed/ardrive-l2 \
  --publisher 34LYvMptiDvBP5sqfh1oAd6Q4qFsy4PWaZ1HTFmML7h5

For L1 bands the command is index-l1-verify. It needs sources to anchor and sample against (--anchor-from, at least two distinct machines). l1-4 bands carry witness files that bind every block, so a whole set, from genesis to the tip, verifies with no --allow-* flag. --publication also binds the bands to what their publisher signed:

IMAGE=ghcr.io/ar-io/ar-io-core:latest   # Release 87 or later
docker run --rm -u "$(id -u):$(id -g)" -v "$PWD/parquet-l1:/bands:ro" \
  -w /app --entrypoint /nodejs/bin/node "$IMAGE" \
  /app/dist/cli/cli.js index-l1-verify --bands-dir /bands \
  --anchor-from https://<gateway one>,https://<gateway two> \
  --publication https://<publisher gateway>/ar-io/indexes \
  --publisher-key <the publisher's observer address>

--from and --to check part of the set. In a gateway checkout, check the installed bands, or the gateway's own core.db, with the wrapper:

./tools/ar-io-node index-l1-verify --bands-dir data/indexes/installed/parquet-l1 \
  --anchor-from https://<gateway one>,https://<gateway two>

./tools/ar-io-node index-l1-verify --core-db data/sqlite/core.db \
  --anchor-from https://<gateway one>,https://<gateway two> \
  --allow-unbound --allow-unverified-format1

Only bands carry witnesses: a core.db holds none, so checking one reports its pre-2.0 blocks and its blocks holding a format-1 transaction with data as unbound, and needs the two flags to pass. Each band's folder must be named by its band id: a folder a BitTorrent client named after its torrent fails identity until you rename it (see Check L1 Bands Against the Chain). For one root-TX band, the command is index-band-verify --band-dir <band> --arweave-nodes.

Read the Result

{
  "ok": true,
  "bands": 23,
  "items": 127734401,
  "checks": {
    "identity": { "ok": true, "failures": [] },
    "selection": { "ok": true, "failures": [] },
    "files": { "ok": true, "failures": [] },
    "tables": { "ok": true, "failures": [] },
    "locations": { "ok": true, "failures": [] },
    "parents": { "ok": true, "failures": [] },
    "lookups": { "ok": true, "failures": [] },
    "onchain": {
      "ok": true,
      "checked": 800, "wrong": 0, "unreadable": 12, "errors": 0,
      "seed": 1934802271,
      "failures": []
    }
  },
  "superseded": [],
  "unlocatedBands": []
}

ok is the verdict. Each check lists its failures, and each failure names the band (its found starts with the band id) and the height it starts at. Keep the seed: --seed <n> draws the same sample again.

index-l1-verify prints a different shape, the same with --core-db and --bands-dir: ok, anchored and the anchor, what is unbound, the sample (with detects, what a pass means), the checks it skipped, the gaps it accepted, and assurance, what binds what, in words. Its checks are a list, each with name, ok and detail, and a failing one carries failures. See the result in the CLI reference.

What index-l2-verify Checks

CheckFails when
identitya band's id doesn't name its heights, or its digest isn't that of its heights, selection and rows, or, with --publisher, its tag isn't the publisher's. The id is what the publication signs, so this ties the signature to the bytes
selectionthe bands hold different selections
filesa band lacks a file its layout requires, or holds one it doesn't
tablesa table's rows, read in file order, are out of their table's order, or don't digest to what band.json says, or an owner key doesn't hash to its address
locationsa location is partly set; or isn't laid out as ANS-104 requires (signature right after the 2-byte type, owner right after the signature, payload after the owner and inside the item); or data_size isn't the payload the location gives; or a signature or owner length isn't its type's; or an id, address or root isn't 32 bytes; or an item lies outside its band's heights
parentsan item directly in its root isn't at offset 0 of it, or an item in a nested bundle names one parents doesn't describe where the item says its payload starts, or a parents row's payload doesn't start inside it
lookupsa lookup file doesn't hold exactly the rows its tables give (--skip-lookups leaves this out)
onchaina sampled item's header, read at its recorded location from Arweave nodes, is not that item, or too few could be verified. --onchain <n> sets the items per band (20 unless set; 0 leaves it out). Items directly in their roots and items in nested bundles are sampled apart, half each, so rare nested items are always read

The set is refused outright, with no checks run, if its bands overlap or leave a hole: that is not one index. --from and --to check only the bands inside those heights. A band that its own publisher's newer band supersedes is left out, as a subscriber leaves it out, and listed under superseded.

Every row is read in file order, so memory stays flat. The lookup check sorts and joins, and spills to the system temp directory: --spill-max caps it at 128 GB unless set. Measured on a 2-core, 10 GB host, the whole ardrive-l2 index of 23 bands and 127.7 million items, with 50 items per band read from Arweave nodes, took 1 hour 51 minutes; its largest band (69 million items) takes 66 minutes alone and spills about 65 GB. The on-chain sample is seconds per band.

Reading from Arweave Nodes

index-l2-verify, and index-band-verify --arweave-nodes, read root transactions from Arweave nodes and prove every byte, so the check trusts no gateway, the publisher's included:

  1. The root's id is bound to its data. Its header's signature must hash to its id, and the signature must verify, since a transaction signs its data_root and data_size. For the few valid transactions arweave-js can't verify, at least two nodes must serve the same data_root and data_size instead; the result says which (bySignature, byAgreement).
  2. Its place in the weave comes from /tx/{id}/offset, whose size must be the signed data_size.
  3. Every chunk is proven. Its data_path must validate against the signed data_root under the consensus rules for its weave offset, the chunk must hash to the path's leaf, and it is placed by the bounds the proof gives. A node whose chunk fails is passed over for the next. A wrong offset can't pass wrong bytes: the chunks it leads to would not prove against the root's data_root.

Each sampled header comes out as one of:

OutcomeMeansCounts
okthe bytes at the recorded location are a data item header whose signature hashes to the item's idfor the band
wrongsomething else is there, or the range runs past the rootagainst the band: its offsets are wrong
unreadableevery node that answered said 404 for the root, or for the chunk holding the header: the data is gone network-wide (unseeded or purged)neither way; another sample is taken in its place
errorsnothing usable came back: nodes busy or failing, a chunk no node could prove, no node reachable, a root that can't be boundagainst the pass ratio; rerun

A 404 counts only when every node that answered said 404. One busy node among them, or one whose chunk failed its proof, makes it an error instead: that node may hold the data, and an unprovable chunk is the node's fault, not an absence.

The default pool is the nodes a gateway reads chunks from. Most of them hold a partition of the weave and answer 404 for most offsets; that is normal. Name your own with --arweave-nodes http://a:1984,http://b:1984. With --gateway-url instead, roots are read through that gateway's /raw: faster, but that gateway is trusted.

What index-l1-verify Checks

index-l1-verify has two views of the same index. --core-db is the operator's: does this gateway's own database hold the chain? --bands-dir is the consumer's: a directory of bands, checked with no gateway and no database, so anyone deciding whether to install someone else's bands can check them before importing a byte. Given both, it checks core.db. It is read-only, so the gateway can stay up. Both views run the same judge over the same chain rules, and index-l1-import runs that judge too before it writes.

What Binds What

An index agreeing with itself proves nothing: a fabricated chain built by Arweave's rules passes every self-check. Three things tie it to the real chain:

  • The anchor (--anchor-from). The block at the top of the range is fetched (/block/height/N) from every source given. At least --anchor-min distinct machines (default 2, and never fewer) must serve it, and names that resolve to a shared address count as one machine. Each header's identity hash is recomputed from its own fields, for every era from the 2.0 fork; every source that answered must give the same hash; and the index's row must equal the header field by field, with its transaction ids in block order. A header the command cannot recompute exactly is a failure, never a pass. Below the 2.0 fork no header recomputes, so a range ending at 422,249 is anchored at the fork block, whose previous_block binds it, and an l1-4 range ending at a pinned height (99,999, 199,999, 299,999 or 399,999) is anchored by its witnessed folds reaching the pins there (anchor.pinned: true, no header fetched). So pre-2.0 bands verify and import with no --allow-unanchored.
  • The hash_list_merkle recurrence. From the 2.0 fork, each block's hash_list_merkle folds the hash, weave size and tx_root of every block below it, so an anchored top binds those three for every post-2.0 block in the range. The links then bind each previous_block, the weave each block_size, and a recomputed tx_root each block's transactions' data sizes and data roots. Not their ids: tx_root drops them.
  • The sample. Nothing above binds transaction ids, their order, any other transaction field, or any other block field. Checking every one would mean fetching every header or every signature, so the command draws a keyed uniform sample from the --anchor-from sources: the headers of --sample-blocks blocks (default 1000), recomputed and compared field by field from the 2.0 fork, and below it attested: the header --anchor-min distinct machines serve alike, the sources' word rather than a proof (counted as blocksAttested, and what binds a pre-2.0 block's transaction order); --sample-txs transactions per band (default 1000), pre-2.0 included, each fetched and checked against its signature over the fields the index holds; and --sample-format1 format-1 transactions with data per band (default 200) drawn from the 2.0 fork, which catches a format-2 transaction relabelled format 1, witnessed or not. --sample-blocks 0 with sources, or no format-1 draw over such transactions (--sample-format1 0, or no --anchor-from), fails the run unless --allow-unsampled. sample.detects says what a pass means: the share of forged rows that would have been caught with 95% probability. A forgery confined to fewer rows may pass. --seed repeats a run.

RSA signatures are verified exactly as Arweave verifies them, not by OpenSSL, which is stricter and rejects some real mined transactions. A recomputed header or a verifying signature decides on one source's word. A refutation on a source's word needs --anchor-min distinct machines saying so: a single lying source makes a draw unreadable, never a failure. More than 20% of any one kind of draw (blocks, transactions, format-1) unreadable in any one band fails the sample as unreadable.

What two agreeing sources mean depends on who they are. Two ar.io gateways both route /block/ to their operators' chosen Arweave nodes, so their agreement is those choices agreeing, not two independent nodes. A source that redirects is not followed. The result names each source and the address it was counted as.

Witness Files (l1-4)

Two kinds of block can't be bound from a band's tables alone, because the chain committed to something no header carries:

  • A block holding a format-1 transaction with data. Its tx_root leaf is the root of that transaction's data, and a format-1 header carries no data root.
  • A block below the 2.0 fork (height 422,250). Its identity hash covers the full wallet list, which no header carries, so it can't be recomputed. Arweave's 2.0 fork bound these blocks another way, through the 2.0 seed of hash_list_merkle.

From layout l1-4 a band carries what is missing, as two witness files:

FileOne row perGives
witness_v1_data_roots.parquetformat-1 transaction with data (none from the 2.9.6 fork)its v1 data root, as Arweave computes it
witness_fork_2_0.parquetheight below 422,250 (only in a band holding such heights)the block's transition tx_root, the 2.0 seed fold through it, and its "2.0 hash"

A witness is an untrusted value: whoever wrote it needs no trust, because the verifier recomputes everything it can from the band and checks the rest against hashes the chain fixes. A v1 data root is put into its block's recomputed tx_root, which must then equal the header's (below the 2.0 fork, the witnessed transition tx_root). Each transition tx_root is recomputed from the block's transactions. Each fold is recomputed from the one below and must reach block 422,250's hash_list_merkle, or a fold pinned in ar-io-node at heights 99,999, 199,999, 299,999, 399,999 and 422,249, recomputed by a test from Arweave's own 2.0 checkpoint. That binds each pre-2.0 block's hash, weave size and tx_root to the chain. Each block's 2.0 hash is recomputed from the band's own fields and transaction ids, and their fold must reach the pinned folds of Arweave's genesis_data/hash_list_1_0, the list Arweave checks every pre-2.0 header it syncs against. That binds the rest of a pre-2.0 header and its transaction ids to Arweave's checkpoint, not to a hash the chain's own blocks reach, and the result says so. A pre-2.0 block is bound only when both folds reach their pins.

A wrong witness fails the check (tx_root, hash_list_merkle, witnesses or hash_list_1_0); it never falls back to unbound. An l1-4 band holds every witness, and a block of it left unbound fails witnesses, whatever --allow-* flag is given. A band whose witnesses its publisher could not have or confirm in full is published as l1-3 instead.

Witnesses are not part of a band's id. A publisher adds them to a published l1-3 band in place: only band.json and the new files change, the band keeps its id, and a subscriber that holds it fetches only those. Every witness is checked against the chain before the band is published, by the same code every verifier runs, and again by every index-l1-verify and import that reads it. The full format is in the publication protocol.

What It Cannot Bind (unbound)

It fails closed. What it has no way to bind is reported in unbound, by class, and fails the run unless you accept that class with its flag. A whole set of l1-4 bands needs none of them:

ClassBlocksAccept with
pre20blocks below the 2.0 fork that no witnessed fold binds: every one in a core.db, or in a band without witnesses (l1-3)--allow-unbound
txRootFormat1blocks holding a format-1 transaction with data whose v1 data root no witness gives: every such post-2.0 block in a core.db or an l1-3 band (none from height 2,000,000). Every field of every transaction in such a block is bound only by the sample; index-l1-audit checks a sample of them by fetching their data--allow-unverified-format1
incompleteblocks the index holds only part of: a link whose transaction it lacks, or a transaction whose owner's key it lacks. A publisher's tip band can be like this--allow-incomplete

--allow-unbound and --allow-unverified-format1 accept such blocks of a core.db or of bands without witnesses, never an l1-4 band's. Without --anchor-from, nothing outside the index vouches for it: the result says anchored: false, and the run fails unless --allow-unanchored. Every flag that accepted something is echoed in accepted, so a pass that rests on one says so; read unbound and assurance and accept a class only if a pass on those terms is enough for your use.

The Checks

Check
identityBands: each directory is named l1-h<from>-<to>-<tag>-<digest> in canonical decimals, its heights are its band.json's, and its digest is its tables'; with --publication, its tag is the publisher's
coverageBands: they cover every height asked for, each band whole
publicationWith --publication: every band is listed in the signed publication, every file with the size and SHA-256 signed, and every band it lists in the range is here
filesBands: every entry is a readable band holding exactly its layout's files
schemaBands: every Parquet file has exactly its layout's columns, in order, with its types
tablesBands: each table's rows are in the table's order, inside the band's heights, and digest to what band.json says
lookupsBands of l1-3 and l1-4: each lookup file is in key order and holds exactly the rows its tables give and its band.json describes
contiguous, linkedEvery height once; each previous_block is the hash below
hash_list_merkleThe recurrence in each era's form; the 2.0 seed against the fold through 422,249; a witnessed fold against each pinned fold it reaches
block_size, weave_accountingEach block grew the weave by its block_size, and (below the offset where Arweave began padding data to a chunk) by its transactions' data_size
tx_rootRecomputed from each post-2.0 block's transactions, with the 2.5 padding and the witnessed v1 data roots; below the 2.0 fork, the transition tx_root from each block's transactions against its witness
witnessesBands of l1-4: each witness file in its order, digesting to what band.json says, every row inside the band and well formed; each witnessed fold the fold of the block below and the block; no format-1 transaction with a data_root column set
hash_list_1_0Bands of l1-4: each pre-2.0 block's 2.0 hash recomputes from its fields, ids and transition tx_root to its witness, and their fold reaches the pinned fold of Arweave's hash_list_1_0
sample_blocks, sample_format1--sample-blocks 0 with sources, or a range anchored by pinned folds with no sample at all; format-1 transactions with data from the 2.0 fork and none drawn. Each fails unless --allow-unsampled
nullsEvery key and commitment column is set: a NULL is a failure, never read as zero. Also format 1 or 2, and no format 2 below the 2.0 fork
position, tx_countEach transaction sits at the link of its height and index, links run 0 to n-1, and tx_count is their number
tag_count, owners, walletsTags match tag_count and run 0 to n-1; every transaction has an owner; every wallet key hashes to its address
rowsNo transaction or link at a height without a block, and none outside the index's blocks
anchor, sample, unboundAs above

--from and --to set the heights to check (default: everything held). With --bands-dir, the directory is listed explicitly, never globbed: a folder without a readable band.json (a retired copy, or a stray directory) is ignored and listed in ignored, and two installed generations of one band with the same tables are read once. Bands only partly inside --from/--to are not checked and fail coverage. A set that overlaps or leaves a hole, or a --bands-dir that is missing or cannot be read, is refused outright. --publication, with --publisher-key or --registry-url, binds the bands to the publication their publisher signed; without it the result says publisherBound: false.

The lookup check spills to the system temp directory (through tools/ar-io-node, data/tmp/index-verify on the node's data disk). The spill is capped at --spill-max (default 128GB) and at 80% of the space free there, and never goes into the last --spill-reserve-gb (default 100) of it. With no room under that, the run is refused before it starts, unless --skip-lookups.

Every option and the full result are in the CLI reference.

When a Check Fails

FoundDo
identity, files or tables failure in a band you installedthe copy is damaged or not what was signed: remove it and let the index-swarm sidecar fetch it again; if it recurs, tell the publisher
the same in a band you publishedrebuild it (index-export --once --range <from>-<to> into a staging directory), check the rebuild, and publish it in its place with index-export --once --resettle --range <from>-<to>
locations, parents, or onchain wrongthe builder placed items wrongly, or its source holds a wrong row: report it to the publisher with the band id and the failures; the publisher repairs the source, rebuilds and re-settles the band
many unreadablenot a fault of the index: every node said 404, so the data is gone network-wide. Seeding the missing chunks again, where the data still exists, makes them readable
many errorsrerun; the nodes were busy. If a chunk keeps failing its proof, the nodes serving it are at fault, not the index
lookupsthe lookups are derived: rebuilding the band rewrites them
index-l1-verify: unboundthe index holds blocks nothing binds (see What It Cannot Bind). For l1-3 bands, the fix is their l1-4 versions, which a publisher on Release 87 adds in place. Until then, read unbound and assurance, and pass the class's --allow-* flag only if a pass on those terms is enough
index-l1-verify: witnesses or hash_list_1_0an l1-4 band's witnesses are missing or do not reproduce what the chain or Arweave's checkpoint fixed. No --allow-* flag accepts that: as for tables, remove an installed copy and let the sidecar fetch it again, and tell the publisher if it recurs
index-l1-verify: anchorread anchor.problems for why it did not bind and anchor.mismatches for the fields where the index differs from the chain's header. Give sources that resolve to at least two distinct machines
index-l1-verify: sample unreadabletoo many draws went unanswered in a band. That is not evidence against the index: rerun

A sample mismatch on reward_pool between heights 585,633 and 633,719 is a known fault of indexes built before Release 86, not of the chain: before the 2.4 fork, headers write reward_pool as a bare JSON number, and from 585,633 it passes 2^53, so the block importer, parsing it as a JavaScript number, stored an odd value rounded to an even neighbour. Gateways that indexed those heights themselves, or imported L1 bands built from them, hold the rounded values. From Release 86 block headers are parsed with long integers kept exact.

A band's id is a digest of its heights, selection and rows, so a publisher, or anyone with the same source data, can rebuild a band and compare ids: equal ids are equal rows. A source that has changed since (items backfilled, offsets repaired) gives a new id, which is what re-settling publishes; compare against the source as of the same time, or diff the rows.

For Agents

Run the command, read stdout (or stderr on exit 1) as JSON, and act on ok and each check's failures. unreadable is never a fault of the index, and errors are never a verdict on it either way, so rerun before acting on them. Never treat a passing sample as proof of every row: the row-by-row checks are what cover every row. For index-l1-verify, also read anchored, unbound, accepted and assurance: a pass that rests on an accepted gap lists it in accepted.

How is this guide?