abx.
Protocol

Parameters

Parameters are named values attached to a token or to the whole contract. They are a project's general configuration layer: a program reads them, metadata is derived from them, and effects re-run when they change.

Parameters live in the contract and are read from the contract. The store enumerates its own keys, so any RPC can list a token's complete configuration and read each value — no indexer, no resolver, no metadata document in between. That store is the canonical surface, which is why parameters are deliberately not copied into the served token JSON: it would be a second serialization of state that is already enumerable, inside the one document marketplaces need to stay cheap to fetch. A code project's program receives every parameter as tokenData; traits meant for marketplace display go in attributes, computed from the same values.

Scopes

A parameter is set at one of two scopes:

  • Contract scope: one value for the whole project.
  • Token scope: a value for a single token.

When both scopes set the same key, the token value wins. This is how a project sets a default for every token and overrides it for specific ones.

Values

A parameter maps a key to bytes. A flag, valueIsHash, says how to read the value. When it is false, the value is the literal. When it is true, the value is the keccak256 of a larger value stored as one on-chain blob, up to about 24 KB, read back with tokenParamData or contractParamData. A value larger than that is held off-chain and referenced by a locator parameter.

Setting and clearing are distinct: on the owner's raw setter an empty value is a valid value, and removing a key is a separate operation, so a reader can tell "set to empty" from "unset." (The governed path is stricter — a zero-length String/Bytes write is refused outright; see Writing a value.) Each change records updatedBy, the authorized address that made it, as on-chain provenance. The events are TokenParamConfigured, ContractParamConfigured, and their ...Cleared counterparts.

The lock is per-key, and it comes from the schema — not from the metadata locks. The metadata locks cover fields, the URI configuration, the script, and the dependency set; parameters are none of those, so an ungoverned parameter has no lock at all — the owner's raw setter keeps writing it after every one of those locks is engaged, and the renderer keeps projecting the result into tokenURI.

A parameter that carries a schema is different, and the difference is a real guarantee rather than a near-miss. Once its lockAfter deadline has passed:

  • every governed write reverts ParamLockExpired, permanently;
  • the schema itself is welded too — a further setParamSchema on that key also reverts ParamLockExpired, so the type, the authorization, the bounds, and a Select's option table can never be edited again. Without that, lockAfter would be a much weaker promise than it reads as: a locked Select param's options could be swapped afterwards, and the value a collector bought — index 3, "Ember" — would render as "Frost" with no parameter write at all;
  • the owner's raw setter was already closed on any schema'd key (SchemaGoverned), so there is no ungoverned back door to the same key.

And the deadline is monotonic: a later setParamSchema may only move it earlier, never later and never back to open, or it reverts ParamLockNotExtendable. A deadline a project can push back is a deadline collectors cannot price, so the chain refuses it.

Two things are still downstream of a welded parameter, and both are readable: a augmentHook overrides values at read time (until lockParamHooks() freezes the hook set), and an ungoverned key is only ever as frozen as the owner chooses. See what a project owner can do for how all of this reads to a buyer. The upside of the same design is real: a parameter is how a token stays responsive to its collector and to live chain state long after the metadata is frozen.

Configurable parameters

A parameter becomes governed by attaching an on-chain schema. Because the schema is on-chain, any frontend can build the configuration UI directly from the chain, with no off-chain definition. A per-key schema declares:

  • Type: one of Bool, Select, Uint256Range, Int256Range, DecimalRange, HexColor, Timestamp, String, or Bytes.
  • Authorization: who may set the value. Creator (the contract owner), TokenOwner, or a specific Address, and OR-combinations of these.
  • Constraints: the select options, or the min and max for a range.
  • Lock: an optional timestamp after which neither the value nor this schema can change again.

This is the model Art Blocks calls PostParams, with the same type set plus Bytes. A schema is set with ParamSchemaConfigured.

The TokenOwner authorization honors delegate.xyz. A wallet the token owner has delegated to can configure the parameter as the owner, so a token held in a vault is configured from a hot wallet. It is on by default, set at deploy, and fail-closed: a missing or unexpected registry means "not delegated," never a revert. The registry is announced with DelegateRegistrySet.

