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, orBytes. - Authorization: who may set the value.
Artist(the contract owner),TokenOwner, or a specificAddress, 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 itslockAfterto a timestamp in the past: every subsequent write revertsParamLockExpired, 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
Selectoption, 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-schemarefuses 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_paramsblock — a collector-written value is metadata anyone readingtokenURIsees, not only render input. This is deliberate: a value under aTokenOwnerorAddressleg 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 notEach 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.binPassing 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.