Whitepaper · 11
Implementation Guide
From a clean clone to a deployed, hooked, attested batch token, step by step. Every JavaScript block in this chapter is copied from test/standard/ImplementationGuide.test.js and runs in CI; if a block here were wrong, the build would fail.
How to use this guide
The steps build on each other and share variables: token, attestations, the signers and the ids created along the way. Read them in order the first time. Run them yourself with:
npx hardhat test test/standard/ImplementationGuide.test.js
| Step | What you get | Who acts | Main calls |
|---|---|---|---|
| 1 · Set up | a compiled, tested checkout | developer | npm ci, npm test |
| 2 · Deploy | token proxy + attestation registry | deployer | upgrades.deployProxy, ARexAttestations |
| 3 · Roles | least-privilege operators | admin | grantRole |
| 4 · Create | a batch with metadata commitment | issuer | createBatch |
| 5 · Read | the batch record and supply | anyone | batchOf, totalSupply(uint256), parentsOf |
| 6 · Transfer | a checked transfer | holder | transferCheck, safeTransferFrom |
| 7 · Lineage | split, merge, processing | holder / transformer | transform |
| 8 · Status | lifecycle and metadata updates | status role | setBatchStatus, setBatchMetadata |
| 9 · Hooks | an ordered rule pipeline | hook admin | registerHook, getHooks |
| 10 · Attest | evidence that unlocks transfers | trusted issuer | setTrustedIssuer, attest |
| 11 · Sanction | eligibility with source and TTL | eligibility manager | setSanctioned |
| 12 · Enforce | freeze, pause, forced transfer | enforcer / pauser | setFrozenTokens, forcedTransfer |
| 13 · Retire | end of life of a lot | status role, holder | setBatchStatus(…, 255, …), burn |
| 14 · Index | the lineage graph from events | indexer | queryFilter(BatchTransformed) |
| 15 · Upgrade | a validated implementation upgrade | upgrader | validateUpgrade, upgradeProxy |
1 · Set up
Node.js 20 or 22 LTS and npm. Dependencies are pinned in package-lock.json (OpenZeppelin 5.3.0); use npm ci so that your build is the one CI builds.
git clone https://github.com/Arex-Corp/A-REX.git && cd A-REX
npm ci
npm run compile
npm test # 46 tests: reference implementation, hooks, extensions, invariants, this guide
npm run interface-ids # prints the ERC-165 ids; they must match the specification
The snippets below use three small helpers and name ten accounts. explain turns a rejection code back into its name; the list is the set of codes defined in ARexCodes.
const commodity = (scheme, code) => ethers.keccak256(ethers.toUtf8Bytes(`${scheme}:${code}`));
const unit = (u) => ethers.encodeBytes32String(u);
const ref = (text) => ethers.keccak256(ethers.toUtf8Bytes(text));
const REASONS = Object.fromEntries(
["PAUSED", "FROZEN", "SENDER_INELIGIBLE", "RECEIVER_INELIGIBLE", "SANCTIONED", "BATCH_NOT_FOUND",
"BATCH_RETIRED", "BATCH_EXPIRED", "BATCH_NOT_PRODUCED", "ATTESTATION_MISSING", "SEASON_CLOSED",
"IN_TRANSIT", "LIMIT_EXCEEDED", "DOCUMENT_MISSING"].map((n) => [ethers.keccak256(ethers.toUtf8Bytes(`AREX:${n}`)), n]),
);
const explain = (code) => (code === ethers.ZeroHash ? "OK" : REASONS[code] ?? code);
const [admin, issuer, farmer, buyer, coop, mill, warehouse, certifier, enforcer, custodian] = await ethers.getSigners();
2 · Deploy
The reference token is UUPS-upgradeable, so it is deployed behind an ERC-1967 proxy with the OpenZeppelin upgrades plugin, which also validates the storage layout. The attestation registry is a plain contract bound to the token.
const ARexToken = await ethers.getContractFactory("ARexToken");
const token = await upgrades.deployProxy(ARexToken, [admin.address, "ipfs://<cid>/{id}.json"], { kind: "uups" });
await token.waitForDeployment();
const attestations = await (await ethers.getContractFactory("ARexAttestations"))
.deploy(admin.address, await token.getAddress());
From the command line the same stack, plus the eligibility and expiry hooks, is deployed with:
npx hardhat node # terminal 1
npx hardhat run scripts/deploy-standard.js --network localhost # token, attestations, 2 hooks
npx hardhat run scripts/deploy-full-standard.js --network localhost # every hook in order + extensions
or with Hardhat Ignition:
npx hardhat ignition deploy ignition/modules/ARexToken.js --network localhost \
--parameters '{"ARexTokenModule":{"baseURI":"ipfs://<cid>/{id}.json","allowPreHarvestTransfers":true}}'
For a testnet or L2, hardhat.config.js defines sepolia, polygon and arbitrum; set the RPC URL and PRIVATE_KEY in .env (template in .env.example). ARexToken compiles to 24,136 bytes with optimizer.runs = 1, under the 24,576-byte EIP-170 limit.
3 · Roles
initialize(admin, baseURI) gives every role to admin, which is convenient for a first deployment and wrong for production. Grant each operational role to the service or multisig that performs it:
await token.grantRole(await token.ISSUER_ROLE(), issuer.address);
await token.grantRole(await token.TRANSFORMER_ROLE(), mill.address);
await token.grantRole(await token.STATUS_ROLE(), warehouse.address);
await token.grantRole(await token.ENFORCER_ROLE(), enforcer.address);
| Role | Allows | Recommended holder |
|---|---|---|
DEFAULT_ADMIN_ROLE | grant and revoke every role; setBaseURI | governance multisig behind a timelock |
UPGRADER_ROLE | replace the implementation | same timelock |
HOOK_ADMIN_ROLE | registerHook, removeHook | same timelock (hook changes become visible before they apply) |
ENFORCER_ROLE | setFrozenTokens, forcedTransfer | enforcement multisig (compliance and legal) |
PAUSER_ROLE | pause, unpause | incident-response key |
ISSUER_ROLE | createBatch | origination service |
STATUS_ROLE | setBatchStatus, setBatchMetadata | warehouse, inspector or registry |
TRANSFORMER_ROLE | processing transforms (commodity, unit or quantity change) | processors |
ATTEST_ADMIN_ROLE (on ARexAttestations) | setTrustedIssuer, revoke any attestation | certification authority or platform |
When every role has its holder, the deployer renounces what it no longer needs with renounceRole(role, deployer).
4 · Create a batch
A batch is one ERC-1155 id. Its on-chain record is small and fixed; everything descriptive lives in a metadata document whose hash is committed on-chain (schema: standard/schema/batch-metadata.schema.json).
const metadata = { schemaVersion: "1.0.0", commodity: { scheme: "HS", code: "100199" }, unit: "KGM", quantity: 250000 };
const canonical = JSON.stringify(metadata); // RFC 8785 canonicalization in production
const metadataHash = ethers.keccak256(ethers.toUtf8Bytes(canonical));
const uri = "ipfs://<cid>/batch-1.json"; // wherever you stored `canonical`
const init = {
commodity: commodity("HS", "100199"), // wheat
unit: unit("KGM"),
producedAt: Math.floor(Date.parse("2026-03-18T00:00:00Z") / 1000), // 0 = not yet harvested
expiresAt: 0, // 0 = no expiry
originRef: ref("DDS-2026-AR-000123"), // a reference, never coordinates
metadataHash,
uri,
};
const id = await token.connect(issuer).createBatch.staticCall(init, 250_000, farmer.address, "0x");
await (await token.connect(issuer).createBatch(init, 250_000, farmer.address, "0x")).wait();
createBatch.staticCall returns the id the transaction will assign; ids start at 1 and increase by one. The token rejects:
| Condition | Error |
|---|---|
caller lacks ISSUER_ROLE | AccessControlUnauthorizedAccount(account, role) |
to is the zero address | ARexInvalidRecipient() |
quantity == 0 | ARexInvalidBatch("ZERO_QUANTITY") |
commodity == 0 or unit == 0 | ARexInvalidBatch("COMMODITY_REQUIRED") / ("UNIT_REQUIRED") |
both dates set and expiresAt <= producedAt | ARexInvalidBatch("EXPIRY_BEFORE_PRODUCTION") |
paused, receiver ineligible, or a hook rejects OP_MINT | EnforcedPause(), ERC7943CannotReceive(to), ARexHookRejected(hook, code) |
Field conventions:
| Field | Convention | Example |
|---|---|---|
commodity | keccak256("<scheme>:<code>"), scheme in HS, FAO, GTIN, UNSPSC or your own | keccak256("HS:100199") wheat |
unit | UN/CEFACT Recommendation 20 code as bytes32 | "KGM", "TNE", "LTR", "BX" |
producedAt | Unix seconds; 0 for a lot not yet harvested | forward sale, crop receivable |
expiresAt | Unix seconds; 0 for no expiry | perishable produce |
originRef | hash of a reference to origin evidence; never coordinates | EUDR DDS reference, registry id |
metadataHash | keccak256 of the canonical (RFC 8785) metadata document | |
uri | where the document lives; keep it short, it is stored on-chain | ipfs://<cid>, GS1 Digital Link |
5 · Read a batch
const batch = await token.batchOf(id); // reverts ARexBatchNotFound if unknown
await token.batchExists(id); // true / false, never reverts
await token["totalSupply(uint256)"](id); // units in circulation (overloaded name)
await token.parentsOf(id); // [] for an original lot
await token.uri(id); // batch URI, or the base URI if empty
await token.isExpired(id); // reference-implementation helper
totalSupply is overloaded in ERC-1155 Supply (totalSupply() and totalSupply(uint256)), so in ethers v6 call it by signature. Batch.status is 0 (ACTIVE), 255 (RETIRED) or an issuer-defined value in between.
6 · Transfer
Always ask first. transferCheck replays the transfer pipeline as a view and returns the first rejection code; 0x00…00 means allowed. canTransfer is the ERC-7943 boolean version of the same answer.
const code = await token.transferCheck(farmer.address, buyer.address, id, 10_000);
if (code !== ethers.ZeroHash) throw new Error(`transfer blocked: ${explain(code)}`);
await token.connect(farmer).safeTransferFrom(farmer.address, buyer.address, id, 10_000, "0x");
The pipeline order is fixed, so the code tells you which rule failed:
| Order | Check | Code returned by transferCheck | Revert on the transaction |
|---|---|---|---|
| 1 | token paused | AREX:PAUSED | EnforcedPause() |
| 2 | sender eligible (every ELIGIBILITY hook) | AREX:SENDER_INELIGIBLE | ERC7943CannotSend(from) |
| 3 | receiver eligible | AREX:RECEIVER_INELIGIBLE | ERC7943CannotReceive(to) |
| 4 | batch exists | AREX:BATCH_NOT_FOUND | ARexBatchNotFound(id) |
| 5 | batch not retired | AREX:BATCH_RETIRED | ARexBatchRetired(id) |
| 6 | amount <= balance − frozen | AREX:FROZEN | ERC7943InsufficientUnfrozenBalance(from, id, amount, unfrozen) |
| 7 | every CHECK hook, in registration order | the hook's code | ARexHookRejected(hook, code) |
The view and the transaction evaluate in the same order, so the code always names the check the transaction fails first.
Two details matter in practice. AREX:FROZEN is also returned when the sender simply does not hold enough units, because the check is on the unfrozen balance. And transferCheck passes empty data to hooks; a hook that reads transfer data (such as DeliveryHook settlement) can answer differently for the real transaction.
7 · Lineage: split, merge, process
transform is the only way to make a batch from batches. It burns the inputs from from, mints the outputs to to, records parentsOf(output) = inputIds and emits one BatchTransformed, which maps to an EPCIS TransformationEvent. transform.staticCall returns the new ids.
A re-batch keeps commodity, unit and total quantity, and needs no role, only ownership or ERC-1155 approval. Split one lot in two for a buyer:
const [lotA, lotB] = await token.connect(farmer).transform.staticCall(
farmer.address, buyer.address, [id], [400], [init, init], [250, 150], ref("SALE-42"));
await token.connect(farmer).transform(
farmer.address, buyer.address, [id], [400], [init, init], [250, 150], ref("SALE-42"));
Merge two lots into one pooled lot for a cooperative:
const [pooled] = await token.connect(buyer).transform.staticCall(
buyer.address, coop.address, [lotA, lotB], [250, 150], [init], [400], ref("POOL-7"));
await token.connect(buyer).transform(
buyer.address, coop.address, [lotA, lotB], [250, 150], [init], [400], ref("POOL-7"));
Processing changes the commodity, the unit or the quantity, so it needs TRANSFORMER_ROLE in addition to approval. The mill turns 1,000 kg of the farmer's wheat into 750 kg of flour:
const now = (await ethers.provider.getBlock("latest")).timestamp;
const flourInit = { ...init, commodity: commodity("HS", "110100"), producedAt: now };
await token.connect(farmer).setApprovalForAll(mill.address, true);
const [flour] = await token.connect(mill).transform.staticCall(
farmer.address, mill.address, [id], [1_000], [flourInit], [750], ref("MILL-RUN-7"));
await token.connect(mill).transform(
farmer.address, mill.address, [id], [1_000], [flourInit], [750], ref("MILL-RUN-7"));
| Rule | Enforced by |
|---|---|
caller is from or approved by from | transform |
| every input exists, is not retired and has enough unfrozen balance | _update |
| re-batch: same commodity and unit, Σ inputs = Σ outputs | no role needed |
| anything else | TRANSFORMER_ROLE, else AccessControlUnauthorizedAccount |
| hooks see every leg | OP_TRANSFORM for burns and mints |
The lineage built by this guide:
flowchart LR
B1["#1 wheat 250,000 kg<br/>farmer"] -->|"split · SALE-42"| B2["#2 wheat 250"]
B1 -->|"split · SALE-42"| B3["#3 wheat 150"]
B2 -->|"merge · POOL-7"| B4["#4 wheat 400<br/>coop"]
B3 -->|"merge · POOL-7"| B4
B1 -->|"process · MILL-RUN-7"| B5["#5 flour 750<br/>mill"]8 · Status and metadata
Only STATUS_ROLE changes status; holding units grants nothing. Values 1..254 are yours to define; publish their meaning in the metadata document (status.table). Updating metadata replaces the hash and URI and emits BatchMetadataUpdated, so the history of documents stays verifiable.
await token.connect(warehouse).setBatchStatus(flour, 1, ref("IN_STORAGE")); // 1..254 are yours
const labReport = { ...metadata, lab: { moisture: 14.2 } };
await token.connect(warehouse).setBatchMetadata(
flour, ethers.keccak256(ethers.toUtf8Bytes(JSON.stringify(labReport))), "ipfs://<cid>/flour-lab.json");
9 · Hooks
Hooks are independent contracts the token calls in registration order. Here the eligibility hook runs first, then expiry, then a rule that coffee needs an EUDR due-diligence statement attested before it can be sold.
const eligibility = await (await ethers.getContractFactory("EligibilityHook")).deploy(admin.address);
const expiry = await (await ethers.getContractFactory("ExpiryHook")).deploy(true); // allow pre-harvest
const attestationHook = await (await ethers.getContractFactory("AttestationHook"))
.deploy(admin.address, await attestations.getAddress());
const COFFEE = commodity("HS", "090111");
const EUDR_DDS = ref("AREX:EUDR:DDS");
await attestationHook.setRequirement(COFFEE, EUDR_DDS); // coffee needs a due-diligence statement
await token.registerHook(await eligibility.getAddress()); // evaluated first
await token.registerHook(await expiry.getAddress());
await token.registerHook(await attestationHook.getAddress()); // evaluated last
await token.getHooks(); // the ordered list
| Hook | Flags | Constructor | Configure with |
|---|---|---|---|
EligibilityHook | CHECK, ELIGIBILITY | (admin) | setAllowlistEnabled, setAllowlisted, setBlocklisted, setSanctioned |
ExpiryHook | CHECK | (allowPreHarvestTransfers) | — (reads the batch record) |
AttestationHook | CHECK | (owner, attestationRegistry) | setRequirement(commodity, topic) |
PerishabilityHook | CHECK | (admin) | setCommodityDecay, setBatchDecay |
SeasonalHook | CHECK | (admin) | addWindow(commodity, op, start, end), clearWindows |
DeliveryHook | CHECK, EXECUTED | (admin) | schedule, pickUp, markInTransit, recordReading, confirmDelivered, dispute, resolve |
TraceabilityHook | EXECUTED | (admin, persist) | recordEvent (RECORDER_ROLE) |
Registration rejects EOAs, contracts without IARexHook ERC-165 support and hooks whose flags are 0. Place cheap, frequently rejecting hooks first: a rejection stops the pipeline. Writing your own hook: docs/HOOK-DEVELOPMENT.md.
10 · Attestations
An attestation is (batch, topic) → (issuer, reference, issuedAt, expiresAt, revoked), valid only while its issuer is trusted for the topic. With the requirement from step 9 in place, the coffee lot cannot move until the statement is attested:
const coffee = await token.connect(issuer).createBatch.staticCall({ ...init, commodity: COFFEE }, 60, farmer.address, "0x");
await token.connect(issuer).createBatch({ ...init, commodity: COFFEE }, 60, farmer.address, "0x");
explain(await token.transferCheck(farmer.address, buyer.address, coffee, 1)); // "ATTESTATION_MISSING"
await attestations.setTrustedIssuer(EUDR_DDS, certifier.address, true); // ATTEST_ADMIN_ROLE
await attestations.connect(certifier).attest(coffee, EUDR_DDS, ref("DDS-2026-AR-000123"), 0);
await attestations.isAttested(coffee, EUDR_DDS); // true
explain(await token.transferCheck(farmer.address, buyer.address, coffee, 1)); // "OK"
isAttested is false when there is no attestation, when it is revoked, when expiresAt has passed, or when the issuer is no longer trusted for the topic. The issuer or ATTEST_ADMIN_ROLE may revoke. Topics are free bytes32 values; use keccak256("<NAMESPACE>:<NAME>"), for example AREX:EUDR:DDS or AREX:CERT:ORGANIC_EU.
11 · Sanctions and eligibility
EligibilityHook answers canSend and canReceive for the token. A sanction records its source and an optional expiry, so a list update can lapse automatically.
await eligibility.setSanctioned(buyer.address, true, ethers.encodeBytes32String("OFAC-SDN"), 0);
explain(await token.transferCheck(farmer.address, buyer.address, id, 1)); // "RECEIVER_INELIGIBLE"
await eligibility.setSanctioned(buyer.address, false, ethers.encodeBytes32String("OFAC-SDN"), 0);
12 · Enforcement
Freezing and forced transfers are the ERC-7943 enforcement surface, for liens, court orders and seizures. A freeze may exceed the current balance and then also covers future deposits. A forced transfer skips eligibility and the frozen check, works while the token is paused, lowers the frozen amount to what remains, and still runs the hooks with OP_FORCED_TRANSFER.
await token.connect(enforcer).setFrozenTokens(buyer.address, id, 6_000); // may exceed the balance
await token.getFrozenTokens(buyer.address, id); // 6_000
explain(await token.transferCheck(buyer.address, farmer.address, id, 5_000)); // "FROZEN"
await token.pause(); // PAUSER_ROLE
await token.connect(enforcer).forcedTransfer(buyer.address, custodian.address, id, 6_000); // works while paused
await token.unpause();
Document the procedure under which ENFORCER_ROLE acts (court order, pledge agreement, sanctions notice) and keep the role behind a multisig. Every action emits Frozen or ForcedTransfer.
13 · Retire
RETIRED (255) is terminal: no transfer, mint or transform, and no way back. Burning remains possible so that delivered or destroyed goods leave the supply.
await token.connect(warehouse).setBatchStatus(flour, 255, ref("DELIVERED")); // RETIRED is terminal
explain(await token.transferCheck(mill.address, buyer.address, flour, 1)); // "BATCH_RETIRED"
await token.connect(mill).burn(mill.address, flour, 750); // burning still works
14 · Index
The lineage graph is the list of BatchTransformed events; nothing needs to be reconstructed from individual transfers.
const transforms = await token.queryFilter(token.filters.BatchTransformed());
const edges = transforms.flatMap((e) =>
e.args.inputIds.flatMap((input) => e.args.outputIds.map((output) => [input, output, e.args.processRef])));
// edges: [1 → 2], [1 → 3], [2 → 4], [3 → 4], [1 → 5]
| Event | Emitted by | Use |
|---|---|---|
BatchCreated(id, issuer, commodity, unit, quantity, to) | createBatch | new lot; EPCIS ObjectEvent ADD |
BatchTransformed(operator, inputIds, inputAmounts, outputIds, outputAmounts, processRef) | transform | lineage edge; EPCIS TransformationEvent |
BatchStatusChanged(id, previousStatus, newStatus, reason) | setBatchStatus | lifecycle |
BatchMetadataUpdated(id, metadataHash, uri) | setBatchMetadata | document history |
TransferSingle, TransferBatch | every balance change | holdings; EPCIS ObjectEvent OBSERVE |
Frozen(account, tokenId, amount), ForcedTransfer(from, to, tokenId, amount) | enforcement | liens, seizures |
HookRegistered(hook, flags, position), HookRemoved(hook) | hook admin | rule changes |
Attested, AttestationRevoked, TrustedIssuerUpdated | ARexAttestations | evidence |
15 · Upgrade
Storage is ERC-7201 namespaced (erc7201:arex.storage.token); append fields to TokenStorage, never reorder or remove them. Validate before every upgrade; the plugin refuses layouts that would corrupt storage.
await upgrades.validateUpgrade(await token.getAddress(), ARexToken, { kind: "uups" });
await upgrades.upgradeProxy(await token.getAddress(), ARexToken); // UPGRADER_ROLE
In production UPGRADER_ROLE sits behind a multisig and a timelock, and the upgrade is rehearsed against a fork of the target chain first.
Testing your integration
npx hardhat test test/standard/ImplementationGuide.test.js # this chapter, executed
npx hardhat test test/standard/ARexToken.test.js # token behaviour and conformance
npx hardhat run scripts/smoke-standard.js # narrated walkthrough of one lot
npx hardhat run scripts/gas-report-standard.js # gas with 0, 1 and 2 hooks, and per hook
test/standard/helpers.js exports deployStack(), batchInit(), the OP, HOOK, STATUS and CODES constants and supply(), for your own tests.
Going to production: checklist
- Reproducible build (
npm ci), CI green, interface ids equal to the specification. - Roles redistributed as in step 3; deployer roles renounced.
- Hooks audited, order documented,
getHooks()published. - Metadata schema version and status table published;
originRefand restricted data free of personal data. - Legal instruments referenced in metadata; classification of the offer settled with counsel.
- Third-party audit of the token, the attestation registry and every registered hook (package:
docs/AUDIT-READINESS.md). - Upgrade rehearsed on a fork with
validateUpgrade.