Skip to main contentA-REX Core

白皮书 · 11

实施指南

从一份干净的克隆开始,逐步走到一个已部署、已挂接钩子、已获证明的批次代币。本章中的每一个 JavaScript 代码块都复制自 test/standard/ImplementationGuide.test.js 并在 CI 中运行;如果这里的某个代码块有误,构建就会失败。

如何使用本指南

各步骤前后衔接并共享变量:token、attestations、签名者,以及沿途创建的 id。第一次阅读时请按顺序阅读。可以用以下命令自行运行:

npx hardhat test test/standard/ImplementationGuide.test.js
步骤产出执行者主要调用
1 · 准备一份已编译、已测试的检出代码开发者npm ci、npm test
2 · 部署代币代理 + 证明注册表部署方upgrades.deployProxy、ARexAttestations
3 · 角色最小权限的操作方管理员grantRole
4 · 创建带元数据承诺的批次签发方createBatch
5 · 读取批次记录与供应量任何人batchOf、totalSupply(uint256)、parentsOf
6 · 转移一次经过检查的转移持有人transferCheck、safeTransferFrom
7 · 谱系拆分、合并、加工持有人 / 转化方transform
8 · 状态生命周期与元数据更新状态角色setBatchStatus、setBatchMetadata
9 · 钩子一条有序的规则管线钩子管理员registerHook、getHooks
10 · 证明解锁转移的证据可信签发方setTrustedIssuer、attest
11 · 制裁带来源和 TTL 的资格资格管理员setSanctioned
12 · 强制执行冻结、暂停、强制转移强制执行方 / 暂停方setFrozenTokens、forcedTransfer
13 · 注销批次的生命周期终结状态角色、持有人setBatchStatus(…, 255, …)、burn
14 · 索引由事件得到的谱系图索引器queryFilter(BatchTransformed)
15 · 升级经过验证的实现升级升级者validateUpgrade、upgradeProxy

1 · 准备

Node.js 20 或 22 LTS 以及 npm。依赖项固定在 package-lock.json 中(OpenZeppelin 5.3.0);请使用 npm ci,使构建结果与 CI 所构建的一致。

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

下面的代码片段使用三个小型辅助函数,并命名了十个账户。explain 把拒绝代码还原为其名称;该列表即 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 · 部署

参考代币是 UUPS 可升级的,因此使用 OpenZeppelin 升级插件将其部署在 ERC-1967 代理之后,该插件同时会验证存储布局。证明注册表是一个绑定到代币的普通合约。

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());

在命令行中,同一套技术栈加上资格钩子和到期钩子,可通过以下命令部署:

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

或使用 Hardhat Ignition:

npx hardhat ignition deploy ignition/modules/ARexToken.js --network localhost \
  --parameters '{"ARexTokenModule":{"baseURI":"ipfs://<cid>/{id}.json","allowPreHarvestTransfers":true}}'

对于测试网或 L2,hardhat.config.js 定义了 sepolia、polygon 和 arbitrum;请在 .env 中设置 RPC URL 和 PRIVATE_KEY(模板见 .env.example)。ARexToken 在 optimizer.runs = 1 下编译为 24,136 字节,低于 24,576 字节的 EIP-170 上限。

3 · 角色

initialize(admin, baseURI) 把每个角色都授予 admin,这对首次部署很方便,但对生产环境是错误的。请把每个运营角色授予实际履行它的服务或多签:

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);
角色允许推荐持有者
DEFAULT_ADMIN_ROLE授予和撤销每个角色;setBaseURI位于时间锁之后的治理多签
UPGRADER_ROLE替换实现同一个时间锁
HOOK_ADMIN_ROLEregisterHook、removeHook同一个时间锁(钩子变更在生效之前即可见)
ENFORCER_ROLEsetFrozenTokens、forcedTransfer强制执行多签(合规与法务)
PAUSER_ROLEpause、unpause事件响应密钥
ISSUER_ROLEcreateBatch发起服务
STATUS_ROLEsetBatchStatus、setBatchMetadata仓库、检验员或登记簿
TRANSFORMER_ROLE加工类转化(商品、单位或数量发生变化)加工商
ATTEST_ADMIN_ROLE(位于 ARexAttestations 上)setTrustedIssuer、撤销任何证明认证机构或平台

当每个角色都有了持有者之后,部署方通过 renounceRole(role, deployer) 放弃其不再需要的角色。

4 · 创建批次

