abx.
Reference

SDK

@artblocks/abx-sdk is the low-level TypeScript library above the contracts — the toolkit's logic hub. It is ESM-only, built on viem, and requires Node 22.5 or newer. The abx CLI is a shell over this same library: every command it runs is one of the functions below, wired to a terminal UX.

CLI or SDK?

Use the CLI for one-off and interactive work: launching a project, inspecting one, operating it by hand, and letting an agent drive it in a session. Use the SDK for anything programmatic — a server, a mint endpoint, a scheduled job, anything that runs the same operation repeatedly without a human watching.

That line matters more than it sounds, because each CLI invocation is a fresh Node process. One team measured ~4.3 s per invocation on a laptop and ~22.6 s on a small host (0.1 CPU), nearly all of it Node boot plus module load — so a mint that made ten CLI calls took about four and a half minutes. In-process SDK calls on the same host were ~1.3 s each. They also folded eight transactions into one with batchOps, which the CLI does not do for post-deploy configuration.

So: if you are shelling out to abx from application code and parsing its output, you are on the wrong surface — the SDK exposes the same operations as functions, returns values instead of prose, and gives you unsigned transactions you can batch, simulate, and sign however you like. Reach for the CLI when a person (or an agent in a session) is the one deciding what happens next.

Install

npm install @artblocks/abx-sdk viem
import { makePublicClient, ensureFactory, deployOneOfOne } from '@artblocks/abx-sdk';
import { oneOfOneImageAbi } from '@artblocks/abx-sdk/abi';

The package README has a complete deploy → upload → mint → read walkthrough. This page is organized by job.

The env contract

The SDK never loads .env. It reads a handful of variables from process.env as a fallback, only when you don't pass the equivalent option explicitly — and it does that reading through a tiny readEnv that no-ops outside Node, so the package's main entry stays import-safe in a browser bundle (asserted by an esbuild smoke test on every change).

Loading .env is the HOST's job

A host — the CLI's main(), the effects runner's main(), your own server's startup — calls loadDotEnv() once, from the @artblocks/abx-sdk/node subpath, before anything reads process.env. That subpath is the only place .env parsing lives (it needs node:fs/node:path); never import it from code that might run in a browser.

ABX_DEPLOYER_PK is the only signing-key variable the SDK reads (via makeWalletClient/ envSigningKey) — earlier-alpha names (SEPOLIA_FUNDED_PK, SEPOLIA_WALLET_PK) are no longer consulted; a caller with one of those set gets a MissingSigningKeyError that names the rename. Every resolver that touches env (makePublicClient, makeWalletClient, chain/RPC resolution) takes an explicit override that wins outright, so a host — or a browser, which has no process.env at all — never has to go through the environment.

The send-injection model

Every write — a deploy, a mint, an owner op — returns a PreparedTx: unsigned, carrying only what's needed to sign (to/data/value/chainId) plus a human layer (summary/fields) for a sign page. The SDK never signs or broadcasts. Every deploy*/ensure*/stage* function that writes takes a SendTx — the one injection point:

type SendTx = (tx: PreparedTx) => Promise<TransactionReceipt>;
  • makeHotSender({ wallet, account, publicClient, onEvent? }) — for an env/hot key. It pins the nonce once at construction and increments it locally per send (a distributed RPC can briefly serve a stale pending count right after a mined tx, so re-fetching risks reusing the just-spent nonce); waits for a just-created target's code to be visible before estimating gas for every send after the first in a sequence; treats an eth_estimateGas that comes back below a tx's provable gas floor (PreparedTx.gasFloor — EVM code deposit, exactly 200 gas/byte) as proof the node hasn't seen the deploy block yet, retries, and refuses rather than sending an under-funded transaction (GasEstimateBelowFloorError); and throws a typed TxRevertedError — carrying the tx hash — instead of reporting a reverted transaction as "confirmed."
  • Bring your own — a browser wallet, a Safe/multisig flow, a queue you drain later. Anything that signs PreparedTx.data and returns a TransactionReceipt is a valid SendTx.

runPrepared(txs, send) sends a list of PreparedTx in order and returns their receipts.

Deploy a project

