白皮书 · 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_ROLE | registerHook、removeHook | 同一个时间锁(钩子变更在生效之前即可见) |
ENFORCER_ROLE | setFrozenTokens、forcedTransfer | 强制执行多签(合规与法务) |
PAUSER_ROLE | pause、unpause | 事件响应密钥 |
ISSUER_ROLE | createBatch | 发起服务 |
STATUS_ROLE | setBatchStatus、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_ROLE | AccessControlUnauthorizedAccount(account, role) |
to 是零地址 | ARexInvalidRecipient() |
quantity == 0 | ARexInvalidBatch("ZERO_QUANTITY") |
commodity == 0 或 unit == 0 | ARexInvalidBatch("COMMODITY_REQUIRED") / ("UNIT_REQUIRED") |
两个日期均已设置且 expiresAt <= producedAt | ARexInvalidBatch("EXPIRY_BEFORE_PRODUCTION") |
已暂停、接收方不具备资格,或某个钩子拒绝 OP_MINT | EnforcedPause()、ERC7943CannotReceive(to)、ARexHookRejected(hook, code) |
字段约定:
| 字段 | 约定 | 示例 |
|---|---|---|
commodity | keccak256("<scheme>:<code>"),scheme 取 HS、FAO、GTIN、UNSPSC 或自定义 | keccak256("HS:100199") 小麦 |
unit | UN/CEFACT 第 20 号建议书代码,以 bytes32 表示 | "KGM"、"TNE"、"LTR"、"BX" |
producedAt | Unix 秒;0 表示尚未收获的批次 | 远期销售、作物应收账款 |
expiresAt | Unix 秒;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:PAUSED | EnforcedPause() |
| 2 | 发送方具备资格(每一个 ELIGIBILITY 钩子) | AREX:SENDER_INELIGIBLE | ERC7943CannotSend(from) |
| 3 | 接收方具备资格 | AREX:RECEIVER_INELIGIBLE | ERC7943CannotReceive(to) |
| 4 | 批次存在 | AREX:BATCH_NOT_FOUND | ARexBatchNotFound(id) |
| 5 | 批次未注销 | AREX:BATCH_RETIRED | ARexBatchRetired(id) |
| 6 | amount <= balance − frozen | AREX:FROZEN | ERC7943InsufficientUnfrozenBalance(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
| 钩子 | 标志 | 构造函数 | 配置方式 |
|---|---|---|---|
EligibilityHook | CHECK、ELIGIBILITY | (admin) | setAllowlistEnabled、setAllowlisted、setBlocklisted、setSanctioned |
ExpiryHook | CHECK | (allowPreHarvestTransfers) | —(读取批次记录) |
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) |
注册会拒绝 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、TrustedIssuerUpdated | ARexAttestations | 证据 |
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(),供自己的测试使用。
上线生产:检查清单
- 可复现的构建(
npm ci)、CI 通过、接口 id 与规范一致。 - 按第 3 步重新分配角色;部署方角色已放弃。
- 钩子已审计、顺序已记录、
getHooks()已公布。 - 元数据模式版本和状态表已发布;
originRef和受限数据不含个人数据。 - 法律工具已在元数据中引用;发售的归类已与法律顾问确定。
- 对代币、证明注册表和每一个已注册钩子进行第三方审计(材料包:
docs/AUDIT-READINESS.md)。 - 已使用
validateUpgrade在分叉上演练升级。