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 viemimport { 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 stalependingcount 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 aneth_estimateGasthat 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 typedTxRevertedError— 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.dataand returns aTransactionReceiptis a validSendTx.
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 withallowBootstrapunset throws a typedAnchorUnavailableError(anchor,detail: 'no-code' | 'stale-version' | 'not-configured') rather than guessing; onlyallowBootstrap: truedeploys 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()fromcreate2.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.paramsis aOneOfOneInitParams.deploySeries(send, publicClient, { factory, params, salt })— deploy a multi-token Series.paramsis aSeriesInitParams; aSeriesCodeInitParams(addsseedSource+disableTokenOwnerDelegation) drives a code/generative project's clone the same way, thenprepareCodeSetup(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.paymentTokenomitted orzeroAddress⇒ priced in ETH;allocationis the minter's own sell-through budget, distinct from the token'smaxInvocationscap. Enabling a sale is separate from granting mint rights — alsoprepareSetMinter(token, minter).preparePurchase({ minter, token, to?, value?, chainId })— buy one token;toomitted mints to the signer,valueis 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'sTransfer(from=0x0)logs (orTransferSingle/TransferBatchon 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 oneOn-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:
- Anchors —
ensureOneOfOneEditionFactory,ensureEditionFactory,ensureEditionCodeFactory: the three edition clone-factory trust anchors, each probing ERC-1650xd9b67a26(ERC-1155) plus theIAbxEditionMintcapability before trusting a configured address, exactly like the ERC-721 anchors' own version checks. - Deploy —
deployOneOfOneEdition,deployEdition,prepareDeployEditionCodemirrordeployOneOfOne/deploySeries/the code-project setup flow;paramstakeseditionSize(the default per-id copy cap,0= open) and amintAmountof copies at each premint id. - Sell and operate —
prepareEditionMint({ contract, to, tokenId, amount })(theIAbxEditionMinttwin of the sequential mint prep),prepareEditionTransfer(safeTransferFrom),prepareSetMaxSupply(per id, monotonic — never raises), and the sibling minter'sprepareConfigureSale1155/preparePurchase1155, keyed(token, id)per the edition minter. - Fold —
reconstructProjectfoldsTransferSingle/TransferBatchinto each token'ssupplyandholders(a balance map;ownerstaysnull— an id can have many holders, so there's no single one to report),URIas a log-only re-render ping (the edition twin ofMetadataUpdate), andMaxSupplyUpdatedinto that id's cap.contractTypereports'1of1-edition'/'edition'/'edition-code', detected the same way as the ERC-721 side: theEditionSupplyextension marks the 1155 family, thenmaxInvocationsand the params surface narrow it further. - Deployments —
DEPLOYMENTSgainsoneOfOneEditionFactory,editionFactory,editionCodeFactory, andfixedPriceMinter1155, 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 aProjectState. Head reads split by cost class:name/symboland the trust/URI-lane facts are cheap and always read.contractURI/tokenURIare 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 whenreadUriDocuments: true(default false); left off,ProjectState.contractURIand everyTokenState.tokenURIcome backnull.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 viaeth_getCode(independent of anyeth_getLogsrange 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 canonicaltokenDataobject an effect's render commits to: reserved coordinates + params (contract ∪ token, token wins), decoded per schema.augment: falsegives the settled (event-derived) form an effect'sinputsHashaddresses;augment: true(default) adds the live augment hook's entries on top.readParamSchema,readCreatorTokenStatus,readSaleConfig— the typed reads behind the correspondingprepare*/owner-op writes above.onChainUriReport(...)(onchain-uri.ts) — whether a project'stokenURI/contractURIresolve fully on-chain, and what's missing if not.analyzeScript(source, declaredDeps?)(inspect.ts) — static analysis of anabx.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 singlerpcUrl) explicitly tomakePublicClient— there's noprocess.envto fall back to, and none is assumed. - Sign with a connected wallet: build your own
SendTxaround it rather thanmakeHotSender, which expects a localWalletClientbacked by a key. - Never import
@artblocks/abx-sdk/nodefrom 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 publicGET /.well-known/abx-service, safe to fetch before trusting a provider.registerProject(body)— register/index a project; answers200(done, with counts) or202(accepted, still catching up) — narrow withisAccepted. 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);stalekeeps 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 closedIndexErrorClass(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; carriesstatus/code/class.indexProgress(status)turns abackfillingstatus into a done/total/percent triple (deliberatelynulloutsidebackfilling— see the doc comment for why aliveproject'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":cloudPUTs 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);arweaveadds an identity+balance read;fs/ipfsreuse their existinghealth().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 adeploy-codedry-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).