On an ERC-1155 edition, TokenOwner means any holder of the id, and params are shared state. A parameter on an edition belongs to the id, not to a copy — so on a 1,000-copy edition a TokenOwner schema lets the holder of any single copy write the value every holder reads, on a last-writer-wins basis. That is usually what you want for a shared work and rarely what you want for "collectors name their own copy", which needs one id per copy.

It compounds with lockAfter, because the deadline binds everyone including the creator: whoever writes last before the lock bites has set the value permanently, and no correction is possible afterwards from any party. If you want a collector-writable parameter and a lock on an edition, be sure the last write before the deadline is one you would accept forever.

The Address leg is a plain msg.sender comparison with no restriction to externally-owned accounts, so a contract may hold it. That is how open or multi-party participation is built today: a controller contract holds the leg, applies its own rules, and forwards the write — the same router pattern the toolkit recommends for minting. There is no Anyone leg; "any wallet may write" is not expressible from a schema alone.

A schema may be attached at any time, including long after deploy — setParamSchema is owner-gated, not deploy-time-only, so a project's parameter surface is never frozen by the deploy transaction (abx set-schema <address> --schema key:Type:Auth). It is an upsert: calling it for a key that already has a schema replaces that schema. Three consequences worth designing around:

  • Governance is permanent, but a parameter can be retired. There is no way to delete a schema — once a key is governed the raw setter refuses it (SchemaGoverned) for the life of the contract. To decommission one, set its lockAfter to a timestamp in the past: every subsequent write reverts ParamLockExpired, permanently, and so does every subsequent schema write on that key. That is the supported "remove", and it is one call — abx retire-param <address> <key> in the toolkit. It cannot be walked back: lockAfter only ever moves earlier (ParamLockNotExtendable).
  • Replacing a schema does not re-validate stored values. Narrowing a range, removing a Select option, or changing a type leaves any already-written value in place, unchanged and now outside what its own schema allows. Renderers and resolvers read the stored value, so plan a schema change around the values that already exist. (abx set-schema refuses such a change unless you pass --force, and a replacement rewrites every field — restate anything you mean to keep. An existing lock is the exception you cannot get wrong by omission: dropping or extending it reverts ParamLockNotExtendable rather than quietly re-opening the key.)
  • A stored token-scope value cannot be erased. Retiring a key stops future writes; it does not remove the current value, which keeps appearing in token data and stays publicly readable from the contract. This is deliberate: a value under a TokenOwner or Address leg was written by a collector, and the creator should not be able to delete someone else's contribution to their own token. A key that was never written has no stored value, so it contributes nothing — though its schema still lists it (below).
  • A lock does not weld a contract-scope default. clearContractParam deliberately sits outside the schema guard: it is the recovery path out of a contract-scope value poisoned before the schema existed, which would otherwise be frozen for every token with no way back. The consequence is that an owner may set a collection-wide default, attach and expire a schema on that key, sell tokens that inherit the default, and then clear it — changing every token that never wrote its own value. Clearing can only remove a fallback: it cannot forge a value, bypass an auth rule, or touch a token-scope value already written, and the schema keeps governing every token-scope write exactly as before. To make a collection-wide value permanent, write it per token through the governed path rather than leaving it as an inherited default.

Writing a value

Governed writes go through two entry points on the token, chosen by type. Both run the schema's authorization check and the configureHook before anything persists.

// Bool · Select · Uint256Range · Int256Range · DecimalRange · HexColor · Timestamp
function configureTokenParam(uint256 tokenId, bytes32 key, bytes32 value) external;

// String · Bytes — one blob; the evented value is keccak256(data)
function configureTokenParamData(uint256 tokenId, bytes32 key, bytes calldata data) external;

