在 Bloom 上构建
本指南回答一个问题: 构建者如何自行完成这件事? 将 Solidity 现实世界资产合约部署到 Rome 链上作为许可型代币,无需桥接、无需第二种代币,即可向 Solana 钱包和 EVM 钱包开放,并接入你自己的 KYC/合规决策。
目标读者是正在评估或采用此方法的开发者。Bloom 是贯穿全文的实例——其在线资产 ARCV(“矿产金库 I”) 位于 Hadrian(200010)上,是下文每一步的真实实例;其部署回执(deployments/200010.tokens/ARCV.json)记录了此方法产生的确切三个合约和八笔交易。
这里没有修改资产合约。Bloom 运行的是 Plume 的 Arc 框架, 未作修改 (位于 contracts/中的供应商引入代码树,与 plumenetwork/contracts 在 NOTICE中指定版本的字节码完全相同);Rome 侧唯一的合约是 ArcTokenFactoryV2,它只增加了一个函数。工作重点在于 如何 部署并 如何 驱动这些合约——而不是修改它们。
方法概览。 一个许可型代币由 三个 已部署合约组成。它以 分步方式 部署——每个合约一笔交易——因为单笔交易的工厂调用会超过 Rome 的单笔交易账户上限。其代理必须来自 来源锁定的构件 ,否则链上注册会安全失败。合规性是每个地址一个 布尔值,由你的 KYC 流程授权的人员写入。同一部署还可通过链上派生的 合成发送方 从 Solana 钱包访问——无需桥接、无需封装代币、无需第二份允许列表。
前提条件
所有参考工具都位于 scripts/ (先在那里运行 npm install )。每条命令均从注册表解析链信息,而非将其硬编码,因此改用另一条 Rome 链只需改变环境,无需修改代码:
export PRIVATE_KEY=… # 目标链上有资金的部署者/发行方密钥
export CHAIN_ID=200010 # 选择该链
export REGISTRY_ROOT=… # Rome 链注册表的一个检出副本
# Solana 通道流程还需要:
export SOLANA_KEYPAIR=… # 有资金的 Solana 密钥对 JSON 路径(费用支付者)Rome 链上的 Gas 以 USDC 计价;在开发网上,完整部署序列的成本约为 10–15 个原生单位。密钥仅来自环境变量,绝不来自仓库。部署前先构建一次合约,因为构件 必须 来自 rome-contracts/out (见步骤 3):
cd contracts && forge build --via-ir # 引入的 Arc 套件
cd rome-contracts && forge build --via-ir # ArcTokenFactoryV2 + 规范代理步骤 1——了解这里的许可型代币 是 什么
一个许可型 Bloom 代币并非单一合约。它是 三个已部署合约,外加四个每条链由运维部署一次、每个发行方都会复用的共享合约。
每个代币对应的三个合约 ——每项资产恰好拥有以下合约:
ArcTokenProxy
contracts/src/proxy/ArcTokenProxy.sol
代币本身——一个指向共享 ArcToken 实现的 UUPS 代理。余额、持有人和角色均在此处。
WhitelistRestrictions
contracts/src/restrictions/WhitelistRestrictions.sol
允许列表模块。每个地址一个布尔值;资产受限时,每笔转账均会执行该模块。
YieldBlacklistRestrictions
contracts/src/restrictions/YieldBlacklistRestrictions.sol
收益模块。在不影响持仓的情况下,将地址排除在收益分配之外。
四个共享的、每条链一个的合约 (由 deploy-infra.ts):
RestrictionsRouter
模块类型注册表——链路接线,由运维设置一次。
ArcTokenFactoryV2
注册以及规范代理代码哈希(步骤 2–3)。
ArcToken (实现)
每种代币代理所指向的共享、可复用资产逻辑。
ArcTokenPurchase
一个由每种代币和发行方共享的店面(步骤 4)。
wUSDC
资金端——收益货币和销售购买货币。
转账限制和收益限制是 刻意分离的模块:持有人可以在保留资产的同时被禁止获得收益,这正是制裁和法院命令在现实中的运作方式。
这个“三个合约”的事实是验证的关键基础。 verify-deployments.ts 和 register-asset.ts 都要求一个代币能解析为三个已部署合约——曾有一次手动验证代币时,遗漏了一条链上的七个合约(每个收益模块和三个允许列表模块)。
生命周期有三个阶段和一道单向门:
供应量的限制是单向的,因为铸造和销毁会传入 address(0) 作为交易对手方,并且该地址 address(0) 永远无法加入允许列表——因此一旦受限,就再也不能铸造新的供应量。这就是许可型资产向其持有人作出的保证。
步骤 2——分步部署,绝不要作为单笔交易部署
Arc 提供一个一次性调用 createToken ,可在单次调用中部署代理和两个模块,并完成接线。 在 Rome 上不能使用它。 该交易需要 77 个 Solana 账户锁,而 Rome 的单笔交易上限是 62 个锁。它永远无法容纳——无论调优还是进行优化器处理都不行。
因此,部署采用 分步方式:每个合约各自一笔交易,并且代币通过 ArcTokenFactoryV2.registerToken 而非单体式 createToken获得等同工厂创建的状态。这是创建向导,也是发行方控制台逐步执行的流程:

