abx.
Protocol

On-chain storage

The reader representation stores a field's content on-chain and serves it through a reader contract, so tokenURI can return real bytes with no server involved. This page opens up the reference reader — how it stores content, compresses it, and stitches multi-chunk content back together on read.

The reference reader: AbxChunkStore

AbxChunkStore is the reader every ABX project shares. One instance per chain, deployed once, ownerless — the CLI deploys it automatically the first time a chain needs it. A reader field's value is abi.encode(store, manifest); manifest is the pointer, and store.read(manifest) returns the fully assembled, decompressed content.

The store never interprets content — bytes in, bytes out. Compression and chunk boundaries are decided off-chain, by the CLI, before anything is written; the store only stitches and, where flagged, decompresses.

SSTORE2: bytes as bytecode

SSTORE2 stores a blob by deploying it as the runtime code of a tiny, immutable contract: writing costs the EVM's code-deposit gas (~200 gas/byte) rather than SSTORE's per-slot cost, and reading is EXTCODECOPY, a cheap view call. EIP-170 caps a contract's code at 24,576 bytes, so one SSTORE2 write is one chunk, bounded there.

ABX chunks at 22,000 bytes (the SDK's DEFAULT_CHUNK_SIZE), conservatively under that ceiling so a chunk that happens to grow slightly (an incompressible piece under FastLZ) still fits.

Chunk-stitching: content bigger than one write

Content over one chunk is split, each piece written as its own SSTORE2 data contract, and tied together by a manifest: an ordered list of {pointer, compressed}. Reading replays the list — AbxChunkStore.read:

function read(address pointer) external view returns (bytes memory) {
    Chunk[] memory chunks = abi.decode(SSTORE2.read(pointer), (Chunk[]));
    bytes memory out;
    for (uint256 i; i < chunks.length; ++i) {
        bytes memory part = SSTORE2.read(chunks[i].pointer);
        if (chunks[i].compressed) part = LibZip.flzDecompress(part);
        out = bytes.concat(out, part);
    }
    return out;
}

Two staging paths, chosen automatically from the byte count:

Content sizePathTransactions
Fits one transaction's gas budgetwriteContent(datas, compressed)1, atomic
LargerwriteChunk (batched via multicall) for every chunk, then writeManifestone per batch, plus 1

Both paths are ownerless writes to the shared store — the project owner only signs the set-field (or the deploy that bakes the reader value in) that references the finished manifest, never the chunk-staging transactions themselves.

Compression: two mechanisms, two guarantees

--compress <none|fastlz|gzip> on deploy --onchain-image, deploy-series, and set-field --file picks between two independent schemes:

FlagWhat it compressesRepresentationWho decompresses
nonenothingreader
fastlzeach chunk, independently, only kept if it actually shrinksreaderthe reader, on-chain, at read time
gzipthe whole content, before it's ever chunkedreader-gzipthe resolver, off-chain

FastLZ is chunk-local because Solady ships an on-chain decoder for it (LibZip.flzDecompress), so the reader can decode it during read() and the field stays on-chain-renderable. gzip has no on-chain decoder, so a gzip-compressed field can never be read by a contract, only by an off-chain resolver — the representation tag says so explicitly, and an on-chain metadata renderer can't serve it at all. inline-gzip is the same trade-off for content small enough to skip the reader path — a single field value, no chunking.

FastLZ pays off on already-text-like bytes — SVG, JSON, HTML/JS shrink meaningfully. PNG and JPEG are already entropy-coded and gain close to nothing from it.

Cost, and when on-chain is the right call

There are two costs, and the one people plan for is not the one that binds.

Writing is SSTORE2's code deposit, ~200 gas/byte, paid once. Past about 24 KB per file off-chain storage (Arweave, IPFS, S3) is simply cheaper per byte, and past about 256 KB across the whole project the write bill is large enough that the CLI says so up front. On-chain's edge past those sizes is self-resolution and permanence, never price.

Reading is the constraint that decides whether anyone can see the token. An on-chain renderer reassembles the whole document on every tokenURI call, and that cost is superlinear — EVM memory expansion carries a quadratic term, so the rate itself climbs with size. There is no honest flat "gas per KB": across the 10–100 KB range that decides most projects it measures roughly 360,000–405,000 gas per KB, and past that it keeps rising (about 460,000/KB at 187 KB, about 510,000/KB at 256 KB). Compressing with fastlz shrinks what you store; it does not shrink what the renderer has to build.

On-chain contenttokenURI gasPer KB
3 KB1,123,327374,000
10 KB3,588,993359,000
23 KB8,346,220363,000
40 KB14,740,366369,000
50 KB18,759,333375,000
75 KB29,137,215388,000
90 KB35,868,124399,000
100 KB40,254,159403,000
128 KB53,559,735418,000
187 KB86,021,071460,000
256 KB131,269,134513,000

Measured with forge against OneOfOneImage + AbxMetadataRenderer, as the callee's execution gas for one tokenURI call — what a node serving eth_call charges. Content is staged the way --onchain-image stages it (the reader representation, 22,000-byte chunks). The 3 KB row costs more per KB than the 10 KB one because the JSON wrapper is a fixed cost the content has not yet dwarfed.

inline and reader agree within about 1% up to 75 KB — the cost is the renderer's string building, not the storage mechanism — and diverge above it (reader about 2% dearer at 128 KB, 5% at 187 KB, 10% at 256 KB), because the chunk store's read loop concatenates chunk by chunk and is quadratic in chunk count. So the choice between them is a write-cost decision, not a read-cost one, right up to the sizes where nothing reads the token anyway.

Three different limits, all called "the gas limit"

This is the single most common way to get on-chain sizing wrong, and on one endpoint the three span three orders of magnitude. Measured 2026-08-24:

eth_call capeth_estimateGas capblock gasLimit
Base Sepoliasepolia.base.org600,000,00016,777,2161,200,000,000
Base Sepolia — publicnode · drpc50,000,00016,777,2161,200,000,000
Sepolia — publicnode50,000,000–2,000,000,00016,777,21660,000,000
Base mainnet400,000,000
Ethereum mainnet60,000,000
  • eth_call cap bounds an off-chain readtokenURI, read(pointer), anything a marketplace or indexer does. It is a per-provider config (geth's --rpc.gascap, default 50,000,000) and it is the number that decides whether a big on-chain token displays. It is not related to the block limit, and it is commonly far above it in the direction people assume it can't be.
  • eth_estimateGas cap bounds what can be estimated, and therefore in practice what can be sent. Measured at 16,777,216 (2²⁴) on every endpoint tried, independent of the other two — which is why DEFAULT_TX_GAS_BUDGET sits at 8M and must not be raised toward a block limit.
  • block gasLimit bounds a contract reading another contract inside a transaction. Nothing to do with an off-chain read. Base Sepolia's is 1.2 billion, so a contract there can read roughly 1,100 KB of on-chain content in a transaction.

Two caveats that apply to every number above. They are third-party configuration, so they move — Sepolia's publicnode endpoint measured 50M one hour and 2,000M the next, because pooled endpoints rotate between backends. And measuring yours tells you nothing about a marketplace's. So the readable envelope is:

  • ≲ 117 KB (~50M gas) — reads on every endpoint measured. 50M is geth's --rpc.gascap default and also the measured cap of publicnode and drpc on both supported chains, so treat it as the floor below which reach is not a question.
  • Above that — reach depends on whose endpoint is asking, so the toolkit measures yours rather than guessing. deploy --onchain-image and set-field --file probe the configured RPC's real eth_call allowance (via a state-override call that costs nothing and sends nothing) and report it next to the content's estimated read: "your RPC allows 600M, so it reads for you — up to ~729 KB; a 50M-capped provider stops at ~117 KB and will show a revert."
  • No size is refused, at any point. The toolkit used to refuse past 100 KB, reasoning from geth's 50M default minus a margin because hosted providers "commonly cap lower". Measured 2026-08-24 they do not — 50M is the floor, and sepolia.base.org, the default endpoint of the default chain, serves 600,000,000 (~729 KB). The refusal therefore sat below every endpoint that could be found, blocking content all of them could read. A read too large for one node is an RPC-capability problem; the toolkit's job is to state the cost before the bytes are written, not to decide the trade.

The gate is per token, not per project: each token's tokenURI assembles only its own content, so a 300-piece collection of 5 KB works reads perfectly well while a single 256 KB work does not.

Nothing on-chain enforces any of this. A read happens off-chain, and a read too large for one node is an RPC-capability problem, not a reason to make a contract revert forever — bricking is worse than needing a capable endpoint. The honest place to intervene is the toolkit, before the bytes are written, which is where the warning and the refusal live.

Reach is fixable; the bytes are permanent either way

The read cost decides whether a token resolves in one eth_call, and it is easy to mistake that for whether the content is reachable at all. It is not, because a resolver can stand in front of it.

abx deploy-resolver (or a hosted one) reads on-chain content with its own RPC — inline, inline-gzip, reader and reader-gzip all resolve through the same path the on-chain renderer uses — and serves the result as ordinary HTTP. A marketplace then fetches a URL and never makes the large call at all. Point tokenURI at it with abx set-renderer <addr> --off, which switches URI resolution to the stored pointer while leaving every byte exactly where it is.

So the two questions are separable, and worth keeping separate when advising:

  • Storage — where the bytes live, and whether they outlive you. On-chain is the strongest answer available, at any size.
  • Serving — how a viewer gets them today. Self-resolving tokenURI is the purest form and has a reach ceiling; a resolver has no ceiling and is a component someone has to run.

Choosing on-chain storage does not commit you to self-resolving forever, and a token whose read is too large for a given endpoint is not a lost token — it is a token that wants a reader in front of it.

A field renderer, which computes bytes at read time instead of storing them, sidesteps the storage question but not the render one: a computed SVG is cheap because it is small, not because it is computed.

Reads intended for off-chain view calls

Any tokenURI over a few KB of on-chain content is an off-chain view call by design. No ABX contract calls another's tokenURI, so a read too big for one eth_call costs a client convenience, never on-chain correctness.

For code projects, the canonical generator exposes the same document piecewise, so a client past an RPC cap assembles it itself rather than giving up: document, tokenDataJson, dependencyTag, abxJs, gunzipScript, and registryScriptChunk — see Code projects.

Reading and writing, end to end

Metadata, Field renderers, Interfaces

On this page