Trust anchors — the clone factories and shared singletons — resolve-or-bootstrap: every ensure* takes (publicClient, send, opts), checks the configured address (manifest → env → override, via deployments.ts's resolvers), verifies it's both present and the current version, and reports progress through onEvent (never printed by the SDK itself) rather than through a return value alone.

  • ensureFactory, ensureSeriesFactory, ensureSeriesCodeFactory — the three clone-factory trust anchors (1/1, Series, code projects). opts: { chainId, override?, allowBootstrap?, onEvent? } — a missing/stale anchor with allowBootstrap unset throws a typed AnchorUnavailableError (anchor, detail: 'no-code' | 'stale-version' | 'not-configured') rather than guessing; only allowBootstrap: true deploys a fresh one, so trust doesn't silently fragment across duplicate "canonical" factories.
  • ensureRenderer(publicClient, send, { chainId, override?, onEvent? }), ensureSeedSource(publicClient, send, { chainId, onEvent? }) — the shared, stateless singletons; both are CREATE2-canonical (predictRenderer()/predictSeedSource() from create2.ts), so they self-heal to the deterministic address — and, failing that, just deploy — rather than refuse.
  • ensureChunkStore(publicClient, send, { chainId, override?, onEvent? }) — the on-chain multi-chunk content store. Resolve this before staging any on-chain content; an incapable store otherwise fails deep inside a sequence, after earlier transactions already landed.

Project deploys:

  • deployOneOfOne(send, publicClient, { factory, params, salt }) — deploy a 1/1 as an immutable clone at a deterministic address. params is a OneOfOneInitParams.
  • deploySeries(send, publicClient, { factory, params, salt }) — deploy a multi-token Series. params is a SeriesInitParams; a SeriesCodeInitParams (adds seedSource + disableTokenOwnerDelegation) drives a code/generative project's clone the same way, then prepareCodeSetup (below) wires its script/schema/dependency legs in one multicall.
  • predictClone(publicClient, { factory, salt }) — the clone's address, a pure function of (factory, salt) — compute it before deploying so URIs can be baked in.

Salts: saltFor(deployer, entropy?) reserves the predicted address to deployer (front-run-proof — the factory's salt guard checks the leading 20 bytes against msg.sender); permissionlessSalt(entropy?) has no guard, for a shared canonical address anyone may deploy to; saltGuard(salt) reads a salt's reserved address back out (the zero address ⇒ permissionless).

Infrastructure deploys, run once per chain (the CLI's doctor/demo call these lazily; you rarely need them directly): deployFactory, deploySeriesFactory, deploySeriesCodeFactory, deployRenderer, deployFixedPriceMinter, deploySeedSource, deployChunkStore, and their ERC-1155 edition twins deployOneOfOneEditionFactory, deployEditionFactory, deployEditionCodeFactory, deployFixedPriceMinter1155 (see Editions) — every one is deployX(send, publicClient, ...).

ERC-721C (opt-in creator-token enforcement): pass a non-zero transferValidator in InitParams at deploy — permanent, never added later. readCreatorTokenStatus(client, contract) reads { enrolled, validator }; resolveRecommendedTransferValidator(chainId) names the recommended registry per chain; prepareSetTransferValidator({ contract, validator, chainId }) re-points or suspends (zeroAddress) enforcement on an already-enrolled token.

Sell it

The shared, ownerless, multi-tenant fixed-price minter — one deployment per chain, ownerless config keyed by token address, authority deferring to each token's own owner:

  • prepareConfigureSale({ minter, token, paymentToken?, price, allocation, chainId }) — set/update a sale. paymentToken omitted or zeroAddress ⇒ priced in ETH; allocation is the minter's own sell-through budget, distinct from the token's maxInvocations cap. Enabling a sale is separate from granting mint rights — also prepareSetMinter(token, minter).
  • preparePurchase({ minter, token, to?, value?, chainId }) — buy one token; to omitted mints to the signer, value is the ETH to attach.
  • readSaleConfig(client, minter, token) — a project's live sale state: { configured, paymentToken, price, allocation, sold }.
  • prepareSetMinter, prepareSetMaxInvocations (monotonic — never raises), prepareSetPaused, prepareSetPrimaryPayee — the Series-side config a sale needs.
  • mintedTokenIds(client, contract, txHash) — the token ids a mint actually created, decoded from the receipt's Transfer(from=0x0) logs (or TransferSingle/TransferBatch on an edition); authoritative even under concurrent mints elsewhere.

An edition's sale surface is its sibling minter, AbxFixedPriceMinter1155 — see Editions below.

Operate it

Every owner op is a prepareX(args): PreparedTx — metadata fields and locks (prepareSetTokenField, prepareLockTokenField, prepareSetContractField, prepareLockContractField), URI config (prepareSetTokenURIBase/Override/Renderer, prepareLockTokenURI, and the contract-scope twins), royalty (prepareSetRoyalty), ownership (prepareTransferOwnership), transfers (prepareTransfer), and — for a code project — dependencies (prepareSetDependency, prepareRemoveLastDependency, prepareSetDependencyRegistry, prepareLockDependencies), PostParam schemas (prepareSetParamSchema/prepareRetireParam/readParamSchema), contract-scope params (prepareSetContractParam/Data), token-scope PostParams (prepareConfigureTokenParam/Data), and hooks (prepareSetParamHooks).

Batching: prepareMulticall({ ops, summary? }) folds several same-target ops into one Multicallable.multicall transaction — exactly equivalent to sending each individually from the same signer (no extra authority; every subcall re-runs its own onlyOwner/lock checks) and all-or-nothing. batchOps(ops) does this automatically: same-target runs of ≥2 collapse, contract-creation and CREATE2-proxy txs never merge, order is preserved.

import { batchOps, prepareSetTokenField, prepareMint } from '@artblocks/abx-sdk';

const txs = batchOps([mintTx, coverTx, ...artifactTxs]); // same-target runs collapse into one

On-chain content staging — putting a field's bytes behind the shared chunk store/reader: stageFieldContent({ content, compress, field, send, publicClient, chainId, store?, onEvent? }) is the one call that resolves (or reuses) a chunk store, chunks + writes the content, and returns the field's (representation, value). planStagedContent(content, compress) is the pure, no-chain half — the same chunk count and transaction shape a dry-run preview reports. gzip compression is your job (it's a Node-only node:zlib transform, and this module has to stay browser-bundle-safe) — gzip the bytes yourself and pass the result as content; fastlz compression happens inside planChunks. exceedsOnchainSoftLimit(bytesLen) flags content past the point where on-chain storage stops being cost-sane (~24 KB/file, ~256 KB/project — off-chain is far cheaper past that; on-chain's remaining edge is self-resolution, not cost).

Editions (ERC-1155)

An edition is the ERC-1155 twin of a 1/1, Series, or code project — copies of an artwork, not unique tokens — with its own trust anchors, deploy functions, and ops, addressed the same way as their ERC-721 counterparts:

  • AnchorsensureOneOfOneEditionFactory, ensureEditionFactory, ensureEditionCodeFactory: the three edition clone-factory trust anchors, each probing ERC-165 0xd9b67a26 (ERC-1155) plus the IAbxEditionMint capability before trusting a configured address, exactly like the ERC-721 anchors' own version checks.
  • DeploydeployOneOfOneEdition, deployEdition, prepareDeployEditionCode mirror deployOneOfOne/deploySeries/the code-project setup flow; params takes editionSize (the default per-id copy cap, 0 = open) and a mintAmount of copies at each premint id.
  • Sell and operateprepareEditionMint({ contract, to, tokenId, amount }) (the IAbxEditionMint twin of the sequential mint prep), prepareEditionTransfer (safeTransferFrom), prepareSetMaxSupply (per id, monotonic — never raises), and the sibling minter's prepareConfigureSale1155/preparePurchase1155, keyed (token, id) per the edition minter.
  • FoldreconstructProject folds TransferSingle/TransferBatch into each token's supply and holders (a balance map; owner stays null — an id can have many holders, so there's no single one to report), URI as a log-only re-render ping (the edition twin of MetadataUpdate), and MaxSupplyUpdated into that id's cap. contractType reports '1of1-edition' / 'edition' / 'edition-code', detected the same way as the ERC-721 side: the EditionSupply extension marks the 1155 family, then maxInvocations and the params surface narrow it further.
  • DeploymentsDEPLOYMENTS gains oneOfOneEditionFactory, editionFactory, editionCodeFactory, and fixedPriceMinter1155, resolved the same override → env → manifest way as every other anchor (ABX_ONE_OF_ONE_EDITION_FACTORY, ABX_EDITION_FACTORY, ABX_EDITION_CODE_FACTORY, ABX_FIXED_PRICE_MINTER_1155).

Everything else — reconstructIncremental, discoverDeployBlock, stageFieldContent, onChainUriReport, the storage integration, and the service client — works identically on an edition; only the token-shaped calls above have a distinct ERC-1155 form.

Read state

  • reconstructProject(client, { address, fromBlock, toBlock?, factory?, readUriDocuments? }) — rebuild a project's full state by folding the event spine (idempotent, last-writer-wins) and reading string values at the head. No indexer, no provider — delete the projection, run this, get byte-identical state back. Returns a ProjectState. Head reads split by cost class: name/ symbol and the trust/URI-lane facts are cheap and always read. contractURI/tokenURI are the composed metadata documents — on the on-chain lane each one can be hundreds of KB, and neither has a settled value (a renderer can change the composed document with no log at all) — so they're read only when readUriDocuments: true (default false); left off, ProjectState.contractURI and every TokenState.tokenURI come back null.
  • reconstructIncremental(client, prior, opts?) — resume from a prior reconstruction's checkpoint, fetching only the blocks since; the result is identical to a full replay.
  • discoverDeployBlock(client, address, toBlock?) — binary-search a contract's deployment block via eth_getCode (independent of any eth_getLogs range cap) — the scan floor a fresh reconstruction needs so it doesn't default to a full-chain sweep from genesis.
  • listTokens(client, address, opts?) — every token's owner, mint-time seed, and params, read straight from the contract (no event scan) — for a generative collection this is "what did the seeds actually deal," answerable before any indexing has happened.
  • buildTokenData(client, state, token, { augment? }) — the canonical tokenData object an effect's render commits to: reserved coordinates + params (contract ∪ token, token wins), decoded per schema. augment: false gives the settled (event-derived) form an effect's inputsHash addresses; augment: true (default) adds the live augment hook's entries on top.
  • readParamSchema, readCreatorTokenStatus, readSaleConfig — the typed reads behind the corresponding prepare*/owner-op writes above.
  • onChainUriReport(...) (onchain-uri.ts) — whether a project's tokenURI/contractURI resolve fully on-chain, and what's missing if not.
  • analyzeScript(source, declaredDeps?) (inspect.ts) — static analysis of an abx.js-shaped program: trait feasibility, on-chain reproducibility, and a recommended custody lane. recommendLane(analysis) turns that into one line.

A ProjectState includes the identity and trust fields (address, chainId, abxVersion, factory, isCanonical), the collection identity (name, symbol, owner, royalty), the URI resolution config, the on-chain metadata fields, the Series and code-project state where present (maxInvocations, minter, paused, primaryPayee, contractParams, paramSchemas, paramHooks, delegateRegistry, seedSource, script, dependencies), the enabled extensions, and per-token state. On an edition, each token additionally carries supply (that id's total copies) and holders (a balance map); owner reads null there — see Editions.

Helpers over a reconstructed ProjectState (token.ts): findToken(state, tokenId), fieldOf(fields, fieldName), isOnChain(representation), inlineText/inlineBytes (decode an inline value), verifyAgainstHash(bytes, field) (check content against a keccak256/sha256 field), and the reader/renderer field codecs (encodeReader/decodeReader, encodeFieldRenderer/decodeFieldRenderer).

Embed in a browser app

The package's main entry has no Node-only imports — it's bundled under esbuild's platform: 'browser' and asserted clean of any node:* resolution on every change (a mint page the CLI's abx mint-page scaffolds is a real example: a plain React app that imports the SDK directly). In a browser:

  • Pass rpcUrls: [...] (or a single rpcUrl) explicitly to makePublicClient — there's no process.env to fall back to, and none is assumed.
  • Sign with a connected wallet: build your own SendTx around it rather than makeHotSender, which expects a local WalletClient backed by a key.
  • Never import @artblocks/abx-sdk/node from browser-reachable code — see The env contract.

Talk to a resolver

AbxServiceClient is the SDK's one HTTP surface, speaking the provider-neutral control plane pinned by Remote services. A client is bound to an endpoint plus an injected bearer token — never to a brand; the reference CLI, the effects runner, and a hosted agent all drive a self-hosted node or a managed provider through this same class.

import { AbxServiceClient, isAccepted } from '@artblocks/abx-sdk';

const client = new AbxServiceClient({ baseUrl: 'https://your-resolver', token: '…' });
const result = await client.registerProject({ chainId: 84532, address: '0x…' });
if (isAccepted(result)) {
  await client.awaitIndexed(84532, '0x…', { onProgress: (s) => console.log(s.status) });
}
  • descriptor() — the public GET /.well-known/abx-service, safe to fetch before trusting a provider.
  • registerProject(body) — register/index a project; answers 200 (done, with counts) or 202 (accepted, still catching up) — narrow with isAccepted. A timed-out register checks whether the registration landed before assuming it didn't, rather than retrying blind into a second full replay.
  • awaitIndexed(chainId, address, opts?) — poll until a project reaches a terminal lifecycle state (live/failed); stale keeps polling (lagging, not caught up).
  • projectStatus, listProjects, removeProject, reindexProject — the rest of the control plane.
  • publishEffectArtifact, reportEffectStatus — the render-artifact registry a producer publishes outputs through.
  • classifyIndexError(err) — map a catch-up failure to its wire form: a closed IndexErrorClass (rpc_unavailable · rpc_rate_limited · not_abx_contract · internal) plus a fixed, credential-free message — never an interpolated upstream string (a keyed RPC URL is a credential).
  • AbxServiceError — thrown on a non-2xx response or an unreachable service after retries; carries status/code/class. indexProgress(status) turns a backfilling status into a done/total/percent triple (deliberately null outside backfilling — see the doc comment for why a live project's ratio would read as broken).

Storage integration

Byte custody lives in the separate @artblocks/abx-storage package, behind one StorageBackend interface (put/get/has, optional locator/putDirectory/putObject/getObject/health) so the indexer and token API never change when the backend does.

  • resolveBackend(opts?) — pick a backend (fs · cloud/s3 · ipfs · arweave) from flags → env → default; every field is optional, so it can build entirely from env.
  • uploadAndLocate(backend, name, content) — store one file and return the best available public locator, preferring a form that preserves the filename (so the declared MIME type survives when the locator is later attached) — a path-addressed host's <publicBase>/<name>, else a one-entry directory upload, else a bare content-addressed locator with a warning flag.
  • probeStorageBackend(opts, checkOpts?) — a real read/write against the resolved config, not just "is it configured": cloud PUTs through the signed API and GETs back over the public base with a plain unsigned fetch (the only check that catches an R2/S3 endpoint-vs-public-base mismatch); arweave adds an identity+balance read; fs/ipfs reuse their existing health().
  • decideImageContentLane({ onChain, isSvg, backendId }) — inline-SVG > a direct-URL backend's hosted image > keccak custody, purely from facts you already have.
  • validateRenderStorageCombo(combo) — the one source of truth for two known-bad render/storage pairings (a mutable per-token URL can't be a content-addressed gateway; publishing to a resolver on another machine needs a backend that can hand back a public URL) — the same check a deploy-code dry-run and its real-run refusal both consult, so the two can't drift apart.
  • locatorStatus(locator, opts?) / awaitLocatorReady(locator, opts?) — see below.

Uploads are accepted before they are retrievable

An Arweave upload through Turbo returns a locator immediately, but the gateway does not serve those bytes yet. Observed propagation has ranged from ~80 seconds to roughly 7–10 minutes. Nothing in the locator distinguishes "accepted" from "retrievable", so the obvious implementation — upload during a mint and write the locator straight into the token — produces a token that renders broken for the first minutes of its life, exactly when its new owner is looking at it.

locatorStatus(locator, opts?) answers "is this retrievable right now," probing the gateway your config would actually use plus a couple of well-known alternates (so "your gateway hasn't caught up" reads differently from "the network doesn't have it"). awaitLocatorReady(locator, opts?) polls that until it's ready or a deadline passes — the loop a caller would otherwise hand-roll. Design around the gap rather than retrying harder:

  • Do not upload on the display path during a mint. Upload when content is imported or prepared, so propagation happens long before anyone mints.
  • Treat "stored" and "playable" as two states. Check retrievability yourself before you present something as ready.
  • Budget minutes, not seconds, in any retry ladder.

IPFS via a pinning service is generally faster to first byte but has the same shape of gap.

Deployments

DEPLOYMENTS holds the canonical addresses per chain: the ERC-721 and ERC-1155 factories, both fixed-price minters, the renderer, chunk store, seed source, and generator (see Editions for the edition-specific keys). The factory addresses are the trust anchor: a contract is canonical when one of the chain's factories deployed it, which reconstructProject reports as isCanonical. detectCanonicalFactory probes all six anchors — the three ERC-721 ones and the three ERC-1155 edition ones — and only falls back to the 1/1 anchor when none of them claims the clone, so isCanonical: false means no known trust anchor deployed it (a superseded factory, or a contract deployed outside one) rather than a misconfiguration. Resolver helpers such as resolveFactory, resolveRenderer, and resolveChunkStore follow an override, then environment variable, then manifest order. The alpha scope is testnet-only.

Spine

spine exposes the protocol vocabulary: EXTENSION_ID and EXTENSION_NAMES for the extensions, METADATA_FIELD and METADATA_REPRESENTATION for the metadata tags, PARAM_TYPES and AUTH_OPTIONS for configurable parameters, and the event catalog. It also provides the tag and parameter codecs (encodeTag/decodeTag, encodeScalarParam/decodeScalarParam).

On this page