Systementwurf · AREX-HOOK
Writing an A-REX hook
A hook is the only extension point of an A REX Core token. If you can express a rule as "given this operation on this batch between these accounts, allow or return a code", it is a hook.
- Dokument-ID
- AREX-HOOK
- Dokumentversion
- 2.5
- Zuletzt aktualisiert
- 2026-11-03
- Quelle
- docs/HOOK-DEVELOPMENT.md
Die Dokumentversion zählt die Überarbeitungen dieses Dokuments. Sie ist weder die Version des Standards (A-REX Core 2.1.0-alpha.2) noch die der Referenzimplementierung (2.0.0-alpha.10).
| Field | Value |
|---|---|
| Document ID | AREX-HOOK |
| Document version | 2.5 |
| Last updated | 2026-11-03 |
| Status | Current |
| Related | AREX-SDD-001, AREX-SPEC |
A hook is the only extension point of an A-REX Core token. If you can express a rule as "given this operation on this batch between these accounts, allow or return a code", it is a hook.
1. Contract skeleton
Source: contracts/examples/MaxLotPerBuyerHook.sol, compiled and tested in CI. It imports IERC1155 from OpenZeppelin and ARexHookBase from contracts/reference/hooks/.
ARexHookBase gives you ERC-165 registration (supportsInterface(0x332afcbb)) and no-op defaults
for check, onExecuted, canSend, canReceive.
2. The callback contract
| Callback | Called when | Flag | Constraints |
|---|---|---|---|
check(op, operator, from, to, id, amount, data) → bytes32 | before balances change, for each affected id, hooks in registration order, stops at first non-zero | HOOK_CHECK | view; MUST NOT revert for policy; bytes32(0) = allowed |
onExecuted(op, operator, from, to, id, amount, data) | after balances changed, for each id, in order | HOOK_EXECUTED | MUST NOT call balance-changing functions of the token (the token is locked; the call reverts) |
canSend(account) / canReceive(account) | eligibility, per transfer and for the token's ERC-7943 views | HOOK_ELIGIBILITY | view |
op values: OP_MINT = 1, OP_TRANSFER = 2, OP_BURN = 3, OP_FORCED_TRANSFER = 4, OP_TRANSFORM = 5
(for transform, inputs arrive with to == address(0) and outputs with from == address(0)).
data: the ERC-1155 data argument (or createBatch's), abi.encode(processRef) for transforms, empty for forced transfers.
3. Rules of thumb
- Return
OKforOP_FORCED_TRANSFERunless your hook exists to govern enforcement (for example, requiring a court-order reference indata). Seizures must not be blocked by expiry or eligibility rules. - Do not block burns unless you really mean it. Burns are write-offs and deliveries.
- Lock against every operation. A hook that reserves units (delivery, escrow, collateral) must also check
OP_BURNand the input legs ofOP_TRANSFORM(to == address(0)), not onlyOP_TRANSFER; otherwise the holder can re-lot the reserved units into a fresh batch that carries no lock.DeliveryHookshows the pattern. - Read the batch through
IARexBatch(msg.sender).batchOf(id). Do not store the token address; one hook can serve many tokens. - Use namespaced codes:
ARexCodes.code("MYNS", "NAME")=keccak256("MYNS:NAME"). Reuse the standard codes inARexCodeswhen the meaning matches; front-ends already know them. - Keep
checkcheap. It runs on every transfer of every id. Avoid unbounded loops over history;DeliveryHookkeeps an O(1) lock counter for exactly this reason. - Constant flags. The token caches
hookFlags()at registration. Changing it later has no effect until re-registration. - Prefer stateless hooks. If you need state (quotas, counters), write it in
onExecuted, never incheck. - Let the token's owner choose old lots or new lots only. A rule turned on for a token that already holds lots
either governs every lot or only lots created from a point in time on. Inherit
ARexAppliesFrom(contracts/reference/hooks/ARexAppliesFrom.sol), take_appliesFromin the constructor (0 = every lot) and returnOKearly when_governs(batch)is false. The value is immutable, so nobody can exempt more lots later.ExpiryHook,AttestationHook,SeasonalHookandPerishabilityHookdo this.
3a. Reference hooks to copy from
| Hook | Flags | Pattern it demonstrates |
|---|---|---|
EligibilityHook | CHECK, ELIGIBILITY | account lists; sanctions with source and TTL |
ExpiryHook | CHECK | stateless rule from the batch record |
AttestationHook | CHECK | rule that consults IARexAttest |
PerishabilityHook | CHECK | per-commodity config with per-batch override; value views |
SeasonalHook | CHECK | bounded configuration (≤ 16 windows per key; from 2.0.0-alpha.7 ended windows make room and removeWindow removes one by its bounds) |
DeliveryHook | CHECK, EXECUTED | state machine; unit locks against transfer, burn and re-lot; settlement via transfer data; O(1) check |
TraceabilityHook | EXECUTED | observer with emit-only vs persist modes |
CompanyFactsHook | EXECUTED | a rule with actions that never refuses: records written by a recorder role, state carried in onExecuted of a transform's mints (AND over parentsOf), and an onExecuted that ignores every other token and operation instead of reverting |
RequireStageHook | CHECK | a rule that reads another module's view (stageBits) and refuses with its own code when it cannot read it, so check never reverts |
4. Testing a hook
Use the fixtures in test/standard/helpers.js:
RecordingHook (records every callback, can be told to reject) and ReentrantHook (tries to
re-enter) in contracts/reference/hooks/mocks/ are useful for pipeline tests.
With the hook kit (Foundry, from 2.0.0-alpha.8)
conformance/hooks/ holds the checks every hook must pass, as Foundry suites: HookKit (MUST: test_kit_*;
SHOULD, reported only: test_should_*), HookInvariants, HookGas and HookDifferential. The A-REX platform's
gate runs the same files on an uploaded rule. A test inherits the kit and says how to deploy the hook:
templates/module/ is a complete starting point (rule, manifest, lockfile, the gate's foundry.toml); its
README says what may and may not change. In this repository test/foundry/HookKitSelf.t.sol runs the kit on
reference rules and test/foundry/TemplateModule.t.sol on the template:
What the gate's analysis gets wrong, and what it ignores
The A-REX platform's gate refuses a rule on a high-severity Slither finding and indexes a rule's record events only under conditions of its own. Three things to know before uploading:
- Slither
uninitialized-stateon writes through a storage pointer (false positive). Slither (0.11.3 in the gate image) does not follow writes made through a localstoragereference, so a mapping of arrays or structs that is only ever changed asWindow[] storage ws = _windows[key]; ws.push(w);reads as never initialized, a high-severity finding, and the rule is refused. Write through the state variable itself (_windows[key].push(w),_windows[key][i] = w,_windows[key].pop()). Reading through a storage pointer is fine. slither-disablecomments have no effect. Before Slither reads the source, the gate replaces every// slither-disable-*comment and@custom:securityhint, so a rule cannot silence a finding; the gate's advice names each comment that had no effect. Change the code instead.- Records count only from the company's side. The platform keeps a rule's record events only when the call that
entered the rule came from the company's token, from a function the manifest declares, or from the constructor. An
event emitted when anyone else calls
onExecuteddirectly is not indexed, and the gate's test "only the ledger writes records" refuses a rule that writes a record for any caller. Either act only for the token the rule is built for (take it as a constructor parameter of typetoken, as ADR-025 allows, and return whenmsg.senderis not it), or namemsg.senderas the lot's token in the record and declare that argument in the manifest (events[].lot.token).
5. Wrapping an ERC-3643 compliance module
An ERC-3643 IModule exposes moduleCheck(from, to, value, compliance). A thin hook can call it
for OP_TRANSFER and map false to a code, so existing compliance logic (country restrictions,
max balance, time locks) is reusable on batch tokens. The reverse direction — an A-REX hook as an
ERC-3643 module — is equally mechanical.
6. Registering on a token
Order matters: put cheap, high-rejection hooks first (sanctions, pause-like rules), expensive ones last.
7. Writing an add-on
An add-on is not a hook. It is a companion contract bound to one token (constructor (address admin, address token_))
that the token never calls: people record facts about lots with its actions (functions gated by a role the company's
Safe grants, such as RECORDER_ROLE), and it emits records whose first indexed argument is a lot of its token. It
never changes a lot (ownership, quantity, status, certificates), never holds units and never holds a role on the token.
Its only external calls are views of its token, such as IARexBatch.batchExists to refuse a lot that is not the
token's with a named error. A company that needs to refuse an operation writes a rule instead.
conformance/addons/ holds the add-on kit: AddonKit (the Safe administers it and the deploying key holds no role, no
action works without the role, every action works for a recorder, bound to its token, each record names the lot, it
holds nothing on the token), AddonInvariants ("Lots stay consistent" over random actions and token operations) and
AddonGas (AREX_GAS_ACTION_<i>, AREX_GAS_VIEW_<i>). A test supplies four functions, best kept in one mixin:
templates/addon/ holds three complete starting points, each with its kind: "addon" descriptor, lockfile and the
gate's foundry.toml: moisture/ (GrainMoisture, moisture at intake), fair_trade/ (FairTradeCertificate, the
fair-trade certificate of a lot as a declared fact, never a ledger certificate) and premium/ (PremiumPayments, a
premium paid to a producer; it records the payment and never moves money). In this repository
test/foundry/AddonKitSelf.t.sol runs the kit on the three templates and on two known-bad add-ons, each of which must fail the kit test for the rule it breaks: