abx.
Reference

Contracts

The reference implementation is small by design: a handful of interfaces, storage libraries, logic libraries, and extension mixins, assembled by inheritance into six concrete, deployable token contracts — three strict twin pairs, one per project shape — nothing else accomplishes the whole feature set.

A Series is many unique tokens (ERC-721). An Edition is many copies of a token (ERC-1155). Uniqueness maps to ERC-721, copies map to ERC-1155 — an orthogonal choice from the project shape (one work, many works, or generative/code) and from everything else a project picks.

Extensions are mixins, not a framework

Every opt-in piece of the event spine — Royalty, On-Chain Metadata, Max Invocations, and the rest — is one abstract contract in extensions/<name>/, and it is the whole extension: its private ID/VERSION constants, its _init<Name>(...) setup hook, its events (declared on a paired IAbx<Name> interface), and its own supportsInterface override. Nothing about an extension lives anywhere else, so adding one never touches a shared file. RoyaltyExtension is representative:

abstract contract RoyaltyExtension is AbxBeaconCore, ERC2981, Ownable, IAbxRoyalty {
    bytes32 private constant ID = 0x09e6...; // keccak256("abx.extension.royalty")
    uint16 private constant VERSION = 1;

    function _initRoyaltyExtension(address receiver, uint16 basisPoints) internal {
        _setExtensionVersion(ID, VERSION); // announce via the beacon
        _setRoyalty(receiver, basisPoints);
    }

    function supportsInterface(bytes4 interfaceId)
        public view virtual override(AbxBeaconCore, ERC2981) returns (bool)
    {
        return AbxBeaconCore.supportsInterface(interfaceId) || ERC2981.supportsInterface(interfaceId);
    }
}

A concrete token composes the mixins it needs and explicitly ORs each one's supportsInterface — Solady's leaf implementations don't super-chain, so composition is spelled out, not implicit, in the most-derived contract. ID is private on purpose: two extensions can never collide, even composed in the same token.

One extension branches its ERC-165 answer on state, deliberately: CreatorToken (opt-in ERC-721C) advertises the creator-token ids (0xad0d7f6c / 0xa07d229a) and its beacon version only when the token enrolled at deploy — a permanent, deploy-time choice — so an unenrolled token is indistinguishable from a pre-721C token.

Namespaced storage: no layout to protect

Every concern with real state — the beacon's extension registry, royalty, params, on-chain script, whatever — gets its own ERC-7201 namespace: a library with a Layout struct at a slot computed once, off any inheritance position.

library BeaconStorage {
    struct Layout {
        mapping(bytes32 => uint16) extensionVersion;
    }

    // keccak256(abi.encode(uint256(keccak256("abx.storage.beacon")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 internal constant STORAGE_SLOT = 0xfa76...;

    function layout() internal pure returns (Layout storage l) {
        assembly { l.slot := STORAGE_SLOT }
    }
}

Because the slot is a hash of the library's own name, not a position in an inheritance chain, mixins can be added, removed, or reordered on a concrete token with zero risk of two of them silently overlapping the same storage — "no reliance on inheritance-layout order anywhere in an ABX contract" is a repeated, literal comment across the codebase. It also enables the other half of the library story below: a shared, separately-deployed library can safely write into the calling token's own storage, because it computes the identical namespaced slot the token itself would.