批次就是一个 ERC-1155 id。它的链上记录很小且固定;所有描述性内容都存放在元数据文档中,该文档的哈希已承诺上链(模式: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 返回该交易将分配的 id;id 从 1 开始,每次加一。代币会拒绝以下情形:

条件错误
调用者缺少 ISSUER_ROLEAccessControlUnauthorizedAccount(account, role)
to 是零地址ARexInvalidRecipient()
quantity == 0ARexInvalidBatch("ZERO_QUANTITY")
commodity == 0 或 unit == 0ARexInvalidBatch("COMMODITY_REQUIRED") / ("UNIT_REQUIRED")
两个日期均已设置且 expiresAt <= producedAtARexInvalidBatch("EXPIRY_BEFORE_PRODUCTION")
已暂停、接收方不具备资格,或某个钩子拒绝 OP_MINTEnforcedPause()、ERC7943CannotReceive(to)、ARexHookRejected(hook, code)

字段约定:

字段约定示例
commoditykeccak256("<scheme>:<code>"),scheme 取 HS、FAO、GTIN、UNSPSC 或自定义keccak256("HS:100199") 小麦
unitUN/CEFACT 第 20 号建议书代码,以 bytes32 表示"KGM"、"TNE"、"LTR"、"BX"
producedAtUnix 秒;0 表示尚未收获的批次远期销售、作物应收账款
expiresAtUnix 秒;0 表示无到期易腐农产品
originRef指向来源证据的引用的哈希;绝不是坐标EUDR DDS 参考编号、登记簿 id
metadataHash规范(RFC 8785)元数据文档的 keccak256
uri文档所在位置;应保持简短,因为它存储在链上ipfs://<cid>、GS1 Digital Link

5 · 读取批次

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 在 ERC-1155 Supply 中是重载的(totalSupply() 和 totalSupply(uint256)),因此在 ethers v6 中应按签名调用。Batch.status 是 0(ACTIVE)、255(RETIRED),或介于两者之间的、由签发方定义的值。

6 · 转移

务必先询问。transferCheck 以视图方式重放转移管线,并返回第一个拒绝代码;0x00…00 表示允许。canTransfer 是同一答案的 ERC-7943 布尔版本。

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");

管线顺序是固定的,因此代码本身就指明了是哪条规则未通过:

顺序检查transferCheck 返回的代码交易上的回滚
1代币已暂停AREX:PAUSEDEnforcedPause()
2发送方具备资格(每一个 ELIGIBILITY 钩子)AREX:SENDER_INELIGIBLEERC7943CannotSend(from)
3接收方具备资格AREX:RECEIVER_INELIGIBLEERC7943CannotReceive(to)
4批次存在AREX:BATCH_NOT_FOUNDARexBatchNotFound(id)
5批次未注销AREX:BATCH_RETIREDARexBatchRetired(id)
6amount <= balance − frozenAREX:FROZENERC7943InsufficientUnfrozenBalance(from, id, amount, unfrozen)
7每一个 CHECK 钩子,按注册顺序该钩子的代码ARexHookRejected(hook, code)

视图和交易按相同的顺序求值,因此代码始终指明交易最先未通过的那项检查。

实践中有两个细节很重要。当发送方只是持有的单位不足时,同样会返回 AREX:FROZEN,因为检查针对的是未冻结余额。此外,transferCheck 向钩子传递的是空的 data;读取转移数据的钩子(例如 DeliveryHook 结算)对真实交易可能给出不同的答案。

7 · 谱系:拆分、合并、加工

transform 是由批次生成批次的唯一方式。它从 from 销毁输入,向 to 铸造输出,记录 parentsOf(output) = inputIds,并发出一个 BatchTransformed,后者对应于 EPCIS TransformationEvent。transform.staticCall 返回新的 id。

重新分批保持商品、单位和总量不变,不需要任何角色,只需所有权或 ERC-1155 授权。为买方把一个批次拆分为两个:

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"));

为合作社把两个批次合并为一个汇集批次:

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"));

加工会改变商品、单位或数量,因此除授权之外还需要 TRANSFORMER_ROLE。磨坊把农户的 1,000 千克小麦加工成 750 千克面粉:

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"));
规则由谁保证
调用者是 from 或已获 from 授权transform
每个输入都存在、未注销且有足够的未冻结余额_update
重新分批:商品和单位相同,Σ 输入 = Σ 输出无需角色
其他任何情形TRANSFORMER_ROLE,否则 AccessControlUnauthorizedAccount
钩子能看到每一笔变动销毁和铸造均使用 OP_TRANSFORM

本指南所构建的谱系:

flowchart LR
    B1["#1 小麦 250,000 千克<br/>农户"] -->|"拆分 · SALE-42"| B2["#2 小麦 250"]
    B1 -->|"拆分 · SALE-42"| B3["#3 小麦 150"]
    B2 -->|"合并 · POOL-7"| B4["#4 小麦 400<br/>合作社"]
    B3 -->|"合并 · POOL-7"| B4
    B1 -->|"加工 · MILL-RUN-7"| B5["#5 面粉 750<br/>磨坊"]

8 · 状态与元数据

只有 STATUS_ROLE 能更改状态;持有单位不授予任何权利。取值 1..254 留给使用者自行定义;请在元数据文档(status.table)中发布其含义。更新元数据会替换哈希和 URI 并发出 BatchMetadataUpdated,因此文档的历史仍然可验证。

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 · 钩子

钩子是代币按注册顺序调用的独立合约。这里先运行资格钩子,然后是到期钩子,再然后是一条规则:咖啡必须先有经证明的 EUDR 尽职调查声明,才能出售。

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
钩子标志构造函数配置方式
EligibilityHookCHECK、ELIGIBILITY(admin)setAllowlistEnabled、setAllowlisted、setBlocklisted、setSanctioned
ExpiryHookCHECK(allowPreHarvestTransfers)—(读取批次记录)
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)