Keys are readable-ASCII bytes32, and that is enforced on-chain rather than merely conventional: every write path rejects an empty key, a key with an interior zero byte, and any byte outside 0x200x7E, all with InvalidParamKey. (Two of those are integrity rules, not tidiness. A key with an interior zero renders as its truncated prefix while comparing unequal as a 32-byte word, so "seed\0…" would slip past every reserved-key guard and then serialize as "seed"; a key with a high byte passes through JSON escaping verbatim and makes every tokenURI in the collection invalid UTF-8.) Do not keccak256 the key string. Other SDK name encoders hash; this one does not. In Solidity the key is bytes32("plantedAt") (left-padded ASCII), never keccak256("plantedAt") — the hashed form reverts NoParamSchema(). Using the wrong entry point for the type reverts WrongValuePath, so a String cannot be squeezed through the literal path. These are the signatures a browser signing surface encodes; abx configure-param <addr> <id> <key> <value> does the typed encoding for you.

A zero-length `String`/`Bytes` write is refused — design your empty state around it

configureTokenParamData (and its contract-scope twin) reverts InvalidParamValue on data.length == 0, before the configure hook runs, whatever the authorization says. So there is no way to write "nothing" into a governed String or Bytes key.

This is load-bearing rather than incidental: the configure hook's dataLength argument is the discriminator that tells a hook whether it is seeing a scalar write or a blob write, and it can only mean that because zero is impossible on the blob path (see the configure hook's blob arguments).

The consequence lands on any list-like or optional payload — "unequip everything", "clear my inscription", an empty selection — where the natural encoding of empty is zero bytes and the write reverts. Two shapes that work:

  • A sentinel byte. Encode empty as a one-byte marker your renderer recognizes (0x00), so "empty" is a value rather than an absence. Cheapest, and it keeps the whole state in one key.
  • A scalar companion. Keep a Bool or Uint256Range key that says how many entries are live, and let the renderer ignore the blob when it reads zero. More keys, but the empty state costs a scalar write (~17k gas) instead of a blob.

A key that was never written has no stored value at all, and reads as unset — that is a different state from "written, then emptied", and it is the only genuinely empty one.

What a write costs

A minter or controller contract that calls configureTokenParam* inside its own purchase() has to budget gas for it, so here are measured numbers rather than a rule of thumb. These are the inner call costs (contracts/test/PostParamGas.t.sol, re-run it with -vv to reproduce); add the 21,000 transaction base and your calldata on top.

WriteGas
Scalar, cold — first write to a key on this token~152,800
Scalar, warm — overwrite~16,900
String/Bytes blob, cold, 32-byte payload~208,900
String/Bytes blob, warm, 32-byte payload~57,100
String/Bytes blob, warm, 512-byte payload~154,900
Marginal cost per payload byte~203
Two cold scalars + one cold 64-byte blob, one transaction~426,400

The dominant term is cold vs warm, not type: a first-ever write to a key pays a fresh storage slot and an append to that scope's enumeration list; an overwrite pays neither. So a mint that configures three parameters on a brand-new token is paying three cold writes, and the same three writes a week later cost roughly a tenth of that. Budget for the cold case. A blob write additionally deploys one SSTORE2 contract per write — an "overwrite" is a fresh deploy, which is why a warm blob never gets as cheap as a warm scalar.

Enumeration

The parameter store enumerates its own keys. Each scope keeps a key list maintained inside the contract's write paths, so nothing off-chain has to track what a project has set:

function tokenParamKeys(uint256 tokenId) external view returns (bytes32[] memory);
function contractParamKeys() external view returns (bytes32[] memory);
function paramSchemaKeys() external view returns (bytes32[] memory);   // declared schemas, written or not

Each has a paged form for reading past an RPC's eth_call limit — tokenParamKeysPaged(tokenId, start, count), contractParamKeysPaged(start, count), paramSchemaKeysPaged(start, count) — returning the page and the total. Order is insertion order and unspecified; canonical serialization sorts, so nothing may depend on it. The reserved seed key never appears in a key list: it is a token-data coordinate, read directly.