Two kinds of libraries

  • Storage libraries (19: BeaconStorage, SupplyStorage, CollectionMetadataLib, TokenURIStorage, ContractURIStorage, ParamsStorage, ConfigurableParamsStorage, OnChainMetadataStorage, OnChainScriptStorage, DependenciesStorage, MaxInvocationsStorage, ExternalMinterStorage, PrimaryPayeeStorage, PausedStorage, SeedSourceStorage, SeriesMintStorage, TransferValidatorStorage, plus the two the edition lane added — EditionSupplyStorage and Erc1155SupplyStorage) — nothing but a Layout struct and a layout() accessor. If a library has one of these, it holds state; every other library doesn't.
  • Logic libraries — stateless helpers, split by how they run:
    • Inlined (TokenDataLib, DynamicBuffer) — internal functions, compiled straight into the caller, no separate deployment. AbxVersion is a further degenerate case: a single constant, no functions at all.
    • Delegatecalled (AbxMetadataLib — the on-chain metadata field store, linked by all six token types — plus AbxParamsLib, AbxCodeLib, and AbxEditionLib — which all three ERC-1155 edition tokens link, not only EditionCode) — public functions, which Solidity always compiles as calls to a separately-deployed copy of the library. Each copy is CREATE2-deployed through the keyless proxy at a canonical salt, so it has the same address on every chain and a factory that links it keeps a deterministic address too — linking substitutes an address into already-compiled bytecode, and a CREATE2 address does not depend on the deployer's nonce. Params/ConfigurableParams route through AbxParamsLib; OnChainScript/Dependencies route through AbxCodeLib. Both are externalized for the same stated reason: "the spine is identical to an inlined implementation; EIP-170 is why it's a library"SeriesCode composes all four of these extensions at once, and inlining that much logic risks the 24,576-byte contract-size ceiling (the same ceiling that bounds one SSTORE2 chunk). Both also carry their extensions' read views — AbxParamsLib the params key-enumeration and schema views, AbxCodeLib the whole script + dependencies read surface: the mixin shells forward their raw calldata (same signature, same selector) and return the library's return data untouched, so each library's read signatures are part of the tokens' external ABI. Delegatecall preserves the caller's storage context, so the externalized logic still reads and writes the token's own namespaced state — never any of its own.

Since AbxMetadataLib was externalized too (the 2026-08-14 audit remediation), both code types sit under EIP-170 with room to spare — but SeriesCode remains the binding constraint, so measure any addition against forge build --sizes: its implementation, its factory, and the on-chain generator singleton (AbxGenerator) all compile at a separately configured, lower optimizer setting (200 runs, against 1,000,000 everywhere else) purely to shrink further and fit. Every other contract optimizes for cheap runtime gas instead, on the reasoning that a clone, once deployed, runs forever.

Six contracts, three strict twins

The reference implementation ships one contract per (shape, standard) pair:

ShapeERC-721 (unique)ERC-1155 (copies)
One workOneOfOneImageOneOfOneEdition
Many worksSeriesImageEditionImage
Generative / codeSeriesCodeEditionCode

Each column is a strict-superset ladder — a later contract is textually the earlier one's is (...) list with more mixins appended: OneOfOneImage/OneOfOneEdition compose the minimal set that can hold on-chain fields and a royalty; SeriesImage/EditionImage add what a sized, sellable drop needs — a supply cap, a delegatable minter, a payout address, a pause gate; SeriesCode/ EditionCode add what a generative drop needs on top of that — mint-time seeds, governed PostParams, and on-chain code custody. Each row is a twin pair sharing the identical extension surface, because every ABX extension mixin inherits AbxBeaconCore, not the ERC-721 or ERC-1155 base — nothing about an extension assumes a standard.

Every concrete contract's initialize() calls each mixin's _init<Name> in one fixed order, and the doc comment pins the exact resulting event sequence — for example SeriesCode's: AbxDeployed → AbxExtensionVersionSet(royalty) → MaxRoyaltyBpsUpdated → RoyaltyChangedForAll → BurnConfigured → [AbxExtensionVersionSet(creator-token) → TransferValidatorUpdated] → … → AbxExtensionVersionSet(dependencies) → TokenFieldSet* → ContractURIUpdated → Transfer (the bracketed pair appears only when the token enrolls as a creator token at deploy). An indexer author can read the sequence straight off the source instead of inferring it from tests. EditionCode's sequence is the same shape, one rung over: TransferSingle in place of Transfer at the end, everything else identical.

What's shared, unchanged, between a twin pair

  • Every extension above the base itself: RoyaltyExtension, MaxInvocations (row 2+), ExternalMinter, PrimaryPayee, Paused, OnChainMetadata, SeedSourceExtension, ConfigurableParams, OnChainScript, Dependencies (row 3) — composed unchanged onto either base.
  • AbxMetadataRenderer, AbxGenerator, AbxChunkStore, AbxSeedSource — the shared, per-chain singletons — read a token only through its extension interfaces plus ICollectionName.name(), so the exact same deployed renderer serves a 721 token and its 1155 twin: no per-standard renderer, no redeploy for editions.
  • PostParams, except one leg. The TokenOwner auth leg generalizes from "the ownerOf holder" to "any holder with balanceOf(id) > 0" on an edition — params are per-id shared state of the work, and last-writer-wins among holders is the intended semantic — but delegate.xyz delegation does not carry over: an ERC-1155 id has no enumerable single holder to check a delegate against, so an edition's holders always configure directly, never through a vaulted delegate.

