Batched reads and writes
Multicall on the write side, index pagination on the read side, and the gas ceilings that bound both.
Multicall
Every core store inherits Solady's Multicallable, exposing
multicall(bytes[]) → bytes[], which delegatecalls each inner call back to the
same contract.
Uploading a 50-chunk file would otherwise mean 50 wallet prompts and 50 × 21,000 gas of transaction base cost. Instead:
- One signature, one mempool entry, one receipt.
- Atomic — any inner revert unwinds the whole bundle.
msg.senderis preserved, so per-op authorization behaves identically inside and outside a bundle.
Per-contract only
There is no cross-store router. Flows that span stores — createFile then
publishVersion — are orchestrated client-side by the SDK, which keeps each
store's auth surface narrow and its reasoning local.
The same reason makes a tagged publish two transactions: the CID lands on the ABX collection and the tag on the hook, and no single-contract multicall spans two contracts.
Not payable in practice
The Solady ABI marks multicall payable for compatibility, but every BAMFS
write function is non-payable, so attaching value always reverts.
The gas ceiling is the node's, not the chain's
The SDK packs same-store ops under a per-bundle gas budget so a 500-chunk upload becomes a handful of transactions rather than 500.
That budget defaults to 24,000,000 — but it is then clamped, because
eth_estimateGas caps at 224 (16,777,216) on every Base Sepolia
endpoint tried, including keyed ones. That is a node setting; Base Sepolia's
block gas limit is 1.2 billion. A bundle whose estimate exceeds the cap does not
merely cost more — it fails to estimate at all, so the clamp applies regardless
of which RPC you point at.
Tune with --max-bundle-gas, or --no-bundle to send one transaction per write
when diagnosing a failure.
Pagination
Every list-shaped view has a matching triple:
xCount() → uint256
getXAt(uint256 i) → T
getXRange(uint256 start, uint256 end) → T[]covering version history, tags, owned projects, directory listings, and chunk
hashes. Clients walk arbitrarily large collections at fixed eth_call cost per
page, and the unpaginated views still work.
Indexes, not opaque cursors
Ordering is append-only, so an index is a stable cursor: new entries append above it and never renumber what is below. That is what makes it safe to persist one across sessions.
let cursor: bigint | undefined;
do {
const page = await readPublishPage(client, contracts, { limit: 20, before: cursor });
render(page.records);
cursor = page.nextCursor;
} while (cursor);The one thing an index cursor cannot absorb is removal. Tags can be removed,
so a walk that spans a removeTag may skip or repeat an entry. Version history
and the publish ledger are append-only and have no such case.
Reads have a ceiling too
Every BAMFS read is an eth_call, and resolving a 1 MB file out of contract
storage costs roughly 72M gas. Public endpoints differ by more than an order of
magnitude in what they will execute, so the endpoint choice is load-bearing
rather than cosmetic — see Deployments.
Pagination is what keeps a read under that ceiling: fetch a range, not a collection.
