Whitepaper · 07
Hooks and Reference Modules
How functionality is added to a conformant token without touching the standard: the hook contract, the reference hooks, the companion extensions, and how to write, test and register your own.
Modules are demonstrations, not the standard
The relationship between A-REX Core and its modules is the one between ERC-3643 and T-REX's compliance modules: the interface through which modules plug in is standardized; the modules are not. A conformant token with zero hooks is a valid A-REX token. A hook written by anyone is compatible with every conformant token. This is what makes the ecosystem grow without the standard changing.
graph LR
T[ARexToken<br/>conformant token] -->|"check / onExecuted / canSend / canReceive"| H1[EligibilityHook]
T --> H2[ExpiryHook]
T --> H3[AttestationHook]
T --> H4[Your hook]
H3 --> A[ARexAttestations]The hook contract
| Callback | When | Must |
|---|---|---|
check | before balances change, per affected id, hooks in registration order, stop at first non-zero | be a view; never revert for policy |
onExecuted | after balances change, per id, in order | not call the token's balance-changing functions (the token is locked) |
canSend / canReceive | on every transfer and for the token's ERC-7943 views | be views |
op is one of OP_MINT (1), OP_TRANSFER (2), OP_BURN (3), OP_FORCED_TRANSFER (4), OP_TRANSFORM (5). In a transformation, inputs arrive with to == 0 and outputs with from == 0. data is the ERC-1155 transfer data, abi.encode(processRef) for transforms, empty for forced transfers.
The calling token is msg.sender. A hook that needs the batch reads IARexBatch(msg.sender).batchOf(id). A hook bound to one token at deployment (ADR-025, Proposed) names it as token() and refuses calls from any other address.
When more than one rule reads data, ADR-023 (Proposed) makes it a tagged envelope: up to 8 (bytes32 key, bytes32 value) entries, keys namespaced like codes (keccak256("AREX:DELIVERY_ID"), keccak256("AREX:DOCUMENT_REF")), each rule reading only its own key. 32-byte data keeps the single meaning it had for each rule before; anything else is no value, and reading never reverts. A rule written before the envelope reads it as no value, so a sender builds one only when every rule of the token that reads data reads envelopes. The reference DeliveryHook and TraceabilityHook do from release 2.0.0-alpha.7.
Reference hooks
EligibilityHook — allow, block, sanction
Demonstrates HOOK_ELIGIBILITY. Maintains an optional allowlist, a blocklist and a sanctions list; canSend/canReceive return their conjunction; check returns SANCTIONED, SENDER_INELIGIBLE or RECEIVER_INELIGIBLE so that interfaces can explain the refusal. Forced transfers are always allowed: seizing goods from a sanctioned account is the point of enforcement. In production the lists are fed by an identity registry (ONCHAINID, EAS, verifiable credentials) or a sanctions oracle; the hook does not care which.
ExpiryHook — perishability without a module
Stateless. Reads expiresAt and producedAt from the batch. Blocks mints and transfers of expired batches (BATCH_EXPIRED) but not burns (write-off) or forced transfers (enforcement). Optionally blocks transfers of batches that are not yet produced (BATCH_NOT_PRODUCED) — a platform that finances pre-harvest sets the flag to allow them; a spot marketplace sets it to block them. This one hook covers the blocking part of perishability; decay curves become a view helper or an off-chain valuation.
AttestationHook — regulatory gating per commodity
Maps a commodity code to a required attestation topic. Before a transfer, reads the batch, looks up the requirement, asks the attestation registry isAttested and returns ATTESTATION_MISSING otherwise. "Coffee needs an EUDR due-diligence reference", "organic-labelled batches need a valid organic certificate", "cattle needs a sanitary certificate" are each one call to setRequirement. From reference release 2.0.0-alpha.7 its owner can turn on checksTransform (off by default): the input lots of a transform then need their certificate too, so a holder with TRANSFORMER_ROLE cannot hand an uncertified lot to another account as a new lot. The outputs are new lots and are not checked.
PerishabilityHook — decay models
Decay configuration per commodity code with per-batch override; models NONE, LINEAR (to a floor at expiry), EXPONENTIAL (half-life) and STEP (drop every interval). Views valueBps, secondsRemaining, isExpired. Blocks mints and transfers of expired batches (BATCH_EXPIRED) and transfers of batches whose remaining value is zero (PERISH:NO_VALUE); never blocks burns or enforcement.
SeasonalHook — harvest and trading windows
Absolute [start, end] windows per commodity and operation (at most 16 per key). A commodity/op with no windows is unrestricted. Typical use: OP_MINT limited to the harvest window, OP_TRANSFER to the trading window; SEASON_CLOSED otherwise. From reference release 2.0.0-alpha.7 a manager removes one window by its bounds (removeWindow), never by its position, and windows that have ended make room: when a key is full, adding a window first drops its ended ones. Every removal emits WindowRemoved, so an indexer can rebuild the windows from events.
DeliveryHook — custody and settlement
A sender schedules a delivery of amount units to a receiver via a carrier; the units are locked (an O(1) counter, so check never iterates over history). Carrier and sensors record pickup, transit and temperature readings (breach flag); the receiver confirms delivery; disputes go to an arbiter. Settlement is the ERC-1155 transfer itself: sender → receiver, exact amount, with the delivery's number in data (32 bytes, abi.encode(deliveryId), or from release 2.0.0-alpha.7 the AREX:DELIVERY_ID entry of an envelope); check allows it only when the delivery is DELIVERED and onExecuted marks it SETTLED and unlocks. Free (unlocked) units keep moving. Locked units cannot be transferred, burned or used as inputs of a transform: check returns IN_TRANSIT for any operation that would take the holder's balance below the locked amount, except the settlement transfer, mints and forced transfers. After a forced transfer has seized locked units, check still returns a code instead of reverting. From reference release 2.0.0-alpha.7, units frozen by the token (ERC-7943 getFrozenTokens) are not free to schedule (freeUnits says how many are); a delivery whose units a forced transfer took cannot be confirmed or accepted as delivered (LockedUnitsMoved), since its settlement could never happen, but it can still be disputed and returned; and a temperature range whose minimum is not below its maximum is refused (InvalidRange).
TraceabilityHook — EPCIS-shaped event log
Observes every token operation and classifies it with CBV business steps (commissioning, shipping, destroying, transforming) and dispositions (active, in_transit, destroyed, seized); RECORDER_ROLE records non-token critical tracking events (cooling, packing, receiving) with readPoint and bizLocation. Two modes: emit-only (about +17 k gas per transfer; an indexer rebuilds the log) or persist (about +184 k; getEvents answers on-chain, paginated). Version 2, in reference release 2.0.0-alpha.7, is bound to one token at deployment: only that token reports operations, and recordEvent accepts only lots that exist on it, so nobody can write entries in the token's name. Its EPCISEvent carries every field of an entry (operation, read point, location and recorder), so emit-only mode loses nothing. A movement's document reference comes from 32-byte data or from the AREX:DOCUMENT_REF entry of an envelope, never from another rule's entry. The hook is not upgradeable: version 1 instances and their events stay as they were, and version 2 is a new deployment.
CompanyFactsHook — a company's own records
Demonstrates HOOK_EXECUTED only: it never refuses an operation, its onExecuted never reverts, and it is bound to one token. It records a company's own records as the standard's vocabulary descriptor describes them (interop/VOCABULARY.md, "Records on the ledger"): facts about a lot, a site or the company (a recordHash plus at most 8 public values), stage marks (salted stage references at bit indices 0–255, one or up to 50 per transaction), stages received from another company's token, daily anchors (a Merkle root and a count) and voids. Every write carries an approvalHash, the hash of the company's record of who approved it. The company's Safe registers definitions and stages; a recorder key, which can be delegated, writes the records. On a transform it carries to the outputs the stages every parent passed. It adds the stage of a process type only for a processRef committed in the same block (commitRun), and each commitment is honoured once. It carries the custody model (cocModel, the lowest of the parents) and EUDR coverage (eudrCovered, the AND of the parents) the same way. Events: FactRecorded, StageRecorded, ReceivedStageRecorded, Anchored, FactVoided, RunCommitted, StagesCarried, CustodyDeclared. Reference release 2.0.0-alpha.9.
RequireStageHook — no delivery without the required stages
Demonstrates HOOK_CHECK on top of CompanyFactsHook. Up to 16 requirements, each naming the operations it covers (deliveries and, optionally, the inputs of a transform), the commodities and the stages a lot must have passed. A lot that lacks one is refused with REQUIRE_STAGE:MISSING; a lot or stage module it cannot read is refused with REQUIRE_STAGE:UNREADABLE, never a revert. Mints, burns and moves by legal order are never refused. Each requirement carries its own appliesFrom: lots created before it are not checked. The company's Safe sets the requirements with configure, so the starting point can change when the requirements are replaced; every change emits Configured. Reference release 2.0.0-alpha.9.
Companion extensions
Three concerns never needed to be transfer rules; they are plain contracts that read or write attestations:
| Extension | What it does |
|---|---|
CommodityPriceOracle | reporters submit prices per commodity; rounds are numbered, finalized by quorum with the median, or manually once a minimum is reached; latestAnswer, isStale |
CarbonImmobilizationWrapper | records credits immobilised in a registry (registry, serial range, vintage, tonnes, proof), links tonnes to batches, mirrors retirement and release, writes the attestation AREX:CARBON:IMMOBILIZED; never mints credits |
ParametricInsurance | ERC-20 pool; policy per batch with a trigger topic; the claim pays when the trigger is attested before expiry |
Old lots and new lots
A rule switched on for a token that already holds lots either governs every lot or only the lots created from a point in time on. The reference ExpiryHook, AttestationHook, SeasonalHook and PerishabilityHook take that point (appliesFrom, 0 for every lot) when they are deployed. It cannot be changed afterwards, so nobody can exempt more lots later; a different choice is a new deployment of the rule. RequireStageHook is the exception: each of its requirements carries its own appliesFrom, and the company's Safe can replace the requirements with configure.
Describing a rule to the systems that use it
A rule or an add-on can publish an arex.module/1 descriptor next to its code. It is a versioned JSON document that names the contract's parameters and settings, the batch fields it reads, the certificate types it expects, its events and rejection codes, and how its state carries over to a new version. A platform, a wallet, an indexer or an auditor reads it to show the rule, configure it and explain a refusal without code written for that rule. Since revision 1.1 the descriptor may also list the operations the rule governs (ops: mint, transfer, burn, forced_transfer, transform). The field is informative: when it is absent the operations are unknown, and no consumer may rely on it for enforcement, because the hook's own checks decide. Revision 1.2 adds two things. A setting may name a place (ref:site): it is stored as the originRef that lots created there carry, or as the reference to the off-chain origin evidence they carry instead (an EUDR due-diligence statement, for example), never as an address or coordinates. And the declared gas of check and onExecuted is measured with the settings filled: before measuring, a validator calls every operation that sets or adds a value setup times per key with test values (8 unless the descriptor says otherwise, at most 32), so a rule that walks a configurable list declares what it costs with that list full. Revision 1.3 writes down what the descriptor's first consumer already uses, so its descriptors check against the published schema: the actions people record through an approval (an add-on's records, each with the role that may call it), the contract's own errors in words, measures over its records, the per-delivery data a rule reads, a full form of its events with their fields and words, and more value types (a lot, a record of the module, a short text, a text kept only by its fingerprint). Every field it adds is optional, so every earlier descriptor stays valid. Revision 1.4 describes each value well enough for a form to be built from it: new kinds of value (an amount with its currency, a date and time, a document kept by its fingerprint, a code with a format such as a GTIN, a country or region, a unit, a person or a party of the directory), what reaches the ledger (the value, only its fingerprint, or nothing; personal data never), the title, help, example, section and conditions of each field, cross-field checks from a closed list, calculated values, details kept off the ledger, read-only views shown next to a lot, and how a record is shown: always as declared by the organisation that recorded it, never as verified. Revision 1.5 salts every fingerprint: a descriptor that declares fingerprint: { "scheme": "salted-jcs/1" } records the hash of each value together with its own 32-byte salt, so short texts, numbers or plot outlines cannot be found by trying candidates. Revision 1.6 adds measures that are not totals: besides count and sum, a measure may be the average, the lowest or the highest value (avg, min, max) of a number field of the records. These need that number field and are never added up across groups or companies: an average over two companies is the mean of their records, not the mean of their averages. Measures may also be grouped by the party that created the lot (party) and by its site (site). The moisture add-on template of reference release 2.0.0-alpha.10 declares one: the average moisture at intake by product and site. A measure over a value recorded in basis points declares unit: "bp", and a consumer shows it divided by 100 as a percentage. Revision 1.7 lets a field's visibleWhen take the vocabulary descriptor's condition tree over the other fields of the same form: leaves { field, op, value } combined with all, any and not, at most 3 levels and 12 leaves, never an expression and never the record's context. Revision 1.4's single condition stays valid, deprecated, and reads as the equivalent leaf.
The descriptor is informative and Proposed: it was proposed by the A-REX Platform, its first consumer, and becomes a recommended format when a second, independent implementer consumes it. Fields that a rule adds to a batch's metadata document would live under extensions.<key>, validated by the descriptor's schema fragment (ADR-017, Proposed, for metadata schema 1.3.0).
An organisation can also publish its own vocabulary as arex.vocab/1 definitions: field work types (pruning, soil sampling), process types (a 72-hour fermentation, a screen sorting) and stage lists per product (harvest, drying, hulling, grading, export). They are data, not code. Each one uses the descriptor's field object, names its version, and maps to a reference stage class and a GS1 CBV business step, so a reader that does not know the organisation still understands it. A process type is a transform with a type, never a new operation: its processRef carries a 4-byte tag of the type and a fingerprint of the run's values, so a rule can act on the type and anyone can check the run. Records reach the ledger one by one or as a daily Merkle root, each value committed under its own salt, so an organisation can disclose some fields and keep others. A matching fingerprint shows that a record has not changed since it was recorded, never that the work was done as declared. The format is informative and Proposed (vocabulary descriptor revision 1.5). Revision 1.1 adds an optional form that lays the fields out in sections and widths and sets how each one behaves (conditions, defaults, read-only and computed values, masks and patterns); it only narrows what the fields accept, so a reader that ignores it reads every record as before. Revision 1.2 gives conditions one small language, shared with the rule descriptor: not, up to 3 levels and 12 leaves, and leaves that read what the platform knows about the record in codes that read the same in every language (the crop as an EPPO code, the days since planting, the kind of site, the country, the HS heading of the lot's product, its latest stage). Unknown context reads as empty, and every revision 1.1 condition stays valid as written. Revision 1.3 lets a stage list say its order: under strict, the default, a batch is not recorded at a stage while a required earlier stage is missing for it, counting from where the batch entered the list; under flexible, stages are recorded in any order and gaps are left to reports. A stage that follows from the batch's own history (its registration, a transform, a reception) is never refused, and every earlier list reads as strict. Revision 1.4 lets a process type say which coffee process method its outputs carry (washed, natural, honey and its yellow, red and black variants, pulped_natural, wet_hulled, anaerobic, other), taken from a field of the run or fixed by the type, and a batch's process metadata may carry the method (mixed only for a batch). It is the organisation's declaration, not a certification, and it travels with the batch even where the step values stay private. Revision 1.5 lets a mapping to one of the broad classes separating, conditioning, sorting_grading or heat_treating add a subclass from a closed list per class (for example hulling, curing, screen, roasting), and a reader matches another organisation's step of a broad class only by its subclass or by an explicit mapping per sender. Revision 1.6 adds to_processor, a subclass of transporting: goods sent to a processor that works them for their owner (toll processing), so custody moves while title stays.
Writing your own hook
The worked example contracts/examples/SeasonHook.sol stays in the repository. test/standard/ImplementationGuide.test.js exercises it for readers with access. SeasonalHook is the reference module for several windows per commodity and per operation.
Rules of thumb:
- The symbols
OKandOP_FORCED_TRANSFERare defined in the normative text. - Burn behaviour is defined in the normative text.
- The callback
checkis defined in the normative text. - Code names such as
ARexCodes.code("MYNS", "NAME")are defined in the normative text. - Flag values are defined in the normative text.
- The callbacks
onExecutedandcheckare defined in the normative text.
Registering and ordering
Put cheap, high-rejection rules first. HOOK_ADMIN_ROLE should sit behind a multisig or timelock: whoever controls the pipeline controls which transfers are possible.
Testing
test/standard/helpers.js deploys the reference stack. RecordingHook records every callback and can be told to reject with any code; ReentrantHook attempts to re-enter the token from onExecuted and proves the lock. DomainHooks.test.js and Extensions.test.js cover the ported hooks and companions; Invariants.test.js runs a seeded random sequence of operations and checks supply conservation, terminal status and that canTransfer predicts every outcome. From reference release 2.0.0-alpha.8 the reference implementation also ships a hook kit (conformance/hooks/), Foundry suites that any rule must pass, and a module template (templates/module/). Release 2.0.0-alpha.9 adds an add-on kit (conformance/addons/) and three add-on templates tested on it: moisture at intake (GrainMoisture), a fair-trade certificate recorded as the company's declared fact (FairTradeCertificate) and a premium paid to a producer, whose amount and payee are recorded only as salted fingerprints (PremiumPayments).
Interoperating with ERC-3643 compliance
An ERC-3643 compliance module exposes moduleCheck(from, to, value, compliance). A hook that calls it for OP_TRANSFER and maps false to a code makes country restrictions, balance caps and time locks written for security tokens reusable on batch tokens. The reverse — exposing an A-REX hook to an ERC-3643 token — is equally mechanical. Chainlink's Automated Compliance Engine integrates through the same pattern: its policy engine becomes one hook. These two directions are designs. The reference adapter that exists goes the other way round and only reads: it lets ERC-3643 wallets and compliance tooling see each lot as an ERC-3643 token through a notification-only hook that cannot allow or refuse a transfer (ADR-020, Proposed).