Two things follow. paramSchemaKeys() lists every declared key, including ones nobody has written, so a frontend builds its configuration UI from the chain alone — which keys exist included. And because the set keys are on-chain, a reader with nothing but an RPC endpoint can list them and fetch each value with tokenParam / contractParam (or tokenParamData / contractParamData for a data-backed one): a parameter is visible with no resolver and no indexer in the picture. The canonical generator does the same read to build a token's tokenData.

What it costs, and how many. Index maintenance rides the write. Setting a key for the first time in a scope adds about 45,000 gas (about 67,000 for the very first key in that scope); re-setting a key already listed, about 400; clearing one, 1,000–2,000. A seeded mint pays 41 gas more than before, because seed is never indexed. There is no on-chain cap on key count. The read side is the real bound — the generator decodes every enumerated parameter into tokenData — so the design envelope is about 64 parameters per project, which keeps that read comfortably inside a default RPC's eth_call limits. abx verify reports the count and warns past it.

On an ERC-1155 edition, a parameter is shared by every holder

Parameters are keyed by token id, and on an edition an id's copies are all the same id. So a TokenOwner schema on an edition means any holder of that id, writing one value that serves every co-holder, last writer wins. A "name your copy" parameter does not name a copy; it renames the work for all of them. Per-collector configuration needs one id per copy. Communal or aggregate state — a counter, a shared mood, "has ever been held by" — is what the shared shape is genuinely good for.

There is also no on-chain size budget on a holder-writable String or Bytes parameter. The contract accepts any non-empty valid value, one SSTORE2 blob per key, so a single holder can fill every declared data key and push that id's generator document and tokenURI past what common RPC gas limits will serve — for every co-holder at once, and permanently if a lockAfter then bites. This is a deliberate protocol choice: on-chain byte accounting would cost every project gas to police a configuration almost nobody should use, so the constraint lives in the schema a project declares and in the tooling that warns about it (abx deploy-code --copies flags a holder-writable schema at the point of decision). Keep holder-writable keys on an edition to scalar types unless a large holder-authored payload is the point — and if it is, put an Address leg on a controller contract that applies your own size policy before forwarding the write.

Hooks

Three project-level hooks transform on-chain state into results at points in the parameter lifecycle. A hook operates on on-chain state only, so its behavior is deterministic and any resolver reproduces it. In this version a hook is set as an address, and its logic is the implementation's.

  • configureHook: runs when a parameter is written, before the value persists. Reverting vetoes the write. This is the validator slot — the place to reject a value the type system cannot describe, such as an implausible claimed score.
  • augmentHook: runs at read time, computing results from the current on-chain state as metadata or token data is assembled. It can add keys or override stored ones, stores nothing, and emits no event. Because its output is computed live rather than stored, it reflects the latest state and can hold large derived values.
  • transferHook: runs on an ownership change. A mint is a transfer from the zero address, so it also runs at mint. This is the mechanism for output that depends on the owner: on transfer it persists or derives a parameter and emits TokenParamConfigured. It goes beyond a write-time-only model such as PostParams, which has no transfer-triggered behavior. It is a veto: if it reverts, the transfer fails — and because a mint is a transfer from the zero address, a reverting hook stops minting for that project too, including through the shared minter. (The interface covers a burn the same way, as a transfer to the zero address; no shipped ABX token exposes a burn.)

The calling conventions, which a hook implements and the token expects:

// configureHook — revert to veto the write
interface IAbxConfigureHook {
    function onParamConfigured(
        uint256 tokenId,
        bytes32 key,
        bytes32 value,          // the literal on the scalar path; keccak256(data) on the blob path
        address updatedBy,
        uint256 dataLength,     // 0 on the scalar path; data.length on the blob path (never 0 there)
        address dataBlobAddress // address(0) on the scalar path; the live SSTORE2 pointer otherwise
    ) external;
}

// transferHook — revert to veto the transfer; a mint is `from == address(0)`, a burn is `to == address(0)`
interface IAbxTransferHook {
    function onTokenTransfer(
        uint256 tokenId,   // the 721 token, or the 1155 id whose balance moved
        address from,
        address to,
        address operator,  // who initiated the move: the holder, an approved operator, or a minter
        uint256 amount     // copies moved; always 1 on ERC-721
    ) external;
}