What differs

  • Per-id supply. An edition adds EditionSupplytotalSupply(id) / maxSupply(id) / setMaxSupply(id, cap) — the only extension id new to editions (abx.extension.edition-supply); every other extension id above is shared, unchanged, with the 721 side. MaxInvocations keeps its 721 meaning unchanged on the multi-id twins: it still caps the id space (how many distinct works may ever exist), not any one work's copy count.

    --copies N sets a collection-wide default cap for every id (0 = open); setMaxSupply(id, cap) overrides one id, and only ever downward. maxSupply(id) returns the effective cap — but its 0 is overloaded, meaning both "never capped" and "deliberately closed", so the log carries the distinction: DefaultMaxSupplySet at deploy plus a per-id MaxSupplyUpdated where one exists. If you index, read Resolving an edition id's cap; the SDK folds it for you into defaultMaxSupply and each token's maxSupply / maxSupplyOverridden.

  • The metadata-refresh signal. Both twins ping ERC-4906 (MetadataUpdate / BatchMetadataUpdate) and advertise 0x49064906: this repo's IERC4906 is event-only rather than IERC4906 is IERC721, so emitting it on an ERC-1155 is a refresh marker, not an ERC-721 claim, and it lets one shared OnChainMetadata serve both lanes. An edition emits ERC-1155's native URI(string, uint256) per id in addition, where the change is expressible that way — see Event spine.

  • The mint primitive. A 721 twin (past the 1/1) exposes IAbxSequentialMint; every edition exposes IAbxEditionMintmint(to, id, amount), an identified id with a per-mint amount, not a generalization of the sequential primitive. See Minting.

  • The creator-token flavor. CreatorToken (ERC-721C) and CreatorToken1155 (ERC-1155C) share the exact same ERC-165 ids and beacon extension id, but the validator call differs: 721C's is a view with no amount, 1155C's carries the transferred amount and isn't a view — called once per (id, amount) pair in a batch transfer. See Royalty enforcement.

  • The OneOfOneEdition sale-stack asymmetry. OneOfOneImage is one-shot and owner-only, with no sale stack at all. OneOfOneEdition ships ExternalMinter/PrimaryPayee/Paused from day one — a rung OneOfOneImage never climbs — because a priced open or limited edition of a single work is the dominant 1155 product, sold through its own sibling minter, AbxFixedPriceMinter1155.

One implementation, many clones

Each concrete token has one sibling factory (OneOfOneImageFactory, SeriesImageFactory, SeriesCodeFactory, and their edition twins OneOfOneEditionFactory, EditionImageFactory, EditionCodeFactory), and all six are the same shape:

  • The factory deploys one implementation in its own constructor, which immediately calls _disableInitializers() — so the master copy can never be initialized (or hijacked) directly.
  • deploy(params) clones it via EIP-1167 (Solady's LibClone.clone) and calls initialize(params) on the fresh clone in the same transaction.
  • deployDeterministic(params, salt) clones to a predictable address (LibClone.cloneDeterministic), guarded by the salt's leading 20 bytes — all-zero is permissionless, a specific address must match msg.sender — so a reserved address can't be front-run.
  • The factory is ownerless and immutable: no admin key, no upgrade path, nothing to rug. It is the only writer of its own isAbxClone mapping, which is the trust anchor — verifying a contract is canonical means verifying the factory deployed it, not trusting the spoofable AbxDeployed beacon (see Authenticity).

Renderers and readers: composed by address, not by inheritance

AbxMetadataRenderer, AbxChunkStore, AbxGenerator, and AbxSeedSource are a different idiom entirely: stateless, ownerless, shared singletons, deployed once per chain and referenced by address from a field's or a contract's stored value — never inherited into a token. A mixin composes in; a renderer, reader, or seed source composes alongside, staticcalled at read time (Field renderers), called through IAbxOnChainReader (On-chain storage), or called once at mint (Code projects). Keeping them outside the token's inheritance tree is what lets one deployment serve every token on the chain, of any concrete type, and be swapped out — by pointing a new address — without touching the token itself.

Event spine, Metadata, On-chain storage, Parameters, Code projects

On this page