注册会拒绝 EOA、不支持 IARexHook ERC-165 的合约,以及标志为 0 的钩子。把成本低、经常拒绝的钩子放在前面:一次拒绝就会终止管线。编写自己的钩子:docs/HOOK-DEVELOPMENT.md。

10 · 证明

证明是 (batch, topic) → (issuer, reference, issuedAt, expiresAt, revoked),仅在其签发方对该主题受信任期间有效。在第 9 步的要求就位之后,咖啡批次在该声明获得证明之前无法移动:

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"

当不存在证明、证明已被撤销、expiresAt 已过,或签发方对该主题不再受信任时,isAttested 为 false。签发方或 ATTEST_ADMIN_ROLE 可以撤销。主题是自由的 bytes32 值;请使用 keccak256("<NAMESPACE>:<NAME>"),例如 AREX:EUDR:DDS 或 AREX:CERT:ORGANIC_EU。

11 · 制裁与资格

EligibilityHook 为代币回答 canSend 和 canReceive。一条制裁记录其来源和可选的到期时间,因此名单更新可以自动失效。

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 · 强制执行

冻结和强制转移是 ERC-7943 的强制执行接口,用于留置权、法院命令和查封。冻结数量可以超过当前余额,此时它也涵盖未来的存入。强制转移会跳过资格检查和冻结检查,在代币暂停期间也能工作,把冻结数量下调至剩余余额,并且仍会以 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();

请记录 ENFORCER_ROLE 据以行事的程序(法院命令、质押协议、制裁通知),并把该角色置于多签之后。每次操作都会发出 Frozen 或 ForcedTransfer。

13 · 注销

RETIRED(255)是终态:不能转移、铸造或转化,也无法回头。销毁仍然可以进行,使已交付或已销毁的货物退出供应量。

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 · 索引

谱系图就是 BatchTransformed 事件的列表;无需从单独的转移中重建任何东西。

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]
事件发出者用途
BatchCreated(id, issuer, commodity, unit, quantity, to)createBatch新批次;EPCIS ObjectEvent ADD
BatchTransformed(operator, inputIds, inputAmounts, outputIds, outputAmounts, processRef)transform谱系边;EPCIS TransformationEvent
BatchStatusChanged(id, previousStatus, newStatus, reason)setBatchStatus生命周期
BatchMetadataUpdated(id, metadataHash, uri)setBatchMetadata文档历史
TransferSingle、TransferBatch每一次余额变动持仓;EPCIS ObjectEvent OBSERVE
Frozen(account, tokenId, amount)、ForcedTransfer(from, to, tokenId, amount)强制执行留置权、查封
HookRegistered(hook, flags, position)、HookRemoved(hook)钩子管理规则变更
Attested、AttestationRevoked、TrustedIssuerUpdatedARexAttestations证据

15 · 升级

存储采用 ERC-7201 命名空间(erc7201:arex.storage.token);只向 TokenStorage 追加字段,绝不重排或删除。每次升级之前都要验证;插件会拒绝会破坏存储的布局。

await upgrades.validateUpgrade(await token.getAddress(), ARexToken, { kind: "uups" });
await upgrades.upgradeProxy(await token.getAddress(), ARexToken);               // UPGRADER_ROLE

在生产环境中,UPGRADER_ROLE 位于多签和时间锁之后,并且升级会先在目标链的分叉环境上演练。

测试集成

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 导出 deployStack()、batchInit()、OP、HOOK、STATUS 和 CODES 常量以及 supply(),供自己的测试使用。

上线生产:检查清单

  1. 可复现的构建(npm ci)、CI 通过、接口 id 与规范一致。
  2. 按第 3 步重新分配角色;部署方角色已放弃。
  3. 钩子已审计、顺序已记录、getHooks() 已公布。
  4. 元数据模式版本和状态表已发布;originRef 和受限数据不含个人数据。
  5. 法律工具已在元数据中引用;发售的归类已与法律顾问确定。
  6. 对代币、证明注册表和每一个已注册钩子进行第三方审计(材料包:docs/AUDIT-READINESS.md)。
  7. 已使用 validateUpgrade 在分叉上演练升级。