Skip to main contentA-REX Foundation

系统设计 · 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.

Current
文档 ID
AREX-HOOK
文档版本
2.5
最后更新
2026-11-03

文档版本记录的是本文档的修订次数,不是标准(A-REX Core 2.1.0-alpha.2)或参考实现(2.0.0-alpha.10)的版本。

仅提供英文版
本页为英文。白皮书章节和示意图已翻译。规范性标准留在仓库中;请使用申请提前访问获取该文本。系统设计指南和决策记录保持英文。
FieldValue
Document IDAREX-HOOK
Document version2.5
Last updated2026-11-03
StatusCurrent
RelatedAREX-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

CallbackCalled whenFlagConstraints
check(op, operator, from, to, id, amount, data) → bytes32before balances change, for each affected id, hooks in registration order, stops at first non-zeroHOOK_CHECKview; MUST NOT revert for policy; bytes32(0) = allowed
onExecuted(op, operator, from, to, id, amount, data)after balances changed, for each id, in orderHOOK_EXECUTEDMUST 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 viewsHOOK_ELIGIBILITYview

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 OK for OP_FORCED_TRANSFER unless your hook exists to govern enforcement (for example, requiring a court-order reference in data). 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_BURN and the input legs of OP_TRANSFORM (to == address(0)), not only OP_TRANSFER; otherwise the holder can re-lot the reserved units into a fresh batch that carries no lock. DeliveryHook shows 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 in ARexCodes when the meaning matches; front-ends already know them.
  • Keep check cheap. It runs on every transfer of every id. Avoid unbounded loops over history; DeliveryHook keeps 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 in check.
  • 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 _appliesFrom in the constructor (0 = every lot) and return OK early when _governs(batch) is false. The value is immutable, so nobody can exempt more lots later. ExpiryHook, AttestationHook, SeasonalHook and PerishabilityHook do this.

3a. Reference hooks to copy from

HookFlagsPattern it demonstrates
EligibilityHookCHECK, ELIGIBILITYaccount lists; sanctions with source and TTL
ExpiryHookCHECKstateless rule from the batch record
AttestationHookCHECKrule that consults IARexAttest
PerishabilityHookCHECKper-commodity config with per-batch override; value views
SeasonalHookCHECKbounded configuration (≤ 16 windows per key; from 2.0.0-alpha.7 ended windows make room and removeWindow removes one by its bounds)
DeliveryHookCHECK, EXECUTEDstate machine; unit locks against transfer, burn and re-lot; settlement via transfer data; O(1) check
TraceabilityHookEXECUTEDobserver with emit-only vs persist modes
CompanyFactsHookEXECUTEDa 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
RequireStageHookCHECKa 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-state on writes through a storage pointer (false positive). Slither (0.11.3 in the gate image) does not follow writes made through a local storage reference, so a mapping of arrays or structs that is only ever changed as Window[] 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-disable comments have no effect. Before Slither reads the source, the gate replaces every // slither-disable-* comment and @custom:security hint, 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 onExecuted directly 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 type token, as ADR-025 allows, and return when msg.sender is not it), or name msg.sender as 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: