abx.
Using ABXGuides

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 calls mint, 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

PointWired withCalledCan it veto?
Minterabx set-minter <token> --minter 0x…by you, when your contract calls mint(to)n/a — it is the mint
Configure hookabx set-param-hooks <token> --configure 0x…before a governed parameter write persistsyes — its revert rejects the write
Transfer hookabx set-param-hooks <token> --transfer 0x…after every ownership change, mint and burn includedyes — its revert fails the transfer
Augment hookabx set-param-hooks <token> --augment 0x…at read time, when tokenData is assembledno — 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> --send

Two 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-minteronly 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… --send

Two 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 zeropermissionless: anyone may deploy to that address, with their own owner and their own URIs. abx predict prints guard permissionless and 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 wantInterfaceCall
Mint one tokenIAbxSequentialMintmint(address to) → uint256
Mint copies (edition)IAbxEditionMintmint(address to, uint256 id, uint256 amount)
Read a scalar parameterIAbxParamstokenParam(id, key) → (bytes32, bool, bool)
Read a String/Bytes payloadIAbxParamstokenParamData(id, key) → bytes
Read a Select option's labelIAbxConfigurableParamsselectOption(key, index) → string
Who may mint / where money goesIAbxExternalMinter · IAbxPrimaryPayeeminter() · primaryPayee()
Which hooks are wired, and frozenIAbxConfigurableParamsparamHooks() · 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() and paramHooksLocked() are what a buyer reads. If the mechanic is meant to be permanent, abx lock-param-hooks and let them verify it.

On this page