// augmentHook — read-time only; `value` is the final canonical string, augment wins per key
interface IAbxAugmentHook {
    struct AugmentedParam { bytes32 key; string value; }
    function augmentTokenParams(address token, uint256 tokenId)
        external view returns (AugmentedParam[] memory);
}

For a String/Bytes write the value passed to onParamConfigured is keccak256(data), matching the evented value. The augment hook is never called by the token itself — only by renderers and resolvers assembling token data — and it cannot set the reserved coordinates.

The configure hook's blob arguments

A scalar write passes its whole value in value. A String/Bytes write cannot — the value is a blob — so value carries only its hash, and the last two arguments are what make that shape inspectable:

  • dataLength is the blob's byte length, and it is never 0 on the blob path (an empty value is refused before the hook runs). So dataLength == 0 reliably means "this was a scalar write".
  • dataBlobAddress is the Solady SSTORE2 pointer holding this write's bytes — already deployed and readable when your hook runs. The guaranteed invariant is keccak256(SSTORE2.read(dataBlobAddress)) == value. It is the same pointer layout tokenParamData returns, so you read it the same way. Don't CALL it: SSTORE2 prefixes a STOP, so it is data wearing a contract's clothes.

The bytes are not forwarded, on purpose. A blob can be nearly a full contract's worth of data, and putting it in calldata would make every configure call pay for it whether the hook looks or not. A hook enforcing a size ceiling reads dataLength and never touches the blob; a hook that needs the content calls SSTORE2.read and pays for exactly what it asked for.

Two consequences worth knowing before you write one:

  • tokenParamData(tokenId, key) still returns the OLD value inside the hook — that is what "before the value persists" means. The incoming bytes exist only at dataBlobAddress until the hook returns. This is useful: you can compare incoming against outgoing and veto a regression.
  • A veto happens after the blob was written. It has to: an address is only meaningful once the contract behind it exists. On the accepted path this costs nothing extra (the blob is written once either way) — only a rejected write wastes, it wastes only the rejecting writer's own gas, and a dry run surfaces the rejection for free first.

This is also the answer to "can a collector bloat a shared edition parameter?" On a multi-copy edition, params belong to the id, so a holder-writable String/Bytes key is shared. ABX does not impose a protocol-wide byte ceiling — that would cap every project to police a configuration almost nobody wants. Instead the constraint belongs where the decision does: your configure hook sees the length, and can refuse.

operator and amount exist because an ERC-1155 hook cannot otherwise tell a real transfer from a no-op. Solady permits safeTransferFrom(from, to, id, 0, "") from any caller — zero is not greater than a zero balance — so before these arguments existed, a stranger holding no copy could fire the lifecycle for any id, and a hook that stored transfer-derived state would rewrite parameters shared by every real holder. The token now refuses to notify at all on a zero-amount entry or a self-transfer (from == to), which is what makes a naive hook unspoofable; the two arguments let a hook layer its own policy on top. operator is not redundant with from: on ERC-1155 an approved marketplace moves a holder's copies, and a hook that cares who acted needs to see it.

Think before a transfer hook stores per-id state on an edition

Parameters are per id, and on an edition every holder of that id shares them. A hook that writes "the current owner" is really writing "whoever moved most recently" — and it changes the work for all of them. Aggregate or monotonic state (transfer counts, "has ever been held by") is coherent on an edition; a single-owner notion is not, unless the edition size is 1.

Hooks are set together with setParamHooks(configureHook, augmentHook, transferHook) and announced with HooksConfigured; the zero address disables one. That call is onlyOwner, permanently — there is no permissionless path to it, on any collection, in any state.

The transfer hook is a veto, and that is said out loud

An earlier version of this extension swallowed the transfer hook's revert and promised that the parameter lifecycle could never block a transfer. That promise could not be kept, so it was withdrawn rather than restated: the ERC-721/ERC-1155 receiver acceptance check runs after the hook, so on the safe* variants — which marketplace fills wrap — a hook that is cheap when a wallet estimates gas and expensive when the transfer lands starves the work that comes after it, with or without a gas cap. Swallowing bought a guarantee that was false in exactly the cases people use, while making an honest hook's failure invisible.

