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. Derived is literal: the served token JSON carries every set value under abx_params, assembled from the chain by the off-chain resolver and the on-chain renderer alike.

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: 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." Each change records updatedBy, the authorized address that made it, as on-chain provenance. The events are TokenParamConfigured, ContractParamConfigured, and their ...Cleared counterparts.

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. Artist (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 the value can no longer change.

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.

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. That is the supported "remove", and it is one call — abx retire-param <address> <key> in the toolkit.
  • 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, including an existing lock.)
  • A stored value cannot be erased. Retiring a key stops future writes; it does not remove the current one, which keeps appearing in token data and in the served abx_params block — a collector-written value is metadata anyone reading tokenURI sees, not only render input. This is deliberate: a value under a TokenOwner or Address leg was written by a collector, and the artist 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 to either surface — though its schema still lists it (below).

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. 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.

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, the on-chain metadata renderer and the canonical generator both emit the complete set: a parameter is visible with no resolver in the picture.

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 — every enumerated parameter is decoded inside tokenURI — so the design envelope is about 64 parameters per project, which keeps tokenURI comfortably inside a default RPC's eth_call limits alongside a 12 KB document. abx verify reports the count and warns past it.

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. Best-effort: reverts are swallowed, because the parameter lifecycle must never block a transfer.

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, address updatedBy) external;
}

// transferHook — reverts are swallowed; a mint is `from == address(0)`, a burn is `to == address(0)`
interface IAbxTransferHook {
    function onTokenTransfer(uint256 tokenId, address from, address to) 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.

Hooks are set together with setParamHooks(configureHook, augmentHook, transferHook) and announced with HooksConfigured; the zero address disables one.

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.

The same decode produces the abx_params values in tokenURI, so token data and metadata cannot disagree. One difference on that surface: a data-backed value whose content runs past 2,048 bytes is emitted as its {"keccak256": "0x…"} commitment rather than inline, so the key set stays complete while tokenURI stays a single eth_call.

Seeds

A mint-time seed is a parameter with a dedicated source, so it is assigned once and settled. See seeds.

On this page