参考实现是 arc/plan/create.ts (createSequence()),应用将其呈现为六张卡片(bloom/lib/createCards.ts),共计 八笔交易,顺序严格由合约决定:
1
部署资产
new ArcTokenProxy(impl, initData) 其中 initData = ArcToken.initialize(...)
2
部署允许列表模块
new WhitelistRestrictions()
3
将你设为其管理员
whitelist.initialize(issuer)
4
在转账时强制执行
token.setRestrictionModule(TRANSFER, whitelist)
5
部署收益模块
new YieldBlacklistRestrictions()
6
将你设为其管理员
yieldBlacklist.initialize(issuer)
7
在收益时强制执行
token.setRestrictionModule(YIELD, yieldBlacklist)
8
向工厂注册
factoryV2.registerToken(token, impl)
脚本形式只需一条命令:
发行方(PRIVATE_KEY)签署每笔交易,并最终持有代币的角色(ArcToken.initialize 将角色授予 msg.sender ——见步骤 7)。注册(交易 8)会解锁店面的 enableToken 以及任何由工厂介导的升级。
不要将其“优化”回单体式方案。 将这些调用批量合并为一笔交易以节省往返次数,会重新引入分步路径旨在避免的 62 锁溢出。同一上限也是收益分配每笔交易仅处理一个持有人的原因(步骤 8)。如果调用失败并显示
Too many accounts: N > 62,则表示一笔交易触及了过多账户——应拆分它,而非调优。
步骤 3——仅从来源锁定的构件部署代理
registerToken 是确保分步部署安全的安全边界。它只接受其 运行时代码哈希与规范代码哈希相匹配的代币,该规范代码哈希在工厂部署时 ArcTokenProxy 被固化进工厂:
由于该代码哈希是 不可变的,由任何其他构件构建的代理都会以 ProxyCodehashUnknown安全失败。由此产生两条规则,二者均由 CI 和 CLAUDE.md:
中的强制规则执行:代币代理必须从
rome-contracts/out部署——绝不能从contracts/out部署,也绝不能从不同的构建产物部署。这正是工厂的规范代码哈希所固定的编译结果。bytecode_hash = "none"和cbor_metadata = false在rome-contracts/foundry.toml中的设置至关重要。启用元数据后,ArcTokenProxy运行时 被嵌入 在工厂中的type().runtimeCode会携带与独立out/构件不同的 CBOR/IPFS 尾部,创建向导从后者部署——因此链上registerToken会回退,而 所有源代码内的 Foundry 测试都会通过 (Foundry 会嵌入两个副本,因此无法发现这一不匹配)。rome-contracts/test/ArtifactProvenance.t.sol固定了这一属性;最初发现它的是一次有资金的运行。
源代码内测试套件全绿并不能证明来源。 在发布镜像之前,以及任何编译器设置变更之后,请针对 已部署的 工厂验证此属性(只读,无需密钥):
它会对你的本地
ArcTokenProxy构件进行哈希,并断言该哈希原样出现在已部署工厂的字节码中。
如果必须变更编译器设置, 轮换工厂 ——部署一个新的工厂,其不可变值固定新的代码哈希,然后重新接线——而不是手动修补检查:
轮换会在回执中保留旧地址;针对旧工厂注册的代币必须重新向新工厂注册。
步骤 4——通过共享店面开售
销售是独立于创建的行为,且在唯一共享的 ArcTokenPurchase 店面上进行。销售开启前必须满足三项条件,且 ArcTokenPurchase.enableToken 会在链上检查每一项:
受限资产还强制要求第四个隐含条件:由于 每笔转账都会经过允许列表门控,只有当店面地址本身已被加入你的代币允许列表时,才能成功将库存转移至店面。因此,完整的开售序列为:
将店面加入白名单 在你的代币允许列表模块中(
batchAddToWhitelist([storefront])——见步骤 5)。若未完成此操作,步骤 (2) 中的库存转移在受限后会回退TransferRestricted()。转移库存 至店面地址(来自基础设施回执中的
arcTokenPurchase).enableToken(token, amount, price)——买方现在支付 wUSDC 并获得 RWA。
在 共享 店面上,了解提款权限划分很重要,且它并不对称(arc/roles.ts 是权威来源):
enableToken / disableToken / withdrawUnsoldArcTokens
代币的 ADMIN_ROLE (onlyTokenAdmin)
你,即发行方
withdrawPurchaseTokens (wUSDC 收益)
店面的 DEFAULT_ADMIN_ROLE
部署店面的人员
在自托管链上,你同时拥有两种权限。在共享店面上,收益池汇集了每个发行方的收入,因此其提现属于平台级操作——应据此设计结算流程,而不是假定自己拥有单方面控制权。
步骤 5——接入你自己的 KYC / 合规体系
这是客户在表示同意前会阅读的章节,因此它是代码,而非文字说明。 合规性由代币合约本身强制执行 ——而非链下筛查、排序器过滤或场所政策。这些规则随代币进入任何执行路径,覆盖两种钱包通道。链上记录的是你 KYC 决策的 结果 ,即一个布尔值:
你的流程写入的接口
你的 KYC 供应商的批准/拒绝决策恰好会转化为以下调用之一:
谁可以写入
批准所需的权限是
MANAGER_ROLE位于 模块 上——而不在ADMIN_ROLE代币上。addToWhitelist/batchAddToWhitelist/removeFromWhitelist均为onlyRole(MANAGER_ROLE),且该角色存在于WhitelistRestrictions实例上。身份验证ADMIN_ROLE先在代币上操作,然后再写入模块,是这个代码库已经为之付出代价的真实缺陷。不要提供WHITELIST_ADMIN_ROLE作为拒绝的修复方案——它在 initialize 时授予,并且 在整个测试套件中从未检查 (arc/roles.ts断言它是那个不限制任何内容的唯一角色)。
WhitelistRestrictions.initialize(issuer) (向导的第 3 笔交易)向发行方授予 DEFAULT_ADMIN_ROLE, ADMIN_ROLE, MANAGER_ROLE, WHITELIST_ADMIN_ROLE,并且 UPGRADER_ROLE 在该模块上。因此,发行方持有 MANAGER_ROLE 开箱即用,并且还可以委派它:因为发行方持有 DEFAULT_ADMIN_ROLE,一个 grantRole(MANAGER_ROLE, serviceAccount) 会将写入权限交给后端签名者。
一个决策变成链上写入
将你的供应商 webhook 指向一个持有以下权限的签名者: MANAGER_ROLE,并在批准时批量处理这些地址:
解析 whitelistModule 从代币本身(ArcToken.getRestrictionModule(TRANSFER_RESTRICTION_TYPE)),而不是从缓存的投影中获取——将写入指向错误的模块,正是“对任意资产进行批准”曾经写入某个代币白名单的原因。
钱包签名版本(自助演示)
对于一个访客可自行进入的无需许可测试部署,授予 MANAGER_ROLE 给一个 TestApprover (rome-contracts/src/TestApprover.sol),其 模块 是 不可变的。然后 approve(address) 可由任何人使用自己的钱包、支付自己的 gas 来调用——它会调用 addToWhitelist 仅当该地址尚未列出时:
这就是“登录门禁”——它向访客展示真实机制(批准变成链上的允许名单条目,随后由代币执行),而不托管任何资产。 切勿授予 MANAGER_ROLE 给一个 TestApprover 在生产代币上 ——这会让该代币的允许名单变成无需许可。
当门禁生效,以及切换供应商
只有在资产被 加上门禁后, (setTransfersAllowed(false), ADMIN_ROLE 在模块上)。在未加门禁时,转账是开放的——先构建你的允许名单,再启用门禁。一旦加上门禁,任一方不在名单中的转账都会以类型化错误回滚 TransferRestricted() (0xe827105e).
之后切换 KYC 提供方不需要任何链上变更。 允许名单模块、其接口以及它存储的布尔值都与提供方无关。要切换供应商,只需将另一个决策来源指向同一个
MANAGER_ROLE签名者——无需重新部署、无需迁移、无需状态变更。链上从不知道是哪个供应商发起了调用,只知道结果。
第 6 步——让同一资产面向 Solana 钱包开放
同样的三个合约可从 Solana 钱包访问 无需桥接,也无需第二个代币。原生 Solana 用户在任何地方都没有 EVM 密钥;取而代之的是,他们的 EVM 身份是 合成的,由交易实际的 Solana 签名者在链上派生:
Rome EVM 程序直接从签名者本身派生此地址(do_tx_unsigned::derive_sender),因此无法伪造,而且不存在对应的 secp256k1 私钥—— Solana 签名是唯一能够驱动该地址的东西。参考派生逻辑位于 arc/materialise/solana/identity.ts (syntheticAddress),与链上规则字节兼容:
构建者为支持该通道所做的事情 ——请注意 其中没有任何一项属于合约更改:
根据合成地址将该 Solana 用户加入白名单。 对合规层而言,合成地址就是普通地址:粘贴用户的 Solana 公钥,派生出合成地址,然后
batchAddToWhitelist([synthetic])。一个允许名单同时覆盖两种钱包世界。在其第一次交易之前,先为其配置一次签名 PDA。 用于授权某个合成地址签名的外部授权 PDA 必须先存在,而创建它是一个 EVM 通道调用(
create_pda)——纯 Solana 用户无法自行完成,因此由发行方进行配置。配置是external_auth,而不是懒加载。通过通道库提交,而不是手工提交。
submitDoTxUnsigned(arc/materialise/solana/submit.ts)会构建一个DoTxUnsigned指令——一个 未签名的 由 Solana 签名授权的 EIP-1559 负载——并处理手工构建交易最容易出错的四件事:账户发现 通过
rome_emulateCallAccounts,它决定每个账户的可写性。调用的value必须 必须转发给发现逻辑,否则一次 value 转账会将接收方的余额 PDA 标记为只读并在链上失败。计算预算 ——一个
DoTxUnsigned需要一个 250 KB 堆帧 以及一个 135 万 CU 限额(Rome 的 EVM 会超出默认的 32 KB / 20 万上限);为 Solana 的 140 万上限预留约 5 万余量。宝库(手续费)钱包 PDA,作为可写附加项——发现逻辑会忽略它。
v0 + 查找表回退方案。 当某次调用的账户数量超过 1232 字节的旧版交易封装时,客户端会通过新的地址查找表重新以 v0 交易提交。(按今天的测量,店面购买在旧版封装中约占 1,061/1232 字节;而首次持有者若其关联代币账户尚待创建,则可能超过该上限。)
用户是 手续费支付者 ,并从其 Solana 钱包支付 lamports(SOL);他们控制的合成地址持有资产,但从不持有 SOL。每次通道发送后,在下一次之前轮询 EVM nonce——索引器会略微落后于 Solana 确认。
该通道仅支持原子执行。 一个
DoTxUnsigned是由 Rome 的原子 VM 在一笔 Solana 交易内执行的一笔 EVM 交易。迭代 VM(一次 EVM 执行分布在多笔 Solana 交易中) 目前尚无法通过该通道到达。无法以原子方式容纳的调用根本不能走这条通道——修复方式是让调用更小,而不是回退。请注意,v0+ALT 回退是一种更大的 封装,而不是不同的执行;它仍然是一笔原子交易。
为什么 RWA 不会泄漏出边界。 该资产驻留于 EVM 状态中——其余额是 Rome EVM 程序账户内的存储。不存在 SPL 铸币 ,也没有任何原生 Solana 侧可移动的对象;该通道的资产移动原语作用于 SPL 代币账户,而 EVM 存储余额并不具备这类账户。Solana 签名能够表达的每一次移动,都会进入代币合约调用,并执行允许名单门禁——已资助的测试套件断言,向非白名单地址转账的回滚结果在 EVM 密钥或 Solana 密钥签名下完全一致。现金部分(wUSDC)是有意的例外:它由 SPL 支持,并且 会 转出到用户自己的代币账户。
第 7 步——了解部署后谁持有权限
ArcToken.initialize 由代理构造函数(第 1 笔交易)执行,并将角色授予 msg.sender ——即向导驱动者,也就是发行方。它明确授予 五个 角色:
构建者绝不能忽视的两个后果:
MINTER_ROLE,BURNER_ROLE,并且UPGRADER_ROLE在 initialize 时不会被授予。 初始供应量是在initialize(一个内部_mint,不需要角色),但 之后的mint或burn则要求发行方先给自己授予该角色。他们可以这么做,因为他们持有DEFAULT_ADMIN_ROLE(每个角色的管理员——没有任何地方调用_setRoleAdmin,因此DEFAULT_ADMIN_ROLE管理着所有角色)。但这是一个刻意的额外步骤,而不是默认可用的能力。UPGRADER_ROLE从不授予工厂。 通过工厂中介的升级(ArcTokenFactoryV2.upgradeToken)要求 工厂本身 持有UPGRADER_ROLE在代币上的该权限——授予给自己不会产生任何作用。该授予是一扇单向门,工厂无法撤销,因此是可选启用的:如果你想要工厂中介升级,grantRole(UPGRADER_ROLE, factory)就显式调用;否则由发行方直接升级代币。
完整的 21 权限模型(六个合约中的 11 个角色名称——同名在不同合约上代表不同权限)已编写在 arc/roles.ts。请查询 rolesRequiredFor(contract, functionName) 而不是读取角色自身所在的合约,因为四个前台函数和两个工厂函数都受制于位于另一个 不同的 合约上的权限。
第 8 步——分发收益
代币以 wUSDC 支付收益(固定为 initialize,可通过 setYieldToken 在 YIELD_MANAGER_ROLE下可更改)。分发会遍历持有人集合:
正确驱动它时需要注意的机制:
完整的
totalAmount仅在startIndex == 0窗口 (safeTransferFrom(msg.sender, this, totalAmount))。在第一个窗口之前先向代币批准该总额;后续窗口则从已持有的余额中支付。nextIndex在……时回绕到0当遍历完成时。 循环调用distributeYieldWithLimit(total, nextIndex, …)直到nextIndex回到 00.受收益限制的持有者份额保留在代币合约中 ——它们会被跳过,而不是重新分配。
在 Rome 上,实际上的上限是
maxHolders = 1每笔交易 ——与第 2 步相同的 62 账户上限——因此每笔交易只遍历一个持有人(实测每个持有人约 318K gas)。每个白名单持有人,无论是 EVM 还是原生 Solana 用户,都会按比例获得收益;参考脚本是measure-yield-batch.ts,而资助版 smoke 测试中的收益步骤会对其进行端到端断言。
第 9 步——验证你部署了什么
Rome 不会自动将源码提交到任何地方,因此验证是你需要手动执行的一步。每次部署后——无论是基础设施还是新代币——都运行只读、无密钥、幂等的验证器(已验证过的合约会被跳过,因此在发行代币后再次运行只会验证新增的合约):
它期望 三个 每个代币对应的合约(代理合约 + 两个模块)以及基础设施,并报告哪些无法验证,而不是让部署失败。手动做过一次后曾漏掉七个合约。
将资产注册到 Rome 注册表时,会把验证视为一个 门禁,而不是装饰(register-asset.ts):代币只有在 apps/arc/<chain>.json 记录——携带着 standard: arc-permissioned-erc20 以及一个 transferRestriction 为其允许名单门禁命名——只有当其三个合约都在 Sourcify 上验证通过时才行。该记录从链上读取事实(向导部署的代币没有已提交的收据),并且只写入本地注册表检出;提交 PR 仍然是人的工作。
若要对整个闭环进行端到端证明——创建、加入白名单、启用门禁、在两条通道上出售、分发收益——请在任意链上运行资助版 smoke 测试:
第 10 步——了解哪些内容留在链下
信任模型是刻意保持透明的,构建者也应当以同样方式向自己的用户呈现:
允许名单标志是你的链下决策。 KYC/AML 发生在你的流程中——你的供应商,你的规则。链上仅记录并执行 结果,
isWhitelisted(addr).链上唯一的状态是每个地址一个布尔值。 没有 PII,没有文档,没有身份声明,没有链上证明,也没有关于地址归属对象的密码学证明。这是 Arc 在 Plume 上使用的模型;它用更轻量的机制和发行方持有的信任,换取了链上身份标准(例如 ERC-3643)中更沉重的转移成本。
恢复是发行方的权限,而不是逃生通道。 无论是 EVM 还是 Solana,丢失的钱包都可以通过将替代地址加入白名单来恢复,必要时再在你自己的法律流程下使用 mint/burn/upgrade(Arc 没有内置强制转账原语)。因此,发行方的密钥托管也是合规姿态的一部分。
没有中继,也没有托管面。 用户在两条通道上都自行持有 gas。手续费支付服务则意味着要持有密钥——这不是 RWA 产品应额外引入的攻击面。
参考:工具
每个脚本都从 scripts/ 之后 npm install,并且 PRIVATE_KEY, CHAIN_ID,并且 REGISTRY_ROOT 被导出。
deploy-infra.ts
每条链一次:四个共享合约,已连接到 wUSDC。
create-token.ts
分步向导——八笔交易,一个代币。
rotate-factory.ts
在编译器/工件变更后部署一个新的工厂;重新连接。
verify-artifact-provenance.ts
证明你的代理工件被已部署的工厂接受(只读)。
verify-deployments.ts
在 Sourcify 上验证每个代币的全部三个合约 + 基础设施(只读)。
register-asset.ts
先验证门禁,然后准备注册表合规记录。
measure-yield-batch.ts
重新测量某条链上的每笔交易收益上限。
smoke.ts
覆盖两条通道的资助版端到端测试套件。
sweep-synthetic-native.ts
从合成账户中回收原生 gas(测试清理)。
更深入的背景资料与本指南并列提供: docs/ARCHITECTURE.md (分层应用模型), docs/LANES.md (两条通道与托管), docs/COMPLIANCE.md (信任模型),以及 docs/USAGE.md (操作手册)。
最后更新于
这有帮助吗?