Custom mechanics without a custom token
The most common wrong turn on this toolkit is reaching for a bespoke token contract. Escrow, equipping, soulbinding, a growth ratchet — each feels like it needs one, and none of them do. A canonical ABX token has two extension points, and between them they have covered every mechanic anyone has built here so far:
- the minter — one contract you authorize to mint (
abx set-minter), which can do anything it likes before it callsmint, including take payment and hold it; - the param hooks — three optional contracts the token calls at write time, transfer time, and read
time (
abx set-param-hooks).
One contract can be both. That is the shape almost every custom mechanic actually is: a controller that holds the money and the rules, wired into a stock, factory-verified clone that never changes.
The payoff is not just less code. A collector holding your token is holding a clone of a contract the factory deployed, byte-identical to every other ABX project of that type — the trust anchor they can check. Your mechanic lives beside that, in a contract they can also read, and the two are separable. A custom token forfeits that, forever, for the life of the collection.
Hooks need a code project, and the choice is permanent
The param hooks live on SeriesCode and EditionCode — what abx deploy-code deploys. A 1/1
(abx deploy) or an image collection (abx deploy-series) has no configurable parameters, therefore no
hooks, and abx set-param-hooks refuses it. The contract type is fixed at deploy and cannot be
retrofitted, so a collection deployed on the image lane has permanently foreclosed every pattern on
this page.
If the work has any rule attached to it — a transfer restriction, a value collectors can set, anything
that reacts to chain state — deploy it with abx deploy-code even when the art is a static image. A
script that draws the image is fine, and --image-renderer covers the Solidity lane. The minter seam is
the exception: abx set-minter works on any Series or edition.
Two other deploy-time-only choices these patterns depend on: --burnable (redemption and
burn-to-combine have nowhere to settle without it) and --copies (721 vs 1155).
Deploy the token first, or predict its address
Every pattern below has the same chicken-and-egg: the controller needs the token's address at construction, and the token's schema needs the controller's address to authorize it. Don't do the five-step dance — break the cycle with a predicted address.
The four extension points, and what each one can veto
| Point | Wired with | Called | Can it veto? |
|---|---|---|---|
| Minter | abx set-minter <token> --minter 0x… | by you, when your contract calls mint(to) | n/a — it is the mint |
| Configure hook | abx set-param-hooks <token> --configure 0x… | before a governed parameter write persists | yes — its revert rejects the write |
| Transfer hook | abx set-param-hooks <token> --transfer 0x… | after every ownership change, mint and burn included | yes — its revert fails the transfer |
| Augment hook | abx set-param-hooks <token> --augment 0x… | at read time, when tokenData is assembled | no — it adds and overrides keys |
The two vetoes are the whole toolbox. "Can't be transferred until X" is a transfer hook that reverts; "this value may only ever go up" is a configure hook that reverts. Everything else is bookkeeping your controller does with its own storage.
A transfer hook is a veto over minting too — a mint is a transfer from 0x0. A hook that reverts
unconditionally freezes issuance as well as trading. And because setParamHooks is owner-only forever,
a buyer's guarantee that you will never arm one is abx lock-param-hooks, not your word.
See hooks.
Pattern: a vault — mint locks value, release on burn
Lock ETH against a token until an unlock date; pay it out when the token is redeemed. One contract, wired as both the minter and the transfer hook.
contract Vault is IAbxTransferHook {
IAbxSequentialMint public immutable token;
uint64 public immutable unlockAt;
mapping(uint256 => uint256) public escrow;
// ── the minter leg: take payment, mint, remember what this token is backed by
function purchase() external payable returns (uint256 id) {
require(msg.value > 0, "nothing to lock");
id = token.mint(msg.sender); // authorized via `abx set-minter`
escrow[id] = msg.value; // the ETH stays here, in this contract
}
// ── the transfer-hook leg: veto early exits, settle on burn
function onTokenTransfer(uint256 id, address from, address to, address, uint256) external {
require(msg.sender == address(token), "only the token calls this");
if (from == address(0)) return; // the mint itself — always allowed
if (to == address(0)) { // burn = redemption
uint256 amount = escrow[id];
escrow[id] = 0;
SafeTransferLib.safeTransferETH(from, amount);
return;
}
require(block.timestamp >= unlockAt, "locked"); // the veto
}
}Wiring, in order:
# 1. deploy the token with burning enabled — redemption needs it, and it is deploy-time-only
abx deploy-code --burnable --name "Vault" --symbol VLT --max 100 --send
# 2. forge-deploy your Vault with the token's address
# 3. authorize it for both legs
abx set-minter <token> --minter <vault> --send
abx set-param-hooks <token> --transfer <vault> --sendTwo things this leans on. Burn is opt-in at deploy and cannot be retrofitted — --burnable, or the
redemption leg has nowhere to go. And a burn is a transfer to 0x0, so the same hook that vetoes an
early transfer is the one that settles the payout; you do not need a second entry point.
Pattern: soulbind — transferable once, then never
A token that may be minted to someone and then never move. A transfer hook, nothing else.
function onTokenTransfer(uint256, address from, address, address, uint256) external view {
require(msg.sender == address(token));
require(from == address(0), "soulbound"); // only the mint passes
}Because the token is a stock clone, a collector can see exactly this: the restriction is one small
contract at a known address, wired through paramHooks(), and paramHooksLocked() tells them whether
you can still change it. Say so — and run abx lock-param-hooks if you mean it permanently.
Pattern: a monotonic ratchet — a value that may only go up
Growth, levels, a high-water mark. A configure hook, reading the stored value before the write lands.
function onParamConfigured(uint256 id, bytes32 key, bytes32 value, address, uint256, address)
external view
{
require(msg.sender == address(token));
if (key != "size") return;
(bytes32 current,, bool isSet) = IAbxParams(token).tokenParam(id, key);
require(!isSet || uint256(value) >= uint256(current), "size may only grow");
}The hook runs before anything persists, so tokenParam still returns the outgoing value — that is
what makes the comparison possible. The collector still writes the parameter themselves, through the
normal governed path; your hook only decides which writes are legal.
Pattern: equip-and-validate — a structured payload with rules
A Bytes parameter carrying a list — placements, an inventory, a layout — that has to be well-formed.
A configure hook again, this time reading the incoming blob.
function onParamConfigured(uint256 id, bytes32 key, bytes32, address, uint256 dataLength, address blob)
external view
{
require(msg.sender == address(token));
if (key != "placements") return;
require(dataLength <= 512, "too many placements");
bytes memory data = SSTORE2.read(blob); // the bytes of THIS write, already stored
// …decode and validate: no overlaps, every slot in range, every item actually owned…
}The blob is written before your hook runs — that is why blob names a contract that already exists —
so a rejected write costs the writer the storage they paid for. An eth_call dry run surfaces the
rejection for free first, which is what a good configuration UI does before asking for a signature.
Design your empty state — you cannot write zero bytes
A zero-length String/Bytes write is refused by the contract before your hook ever runs, so
"unequip everything" cannot be encoded as an empty payload. Use a sentinel byte, or a scalar companion
key. Full explanation and both shapes:
a zero-length write is refused.
Break the controller cycle with abx predict
The naive sequence is five steps and one of them is easy to forget: deploy renderers → deploy a bare
collection → deploy the controller (it needs the collection's address) → set-minter → only now
declare the Address(controller)-authorized schema, because the controller did not exist when the
collection did.
You do not have to. abx predict computes the collection's deterministic address before it exists, so
the controller can be built against it and the schema can ride the deploy:
# 1. reserve the address. The salt's LEADING 20 BYTES are a guard the factory checks against
# msg.sender, so put your deploying wallet there and pick the trailing 12 bytes yourself —
# that is what makes the reservation front-run-proof.
# salt = <your 20-byte address> ++ <12 bytes you choose>
abx predict --script art.js --for 0xYourDeployer \
--salt 0xYourDeployer000000000000000000000000 # 20-byte address + 12 bytes of your own
# → predicted address: 0xC0LLECT10N…
# 2. forge-deploy your controller against that address — a constant now, code later
forge script script/Deploy.s.sol --sig 'run(address)' 0xC0LLECT10N… --broadcast
# 3. deploy the collection at the reserved address, WITH the schema already authorizing the controller
abx deploy-code --script art.js --salt 0xYourDeployer000000000000000000000000 \
--schema 'level:Uint256Range[0..99]:Address(0xC0NTR0LLER…)' \
--name "…" --symbol … --send
# 4. one wiring transaction, not three
abx set-minter <token> --minter 0xC0NTR0LLER… --sendTwo steps of real work instead of five, and no window in which the collection is live with an unauthorized schema.
Get the salt's guard prefix right, or you are publishing an address anyone can take
The factory requires the salt's leading 20 bytes to equal msg.sender when they are non-zero. So:
- Leading 20 bytes = your deploying wallet → the address is reserved to you. This is what you want whenever you are going to publish or build against the address before you deploy it.
- Leading 20 bytes all zero → permissionless: anyone may deploy to that address, with their own
owner and their own URIs.
abx predictprintsguard permissionlessand warns. Never pre-publish or pre-fund one of these.
Omitting --salt entirely makes the CLI mint a correctly-guarded salt with random entropy — safe,
but a plain re-run gets a different address, which is why this recipe pins one.
Reading the token from your contract
Your controller and hooks talk to the token through small interfaces, all of them stock:
| You want | Interface | Call |
|---|---|---|
| Mint one token | IAbxSequentialMint | mint(address to) → uint256 |
| Mint copies (edition) | IAbxEditionMint | mint(address to, uint256 id, uint256 amount) |
| Read a scalar parameter | IAbxParams | tokenParam(id, key) → (bytes32, bool, bool) |
Read a String/Bytes payload | IAbxParams | tokenParamData(id, key) → bytes |
Read a Select option's label | IAbxConfigurableParams | selectOption(key, index) → string |
| Who may mint / where money goes | IAbxExternalMinter · IAbxPrimaryPayee | minter() · primaryPayee() |
| Which hooks are wired, and frozen | IAbxConfigurableParams | paramHooks() · paramHooksLocked() |
abx scaffold-renderer writes a Foundry project with IAbxFieldRenderer and IAbxParams already
vendored, a worked param read, and tests — the fastest way to a compiling starting point even when what
you are building is a hook rather than a renderer. The full interface set is in
contracts/src/, and the calling
conventions are documented under interfaces and
params.
Before you ship
- Test the hook against a real token, not a guess. A hand-rolled mock will not encode the edge cases
the contract actually has — the zero-length refusal above is exactly the kind of rule a mock invents
its way around. Fork the chain (
forge test --fork-url) against a deployed ABX clone, or deploy one on a testnet and point at it. - A hook that reverts is a hook that bricks something. Invariant one for a field renderer is "never revert"; for a transfer hook the revert is the feature, so the question is only whether it can revert on a path you did not mean — a mint, a burn, a zero-amount ERC-1155 move.
- Say what you kept the power to do.
paramHooks()andparamHooksLocked()are what a buyer reads. If the mechanic is meant to be permanent,abx lock-param-hooksand let them verify it.