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 size | Path | Transactions |
|---|---|---|
| Fits one transaction's gas budget | writeContent(datas, compressed) | 1, atomic |
| Larger | writeChunk (batched via multicall) for every chunk, then writeManifest | one 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:
| Flag | What it compresses | Representation | Who decompresses |
|---|---|---|---|
none | nothing | reader | — |
fastlz | each chunk, independently, only kept if it actually shrinks | reader | the reader, on-chain, at read time |
gzip | the whole content, before it's ever chunked | reader-gzip | the 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 content | tokenURI gas | Per KB |
|---|---|---|
| 3 KB | 1,123,327 | 374,000 |
| 10 KB | 3,588,993 | 359,000 |
| 23 KB | 8,346,220 | 363,000 |
| 40 KB | 14,740,366 | 369,000 |
| 50 KB | 18,759,333 | 375,000 |
| 75 KB | 29,137,215 | 388,000 |
| 90 KB | 35,868,124 | 399,000 |
| 100 KB | 40,254,159 | 403,000 |
| 128 KB | 53,559,735 | 418,000 |
| 187 KB | 86,021,071 | 460,000 |
| 256 KB | 131,269,134 | 513,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 cap | eth_estimateGas cap | block gasLimit | |
|---|---|---|---|
Base Sepolia — sepolia.base.org | 600,000,000 | 16,777,216 | 1,200,000,000 |
| Base Sepolia — publicnode · drpc | 50,000,000 | 16,777,216 | 1,200,000,000 |
| Sepolia — publicnode | 50,000,000–2,000,000,000 | 16,777,216 | 60,000,000 |
| Base mainnet | — | — | 400,000,000 |
| Ethereum mainnet | — | — | 60,000,000 |
eth_callcap bounds an off-chain read —tokenURI,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_estimateGascap 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 whyDEFAULT_TX_GAS_BUDGETsits at 8M and must not be raised toward a block limit.- block
gasLimitbounds 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.gascapdefault 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-imageandset-field --fileprobe the configured RPC's realeth_callallowance (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
tokenURIis 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.