تصميم النظام · 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.
- معرّف المستند
- AREX-HOOK
- الإصدار
- 2.0
- التاريخ
- 2026-09-21
- المصدر
- docs/HOOK-DEVELOPMENT.md
| Field | Value |
|---|---|
| Document ID | AREX-HOOK |
| Version | 2.0 |
| Date | 2026-09-21 |
| 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/.
contract MaxLotPerBuyerHook is ARexHookBase {
uint256 public immutable maxUnits;
constructor(uint256 _maxUnits) {
maxUnits = _maxUnits;
}
// 1. Declare which callbacks you implement. Constant for the life of the hook.
function hookFlags() public pure override returns (uint8) {
return ARexCodes.HOOK_CHECK;
}
// 2. Validate. View. Return a code, never revert for policy.
function check(uint8 op, address, address, address to, uint256 id, uint256 amount, bytes calldata)
external
view
override
returns (bytes32)
{
if (op != ARexCodes.OP_TRANSFER && op != ARexCodes.OP_MINT) return ARexCodes.OK;
uint256 after_ = IERC1155(msg.sender).balanceOf(to, id) + amount; // msg.sender is the token
return after_ > maxUnits ? ARexCodes.code("MAXLOT", "EXCEEDED") : ARexCodes.OK;
}
}
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.
- 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.
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) |
DeliveryHook | CHECK, EXECUTED | state machine; unit locks; settlement via transfer data; O(1) check |
TraceabilityHook | EXECUTED | observer with emit-only vs persist modes |
4. Testing a hook
Use the fixtures in test/standard/helpers.js:
const { deployStack, batchInit } = require("../standard/helpers"); // from a folder next to test/standard
const { token, admin, issuer, farmer, buyer } = await deployStack();
await token.connect(issuer).createBatch(batchInit(), 1000, farmer.address, "0x"); // before the hook exists
const hook = await (await ethers.getContractFactory("MaxLotPerBuyerHook")).deploy(100);
await token.connect(admin).registerHook(await hook.getAddress());
expect(await token.transferCheck(farmer.address, buyer.address, 1, 100)).to.equal(ethers.ZeroHash);
expect(await token.transferCheck(farmer.address, buyer.address, 1, 101))
.to.equal(ethers.keccak256(ethers.toUtf8Bytes("MAXLOT:EXCEEDED")));
await expect(token.connect(farmer).safeTransferFrom(farmer.address, buyer.address, 1, 101, "0x"))
.to.be.revertedWithCustomError(token, "ARexHookRejected");
await expect(token.connect(issuer).createBatch(batchInit(), 101, buyer.address, "0x")) // mints are checked too
.to.be.revertedWithCustomError(token, "ARexHookRejected");
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.
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
await token.connect(hookAdmin).registerHook(hookAddress); // appended: evaluated last
await token.getHooks(); // ordered list
await token.hookFlagsOf(hookAddress); // cached flags
await token.connect(hookAdmin).removeHook(hookAddress); // order of the rest preserved
Order matters: put cheap, high-rejection hooks first (sanctions, pause-like rules), expensive ones last.