So the hook is called plainly. Its revert is the project's own contract reaching the project's own collectors, and it is visible instead of silent. Note that it differs from the transfer validator, which never sees a mint or a burn precisely so a policy contract cannot brick issuance — the distinction being that a validator is usually a third party's and a hook is always the creator's.

Two things follow, and both are readable from chain before you buy:

  • A project with no transfer hook has no such power. paramHooks() returns three addresses; a zero in the third slot means nothing runs on transfer.
  • lockParamHooks() freezes all three addresses forever. Owner-only, one-way, and announced with ParamHooksFrozen; paramHooksLocked() reads it back. After it, every setParamHooks reverts ParamHooksLocked — so a project can prove it will never arm a transfer veto, instead of asking to be trusted. A hook already set stays live and keeps its veto: freezing the set is not disarming what is in it.

No stranger can disarm a hook, and that is on purpose

The transfer validator has a release valve: once owner() is address(0), anyone may suspend it. A param hook has no such valve. setParamHooks is onlyOwner forever, including on an ownerless collection, and a frozen set stays frozen for everyone.

The two look alike and are not. A validator is usually a third party's policy contract and is purely restrictive — everything it can do is refuse a transfer, so disarming it can only ever permit more. A param transfer hook is the creator's own contract, and it is frequently load-bearing: work that responds to its owner, provenance the render reads. Switching one off is not a safety valve, it is a way to break a project that was working — and it would fire on exactly the collections that were most finished, since ownerless usually means settled.

So this is a trust assumption, stated rather than engineered around. A creator can wire a transfer hook that bricks their own collection, and renouncing afterwards makes that permanent, with no path back for anyone. They are not incentivised to: it destroys the thing they were paid for, and the same creator has blunter ways to ruin their own project. What the protocol owes you instead is the read. paramHooks() returns the three addresses — a zero in the third slot means nothing runs on transfer — and paramHooksLocked() tells you whether that answer can still change. Both are eth_calls you can make before you buy.

abx lock-param-hooks <address>   # permanent; freezes configure + augment + transfer
abx state <address>              # prints the three hooks and whether they are frozen

An unlocked hook set is listed as a live owner power; a locked one is listed as a guarantee.

Canonical decode

Each type decodes to a canonical string, so a generator injects a consistent value: HexColor becomes #rrggbb, Timestamp becomes a Unix time, DecimalRange is fixed-point with ten decimals, Bytes becomes base64, and String is UTF-8. Parameters reach a program through token data, and a parameter change is a trigger for effects.

This is the read side, not what you type

These are the forms a program receives. They are not the encodings you write when setting a value. A Bytes parameter is delivered to your script as base64, but it is written as 0x-prefixed hex or from a file:

abx configure-param <address> <tokenId> grid 0x00112233…   # hex
abx configure-param <address> <tokenId> grid --file ./grid.bin

Passing base64 (or any bare string) to a Bytes key is refused, because storing those characters as bytes is indistinguishable from meaning them literally — and an on-chain renderer reading ASCII where it expected bytes draws garbage with nothing failing anywhere. Use String when the characters are the value.

One decode, one place: the canonical rules are what the generator applies when it builds a token's tokenData, and what the off-chain resolver applies when it serves the same values — so the two surfaces cannot disagree about what a stored value means.

Seeds

A mint-time seed is a parameter with a dedicated source, so it is assigned once and settled. A seed schema is the opt-in that lets an authorized party set the seed to a value they choose — it is a governed configureTokenParam(tokenId, "seed", value) write, where the caller supplies the value. It is not a fresh draw: the seed source is never called again, and nothing is randomized. It is the one schema that cannot be declared after the fact: once any seed in the collection exists, the key is closed, so whether a seed can be chosen — and by whom, within what bounds — is fixed and readable (paramSchema("seed")) before anyone buys. The canonical source is pseudorandom, not lottery-grade. See seeds.

On this page