Skip to main contentA-REX Core

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
StepWhat you getWho actsMain calls
1 · Set upa compiled, tested checkoutdevelopernpm ci, npm test
2 · Deploytoken proxy + attestation registrydeployerupgrades.deployProxy, ARexAttestations
3 · Rolesleast-privilege operatorsadmingrantRole
4 · Createa batch with metadata commitmentissuercreateBatch
5 · Readthe batch record and supplyanyonebatchOf, totalSupply(uint256), parentsOf
6 · Transfera checked transferholdertransferCheck, safeTransferFrom
7 · Lineagesplit, merge, processingholder / transformertransform
8 · Statuslifecycle and metadata updatesstatus rolesetBatchStatus, setBatchMetadata
9 · Hooksan ordered rule pipelinehook adminregisterHook, getHooks
10 · Attestevidence that unlocks transferstrusted issuersetTrustedIssuer, attest
11 · Sanctioneligibility with source and TTLeligibility managersetSanctioned
12 · Enforcefreeze, pause, forced transferenforcer / pausersetFrozenTokens, forcedTransfer
13 · Retireend of life of a lotstatus role, holdersetBatchStatus(…, 255, …), burn
14 · Indexthe lineage graph from eventsindexerqueryFilter(BatchTransformed)
15 · Upgradea validated implementation upgradeupgradervalidateUpgrade, 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);
RoleAllowsRecommended holder
DEFAULT_ADMIN_ROLEgrant and revoke every role; setBaseURIgovernance multisig behind a timelock
UPGRADER_ROLEreplace the implementationsame timelock
HOOK_ADMIN_ROLEregisterHook, removeHooksame timelock (hook changes become visible before they apply)
ENFORCER_ROLEsetFrozenTokens, forcedTransferenforcement multisig (compliance and legal)
PAUSER_ROLEpause, unpauseincident-response key
ISSUER_ROLEcreateBatchorigination service
STATUS_ROLEsetBatchStatus, setBatchMetadatawarehouse, inspector or registry
TRANSFORMER_ROLEprocessing transforms (commodity, unit or quantity change)processors
ATTEST_ADMIN_ROLE (on ARexAttestations)setTrustedIssuer, revoke any attestationcertification 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:

ConditionError
caller lacks ISSUER_ROLEAccessControlUnauthorizedAccount(account, role)
to is the zero addressARexInvalidRecipient()
quantity == 0ARexInvalidBatch("ZERO_QUANTITY")
commodity == 0 or unit == 0ARexInvalidBatch("COMMODITY_REQUIRED") / ("UNIT_REQUIRED")
both dates set and expiresAt <= producedAtARexInvalidBatch("EXPIRY_BEFORE_PRODUCTION")
paused, receiver ineligible, or a hook rejects OP_MINTEnforcedPause(), ERC7943CannotReceive(to), ARexHookRejected(hook, code)

Field conventions:

FieldConventionExample
commoditykeccak256("<scheme>:<code>"), scheme in HS, FAO, GTIN, UNSPSC or your ownkeccak256("HS:100199") wheat
unitUN/CEFACT Recommendation 20 code as bytes32"KGM", "TNE", "LTR", "BX"
producedAtUnix seconds; 0 for a lot not yet harvestedforward sale, crop receivable
expiresAtUnix seconds; 0 for no expiryperishable produce
originRefhash of a reference to origin evidence; never coordinatesEUDR DDS reference, registry id
metadataHashkeccak256 of the canonical (RFC 8785) metadata document
uriwhere the document lives; keep it short, it is stored on-chainipfs://<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:

OrderCheckCode returned by transferCheckRevert on the transaction
1token pausedAREX:PAUSEDEnforcedPause()
2sender eligible (every ELIGIBILITY hook)AREX:SENDER_INELIGIBLEERC7943CannotSend(from)
3receiver eligibleAREX:RECEIVER_INELIGIBLEERC7943CannotReceive(to)
4batch existsAREX:BATCH_NOT_FOUNDARexBatchNotFound(id)
5batch not retiredAREX:BATCH_RETIREDARexBatchRetired(id)
6amount <= balance − frozenAREX:FROZENERC7943InsufficientUnfrozenBalance(from, id, amount, unfrozen)
7every CHECK hook, in registration orderthe hook's codeARexHookRejected(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"));
RuleEnforced by
caller is from or approved by fromtransform
every input exists, is not retired and has enough unfrozen balance_update
re-batch: same commodity and unit, Σ inputs = Σ outputsno role needed
anything elseTRANSFORMER_ROLE, else AccessControlUnauthorizedAccount
hooks see every legOP_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
HookFlagsConstructorConfigure with
EligibilityHookCHECK, ELIGIBILITY(admin)setAllowlistEnabled, setAllowlisted, setBlocklisted, setSanctioned
ExpiryHookCHECK(allowPreHarvestTransfers)— (reads the batch record)
AttestationHookCHECK(owner, attestationRegistry)setRequirement(commodity, topic)
PerishabilityHookCHECK(admin)setCommodityDecay, setBatchDecay
SeasonalHookCHECK(admin)addWindow(commodity, op, start, end), clearWindows
DeliveryHookCHECK, EXECUTED(admin)schedule, pickUp, markInTransit, recordReading, confirmDelivered, dispute, resolve
TraceabilityHookEXECUTED(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]
EventEmitted byUse
BatchCreated(id, issuer, commodity, unit, quantity, to)createBatchnew lot; EPCIS ObjectEvent ADD
BatchTransformed(operator, inputIds, inputAmounts, outputIds, outputAmounts, processRef)transformlineage edge; EPCIS TransformationEvent
BatchStatusChanged(id, previousStatus, newStatus, reason)setBatchStatuslifecycle
BatchMetadataUpdated(id, metadataHash, uri)setBatchMetadatadocument history
TransferSingle, TransferBatchevery balance changeholdings; EPCIS ObjectEvent OBSERVE
Frozen(account, tokenId, amount), ForcedTransfer(from, to, tokenId, amount)enforcementliens, seizures
HookRegistered(hook, flags, position), HookRemoved(hook)hook adminrule changes
Attested, AttestationRevoked, TrustedIssuerUpdatedARexAttestationsevidence

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

  1. Reproducible build (npm ci), CI green, interface ids equal to the specification.
  2. Roles redistributed as in step 3; deployer roles renounced.
  3. Hooks audited, order documented, getHooks() published.
  4. Metadata schema version and status table published; originRef and restricted data free of personal data.
  5. Legal instruments referenced in metadata; classification of the offer settled with counsel.
  6. Third-party audit of the token, the attestation registry and every registered hook (package: docs/AUDIT-READINESS.md).
  7. Upgrade rehearsed on a fork with validateUpgrade.