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
SSTORE2's code-deposit cost (~200 gas/byte) makes on-chain storage cost-effective only for small content:
- ≲ 24 KB per file — about one chunk.
- ≲ 256 KB total per project — past this the CLI's dry-run warns loudly on
deploy --onchain-imageandset-field --filealike.
Past these, off-chain storage (Arweave, IPFS, S3) is far cheaper per byte. The reader's actual edge past tiny sizes is self-resolution and permanence, not cost. A field renderer, which computes bytes at read time instead of storing them, sidesteps the size question entirely for generative output.