A BlockSec 对 HKDAP (Anchorpoint) 的安全与合规审查,截至 2026 年 8 月 13 日。
TL;DR(摘要)。 我们审查了 HKDAP 的已部署合约——这是香港发行的首个受监管稳定币,运行在以太坊主网上——它尚未达到生产就绪状态。其 KYC 和撤销控制无法按书面设计工作,其治理高度集中,单个密钥即可铸造、销毁或冻结,而且其若干链上属性与 HKMA 自身的指引冲突。一条共同主线贯穿其中:该合约从零重新发明了生态系统已经提供并大规模审计的原语(多签、访问控制层、时间锁、ERC-20 本身),而大多数缺陷存在于这些定制机制中,而非在复用标准组件的部分。“Beta Access(测试版访问)”标签并不能弥合这一差距。
2026 年 8 月 12 日,Anchorpoint 启动了 HKDAP 的第一阶段,这是一种港币稳定币。这是一次重要的发布。Anchorpoint 是由渣打银行(香港)牵头、香港电讯(HKT)和 Animoca Brands 参与的合资企业,持有 HKMA 仅授予的两张稳定币发行人牌照之一(在 36 家申请者中),而 HKDAP 是香港《稳定币条例》下首批发行的稳定币之一。
与受监管银行的大多数产品不同,HKDAP 可以直接检查。它在以太坊主网上运行,其合约源码已在 Etherscan 上验证。这是一个有用的属性:对于以这种方式发行的稳定币,管辖发行、转账和冻结的规则不是写在文档中,而是实现在任何人都能阅读且完全按书面代码执行的代码中。牌照是一种声明;已部署合约是这一声明的实现,而且它是公开的。
这使得具体审查成为可能,而我们也正是这样做的。我们沿两个维度检查了已部署合约。第一,作为软件:它是否正确、达到生产级?第二,作为受监管稳定币:其链上行为是否符合 HKMA 的 《持牌稳定币发行人监管指引》?
两个维度的发现是一致的。该合约包含多个功能性缺陷,包括不按书面设计工作的合规控制。其治理高度集中,若干高风险操作可被单个密钥执行。并且其若干链上属性与 HKMA 指引的具体条款冲突。我们的评估是:即使作为 beta 版本,该合约也未达到商业稳定币所需的质量标准。
本文其余部分呈现该审查,信息截至 2026 年 8 月 13 日。我们的审查基于公开部署的代码和可观察的链上事实。我们不对区块链无法确定的事项作任何断言,例如储备支持或链下密钥托管。
我们如何找到该合约
我们从发行人出发,而不是从代币列表出发。Anchorpoint 的公司信息链接到其网站 anchorpoint.hk。Beta Access 页面指明了部署:以太坊主网,代理 0x87622385F960fcCB3121d6D0A9513bd1D9Bed6cA,其 源码已在 Etherscan 上验证。
从那里,我们在链上映射了整个系统:代币代理及其实现(ControllableAHKD,0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc)、管理它的治理合约(0x47dd47a776902bee03abdd5aeff43f41b0ffbc2b)、顶层角色注册表(0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c),以及五个合规模块,每个都有自己的治理合约。下面的每一个关系都是通过读取存储槽并在主网上调用视图函数验证的,而非仅从源码推断。
合约的样貌
HKDAP 的三层结构:治理控制面管理代币,代币在每笔转账时查询五个合规模块。

HKDAP 的代币并未使用 OpenZeppelin 的标准 ERC-20 参考实现;其 ERC-20 逻辑是从零编写的(下文几个缺陷正源于此)。该系统有三层:
- 代币。
ControllableAHKD,位于可升级代理之后。一个受控 ERC-20,具有铸造、销毁、暂停和强制销毁,并在每笔转账中接入合规检查。 - 自研的 M-of-N 治理引擎。 每个特权操作(升级、铸造、销毁、暂停、黑名单、冻结、更改合规模块)都经过请求、批准、执行仪式,而非普通多签。
- 五个合规模块。 黑名单、冻结、KYC 激活服务,以及存款和赎回白名单。每个模块本身都是一个代理,由各自的控制权合约治理。
在结构上,同一个“控制权 + 代理 + 实现”单元重复了六次(代币加五个模块),而所有六个控制权都在同一个注册表中解析其角色。系统中的所有控制最终都汇聚到这一个注册表,而谁能改写其中的角色,最终归结为一小组签名者密钥,如下文详细所示。
第一部分:安全与缺陷
本部分的发现汇总如下;每一行在标注的章节中详述。
| 领域 | 发现 | 位置 | 影响 |
|---|---|---|---|
| 1.1 KYC 与撤销控制失效 | KYC 撤销是死代码 | TokenHolderActivationServerLibrary.sol:221-235 |
isActive 忽略提供者注销(循环从不执行,且使用 == 而非 =);故障开放。 |
| 验证者注销从不设置 INACTIVE | TokenHolderActivationServer.sol:504-511 |
一个“已注销”的验证者仍可注册和停用钱包;第二次调用会回滚。 | |
| KYC 证明从未在链上验证 | TokenHolderActivationServer.sol:575-578 |
证明被传入但被丢弃;任何证明,包括空字符串,都能通过。 | |
| 免费转账豁免可通过拆分绕过 | ControllableAHKD.sol:337-535 |
freeTransferLimit 每次调用检查,而非累计;如果用作未 KYC 活动的上限,可通过拆分为低于限额的转账绕过。 |
|
| 1.2 治理过度集中 | 高风险操作为单签名 | authorizationMatrix;铸造交易 0xa7e53c…b33d7 |
铸造/销毁/冻结/KYC停用(角色 C)、暂停/销毁(角色 D)、黑名单/解冻(角色 F)各用一个密钥执行。 |
| 一对密钥(A + B)可升级所有内容 | authorizationMatrix |
代币及所有五个模块的 upgradeTo 都要求角色 A + B;两个人即可替换任何实现。 |
|
| 同一对密钥可改写所有角色 | 注册表 0xa728… authorizationMatrix |
注册表上的 grantRole / revokeRole 同样要求角色 A + B(ADMIN_ROLE 仅由合约持有;DEFAULT_ADMIN_ROLE = address(0));没有外部密钥可直接更改角色。 |
|
| 一个地址持有六个角色 | 角色注册表 0xa728… |
0x2f7f00… 持有角色 C、ADMIN_TOKEN_HOLDER 以及全部四个审计角色;执行与审计重叠。 |
|
| 撤销不即时;没有时间锁 | HybridControlEngine.sol:158-227 |
已计入的签名在角色被撤销后不会被重新验证;执行与最终签名原子发生,没有延迟。 | |
| 引擎的审计追踪不可靠 | HybridControlEngine.sol:36-129, 177-196 |
evtApprove 将 address(0) 作为签名者发出事件;活动请求列表使用 nonce 0 既作真实 id 又作空标记,因此反向遍历会漏掉第一个请求。 |
|
| 1.3 转账检查不一致 | transfer 和 transferFrom 执行不同规则 |
ControllableAHKD.sol:313-502 |
白名单提前返回仅在 transferFrom 路径绕过 KYC;checkingMode 检查不同对象;同一笔转账受不同规则约束。 |
| 1.4 上线前构建的迹象 | 调试日志、错误注释、名称/哈希不匹配、上传测试文件 | UpgradeableProxy.sol:115-119;ControllableAHKD.sol:25 |
代理回退函数中的 console.log(永久 gas 开销);代理注释与代码矛盾;角色名称/哈希不匹配;上传 117 个文件(含测试);优化器运行次数 = 0。 |
| 1.5 唯一一次升级未修复缺陷 | 追踪唯一一次升级 | 交易 0x742372…85136、0xa7a400…630c |
部署于 4 月 28 日 → 7 月 10 日升级;A + B 仪式的两笔交易相隔一个区块(约 12 秒);第一部分的所有缺陷都在已安装实现中。 |
1.1 KYC 与撤销控制无法按书面设计工作
根据监管要求,HKDAP 服务的 B 端(机构)用户须通过 KYC。在合约中,这意味着它至少要做两件事:在转账时强制 KYC,使未通过 KYC 的钱包被阻止;以及在身份提供者或持有者被移除时撤销访问权限。在 HKDAP 中,这条路径在三个独立的位置都是坏的。
KYC 撤销是死代码。 代币通过调用 KYC 模块上的 isActive(address) 来限制转账。isActive 本应做两件事:首先,根据每个钱包的计数器设置一个基础值,当该钱包被 KYC 激活的次数多于被停用的次数时为 active;然后,如果为该钱包作保的任何身份提供者之后被注销,则将该值收窄为 false。两者涵盖两种撤销:撤销一个持有者的 KYC(计数器),以及撤销整个提供者,使其接入的每个钱包都随之失效(循环)。但只有前者发生。
// contracts/hce/erc20/libs/TokenHolderActivationServerLibrary.sol
221 active = ( tokenHolderRegistration[_address].deactivationCount < tokenHolderRegistration[_address].activationCount ) ;
223 uint256 entryCount = 0 ;
224 uint256 index = chainedItemList.firstEntry ; // 0 if such ChainedList is empty
226 while ( entryCount > chainedItemList.entryCount && active ) {
227 ChainedListLibrary.ChainedItem storage chainedItem = identityProvidersByStatuss[index] ; // deregistered provider
230 // NDLR: .... not too sure about that one to be frank
231 active == ( tokenHolderKYCProofDirectoryByProvider[_address][identityProviders[chainedItem.objectId].identifier] == 0 ) ;
233 entryCount++ ;
234 index = chainedItem.pointNext ;
235 }
第 221 行是一个真正的赋值(=),也是唯一生效的赋值:active 变为 deactivationCount < activationCount。循环(第 226-235 行)本应收窄该值,但它两次失败。第 226 行的条件 entryCount > chainedItemList.entryCount 是 0 > N,因此循环体从不执行;即使执行,第 231 行也使用了 ==,这是一个结果被丢弃的比较,而本应使用 = 赋值。因此 isActive 只返回计数器比较,完全忽略提供者撤销。这是故障开放:注销一个被入侵的 KYC 提供者并不会阻止它接入的钱包继续交易。而且由于 unregisterVerifier 不触及这些计数器,被撤销提供者的钱包保持正计数并保持 active。
提供者撤销在另一端也是坏的。 unregisterVerifier 只移动链表节点;它从不将提供者的目录状态设为 INACTIVE:
// contracts/hce/erc20/modules/TokenHolderActivationServer.sol
504 function _unregisterVerifier(string calldata _verifierId) internal {
505 _removeToList(_verifierId, ACTIVE_IDENTIY_PROVIDER, "60a");
507 uint256 _ipIdx = identityProviders.length - 1;
508 uint256 _newIdx = identityProvidersByStatuss.length + 1;
510 _addToList(_ipIdx, _newIdx, INACTIVE_IDENTIY_PROVIDER, _verifierId, "60b");
511 }
由于状态仍为 ACTIVE,一个“已注销”的验证者仍可注册和停用持有者;原本用于重新激活提供者的分支不可达;第二次调用 unregisterVerifier 会下溢并回滚。
KYC 证明从未在链上验证。 当授权验证者通过 registerOrRenew 注册或续期一个钱包时(该函数设有门控,只有当前活跃的验证者才能调用),流程会到达 _checkKYCProof,它本应根据提供者的方案验证提交的证明。按接口说明,该证明是“预言机的 URI,或来自可验证源的签名哈希”。该函数忽略了它:
// contracts/hce/erc20/modules/TokenHolderActivationServer.sol
575 function _checkKYCProof( string memory _ipIdentifier, string memory, string memory _rcCode ) internal view returns ( bool isVerified ) {
576 require( identityProviderDirectory[_ipIdentifier].status == ACTIVE_IDENTIY_PROVIDER, string.concat(ERROR_404, _rcCode, "41b")) ;
577 return true ;
578 }
证明确实会到达该函数。registerOrRenew 将提交的 kycProof 通过 _registerOrRenew 传递,并作为第二个参数传入。但在第 575 行,该参数落在一个完全没有名字的参数中,函数上方的文档注释也省略了它,函数体从不读取它。该函数只确认提供者处于 active 状态,并在第 577 行返回 true,因此证明被丢弃而非被检查。这不是外部人员可以绕过的,因为只有活跃的验证者才能到达这里;但在链上,合约对 KYC 证据不执行任何验证,因此其完整性完全依赖链下验证者。一个被入侵或粗心的验证者可以用任何证明(包括空字符串)激活任何钱包。
这三者合计意味着一个核心合规属性——门控和撤销访问的能力——无法按书面设计工作。
除这三者外,还有一个相关弱点:免费转账豁免可通过拆分绕过。 代币有 freeTransferLimit,低于该限额的转账会跳过 isActive(KYC)检查。但该限额只与当前转账金额比较;合约不按地址或期间保留累计总额。因此,如果该限额被用作未 KYC 活动的上限,持有者可以将其拆分为多笔刚好低于限额的重复转账,从而移动任意总额,使上限失效。
1.2 治理过度集中,高风险操作为单签名
我们在链上枚举了每一个角色和每一个角色持有者。在具体细节之前,有两点很突出。
首先,授权 M-of-N 仪式的角色没有可读名称。在部署配置中,它们仅以 32 字节哈希形式出现,且没有一个与已验证源码中的具名角色常量匹配(具名角色如 SUPPLY_CONTROLLER_ROLE 由合约自身持有,而非签名者)。我们将六个签名角色标记为 A 到 F。系统中权力最大的角色竟是不透明的标识符,这本身就是弱点:它使治理比使用具名角色更难审查。
其次,持有者数量很少。下表读自链上角色注册表;地址已缩写。
| 角色(我们的标记) | 链上哈希 | 持有者 | 授权内容 |
|---|---|---|---|
| A | 0x37f0f656… |
0x747356d525bf47f495af7c6e42f3ab9b8ba87a4b |
upgradeTo / changeAdmin / setControlAuthority 的第一签名 |
| B | 0x95c27f81… |
0x8117a657df7a1c399231bfe26a096eb8f8ad5973、0x5d9d9b28e83382e694916b0feeaf7a9badbb659c、0xebb73f78e253539acceb4e6cc287095788eee5d4 |
几乎所有双签名操作的第二签名 |
| C | 0xfa2fe896… |
0x2f7f00cc5334fe2861e485ff610f74890a0316ed |
mintToDeposit、burnFrom、freeze、KYC 停用(单签名) |
| D | 0x3dce3265… |
0x5092af62a1625fa57404557d3ad417474f3f494c |
pause、destroyBlackFunds、注册/注销存款与赎回地址、registerVerifier(单签名) |
| E | 0xfd21a76d… |
0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2 |
更改合规模块(setBlacklistServer 等) |
| F | 0x510ac1ff… |
0x7daabe5092d3feea5cc19ddbb5380a4eac38f89c |
addBlackList、unfreeze(单签名) |
从上表可以得出几点。
单签名高风险操作。 大多数高风险操作只需要一个配额为 1 的角色。下表读自代币治理合约及五个模块治理合约的链上 authorizationMatrix(除非显示 + B 第二签名,否则均为单签名):
| 操作 | 治理方 | 所需签名 |
|---|---|---|
mintToDeposit、burnFrom |
代币 | C x1 |
freeze、batchFreeze |
冻结模块 | C x1 |
deactivate、adminDeactivate(KYC) |
KYC 模块 | C x1 |
pause、destroyBlackFunds |
代币 | D x1 |
register、unregister(存款 / 赎回) |
目录模块 | D x1 |
registerVerifier、unregisterVerifier |
KYC 模块 | D x1 |
addBlackList、batchBlackList |
黑名单模块 | F x1 |
unfreeze |
冻结模块 | F x1 |
removeBlackList |
黑名单模块 | F x1 + B x1 |
setBlacklistServer、setFreezingServer、setCheckingMode |
代币 | E x1 + B x1 |
| 目录服务器和供应上限设置器 | 代币 | D x1 + B x1 |
upgradeTo / changeAdmin / setControlAuthority(代币和每个模块)、unpause |
代币 + 模块 | A x1 + B x1 |
发行、冻结和 KYC 停用均为单签名(全部在角色 C 下);暂停、销毁、目录变更和验证者注册在角色 D 下为单签名;黑名单和解冻在角色 F 下为单签名。只有升级和配置更改需要第二签名。注意这种不对称:freeze 和 addBlackList 需要一个签名,而 removeBlackList 需要两个,因此限制一个账户比解除限制更容易。
这不仅仅是对矩阵的解读;它可以在一次真实铸造中观察到。撰写本文时最近的一次发行,交易 0xa7e53c…b33d7,是由唯一的角色 C 账户(0x2f7f00cc5334fe2861e485ff610f74890a0316ed)发送给代币治理合约的单笔交易。它调用 request,同一笔交易即达到法定人数,并从 address(0) 发出铸造的 Transfer 事件,没有单独的批准交易,也没有第二签名者。由于发行在请求者自己的交易内完成,一个密钥同时请求并执行了铸造。
引擎中也完全没有时间锁。一旦最后一个所需签名落地,动作就在同一笔交易中执行,没有任何可以被审查、取消或争议的延迟;引擎记录了 executedAt 时间戳,但从不检查它。因此,即使是双签名操作,一旦第二个密钥签名,也会立即结算。
一对密钥升级所有内容。 升级代币和升级全部五个合规模块使用相同要求:A + B。由于 A 是一个账户,B 是共享同一角色的三个账户,两个人即可替换系统中的任何实现。
同一对密钥还控制角色表。 角色只能在注册表(HybridControlledAuthority,0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c)上通过其自身仪式授予和撤销;没有外部密钥可以直接更改它们。而且它的 authorizationMatrix(链上读取)要求角色变更与升级使用相同签名:角色 A 加角色 B。因此,能替换任何实现的两个人也可以向角色 C 添加密钥、移除现有持有者、重写整个角色表。这个双签名门槛比上面的单签名操作要好,但作为系统根权限仍然过低,因为角色 A 是一个没有冗余的单一账户。
一个地址,六个角色。 角色 C 的持有者(0x2f7f00cc5334fe2861e485ff610f74890a0316ed)还持有具名角色 ADMIN_TOKEN_HOLDER_ROLE(KYC 验证者管理),并且是所有四个审计角色的成员(BLACKLIST_AUDITOR_ROLE、FREEZING_AUDITOR_ROLE、WHITELIST_AUDITOR_ROLE 和 AFL_TOKEN_AUDITOR_HOLDER_ROLE)。该密钥一旦被攻破,将同时失去发行、冻结和 KYC 管理。
审计角色自身有两个问题。 第一,它们限制了合规列表的只读 getter,似乎是为了控制谁能读取这些列表,但在公链上这毫无意义:底层存储任何人都可读取(读取存储槽正是我们映射这个系统的方式),因此列表无论如何都是公开的,这种限制表明设计没有考虑到身处公链。第二,集中度:全部四个审计角色落在相同的 23 个账户上,而执行角色 A、C、D、F 的持有者也在其中,因此那些铸造、销毁、冻结和拉黑的同一批密钥,也坐在本应审查这些动作的群体中。
撤销不即时。 在仪式引擎中,签名者的角色只被检查一次,配额随即递减;之前的签名者永远不会被重新验证,因此事后撤销角色并不会收回已计入的投票:
// contracts/hce/HybridControlEngine.sol
158 for ( uint256 i=0; i < approvalRequest.ceremony.length; i++ ) {
159 if ( !_hasBeenMandated && !approvalRequest.ceremony[i].completed &&
160 IControlAuthority(authorityContract).hasRole( approvalRequest.ceremony[i].expectation.authority, _signer ) ) {
161 _hasBeenMandated = true ;
163 approvalRequest.ceremony[i].expectation.quota-- ;
167 approvalRequest.ceremony[i].completed = _approvalIntent && approvalRequest.ceremony[i].expectation.quota == 0 ;
168 }
最后,引擎自身的审计追踪不可靠。 在每次批准时,evtApprove 事件将 address(0) 作为签名者发出,而非真实批准人;真实签名者只存在于交易发送者和内部记录中,因此事件日志无法归因谁批准了请求。活动请求列表使用 nonce 0 既作真实请求 id 又作空标记,因此反向遍历列表的监控或批准工具会漏掉 nonce 0 的请求。两者都不是关键问题,但对于需要干净审计追踪的受监管系统,两者都会减损。
1.3 transfer 与 transferFrom 之间的转账控制不一致
两者之间的部分差异是预期的。在 transferFrom 中,发起者 msg.sender 是被授权的支出者而非资金源,因此代码在每种模式下都显式检查真实来源 from;transfer 不需要这样做,因为 msg.sender 就是来源。这种适应是合理的。另外两个差异无法用发起者解释,它们使同一经济行为受不同规则约束。
第一,存款和赎回白名单只在 transferFrom 路径中被查询,成员资格会触发提前返回,绕过 isActive(KYC)检查。transfer 从不查询它们。收件人是否在白名单中与谁发起转账无关,因此同一收件人通过 transfer 需要 KYC,但通过 transferFrom 可以跳过:
// contracts/hce/ControllableAHKD.sol : transfer(), "Source" mode gates only the sender
323 else if ( activateMode == ActivateMode.Source )
325 _transferCheckSourceMode(_value, "80h");
// contracts/hce/ControllableAHKD.sol : transferFrom() -> _transferFromCheckDestinationMode(_to)
428 try depositDirectoryServer.isRegistered(_to)
429 returns ( bool isIt, IWhitelistServer.WalletAddress memory ) {
430 if ( isIt ) { return ; }
436 try redemptionDirectoryServer.isRegistered(_to)
437 returns ( bool isIt, IWhitelistServer.WalletAddress memory ) {
438 if ( isIt ) { return ; }
444 try tokenHolderActivationServer.isActive(_to) returns ( bool isIt ) {
445 require(isIt || _value < freeTransferLimit, string.concat(ERROR_404, _rcCode, "/86a" ) ) ;
第二,checkingMode 在两个函数中的含义不同。在 transfer 中,“Source”模式检查发送者;在 transferFrom 中,“Source”模式检查支出者(msg.sender),而 from 在每种模式下都被检查。因此,同一个配置设置会根据入口点强制执行两种不同策略。
结果是,受监管代币的转账控制取决于使用哪个函数,这使它们难以推理,并且在某些配置下可以规避。
1.4 上线前构建的迹象
除了具体的逻辑缺陷外,代码库的若干特性表明,一个上线前构建被部署到了主网。
生产环境中的调试日志。 hardhat/console.log 调用遍布各处,包括代理的回退函数内部,而回退函数会在每笔用户交易中运行。由于代理本身不可升级,这一开销是永久的:
// contracts/proxy/UpgradeableProxy.sol
115 function _beforeFallback() internal virtual override {
116 console.log("iam %s, entering fallback as %s", address(this), msg.sender) ;
117 // require(msg.sender != _getAdmin(), "[UPY]404/02");
118 super._beforeFallback();
119 }
代理文件头注释描述了与代码相反的行为。 同一文件带有 OpenZeppelin 的 TransparentUpgradeableProxy 文档,文档说明管理员永远不能落入实现。该合约故意反其道而行:第 117 行的守卫被注释掉,管理员确实会落入实现。信任该注释的审查者会错误地建模信任边界。
角色名称与其哈希不匹配。 “高风险”(elevated risk)角色在代币和模块中以相同的常量名但不同的 keccak 字符串声明,产生了两个不同角色:
// contracts/hce/ControllableAHKD.sol (token) hash = 0xd2b9...
25 bytes32 internal constant ELEVATEDRISK_OWNER_ROLE = keccak256('ELEVATED_RISK_OWNER_ROLE') ;
// contracts/hce/erc20/modules/BlackListServer.sol (module) hash = 0x4de4...
18 bytes32 internal constant ELEVATEDRISK_OWNER_ROLE = keccak256('ELEVATEDRISK_OWNER_ROLE') ;
部署之所以没有出问题,只是因为每个模块持有自己的副本;另一个角色名(AFL_TOKEN_HOLDER_AUDITOR_ROLE)也以同样方式被调换。
其他迹象。 代理的验证包向公共浏览器上传了 117 个文件,包括项目的测试套件,这等于把内部测试和边界情况交给了读者。最近一次升级只做了编译器警告清理,过程中没有第三方审计。优化器设置为 0 次运行,这使得高频使用的代币的热路径成本更高,而不是更低。
这些单独来看都不严重。但它们合在一起表明,代码没有经历一个在主网持有价值的合约所应具备的发布纪律。
1.5 追踪合约的唯一一次升级
该代理已经升级过一次。它于 2026 年 4 月 28 日以实现 0x8ff12fe3bed22d9e40afb4b98ef4dee28d94699e 部署,并于 2026 年 7 月 10 日升级到当前的 0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc。由于升级是链上治理动作,我们可以确切看到谁批准了它。
upgradeTo 要求角色 A 加角色 B。这次升级是相邻区块中的两笔交易,相隔约十二秒:
- 请求,角色 A,来自
0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2,区块 25500519(交易0x742372…85136); - 批准,角色 B,来自
0x3795300b31429f9d37b0dc805528d9390ce87c50,区块 25500520(交易0xa7a400…630c),该交易达到法定人数并在同一笔交易中执行升级。
有两点很突出。第一,整个双签名仪式在一个区块间隔内完成。从请求到执行只有一个区块;在变更上线之前,第二签名者没有任何窗口可以独立审查。
第二,从当前角色注册表无法识别任何一个签名者。角色此后已经轮换:请求者 0xa9315a… 不再持有角色 A(如今持有角色 E),第二签名者 0x3795300b… 现在不持有任何角色。按现状阅读注册表不会告诉你谁授权了升级;只有交易历史才能。这是从另一面看到的“撤销不即时”属性:角色会变动,因此谁持有什么的快照并不是谁做了什么记录。
而且这次升级没有修复本审查中的任何缺陷。它安装的实现 0xe42d38b0… 正是第一部分所描述的实现。我们无法从链上判断升级前是否进行了第三方审计;我们能说的是,如果进行了审计,那么第一部分的缺陷仍然通过了它。
第二部分:它是否符合香港的稳定币框架?
香港的 《稳定币条例》于 2025 年 8 月 1 日生效,持牌发行人在香港金管局的《持牌稳定币发行人监管指引》下接受监管。我们只比较智能合约能自行满足的条款;储备支持、托管和链下密钥仪式不在链上审查范围内。对于下面每一条款,我们说明指引要求什么、合约做了什么、以及两者在哪里出现分歧。比较在此汇总,并在后续章节详述。
| HKMA 条款 | 要求 | 合约分歧点 | 结论 |
|---|---|---|---|
| 6.5.3 | 高风险操作不得由单方进行(多签,以及速度限制或时间锁等缓释措施) | 铸造、销毁、暂停和冻结各用一个密钥执行;没有时间锁(执行与最终签名原子发生) | 不符 |
| 6.5.4 | 在不同获授权人员之间隔离职责;立即撤销权限 | 一个账户持有六个角色,执行与审计重叠;已计入的批准在之后撤销时仍然有效 | 不符 |
| 6.5.5 | 每次代码变更都须经第三方审计;正确、一致、无漏洞 | 第一部分的缺陷存在于已部署实现中;isActive 和提供者撤销没有做到其名称所示的功能 |
不符 |
| 合规控制的有效性 | 黑名单、冻结、白名单和 KYC 控制必须有效 | 部署代码中的 KYC 门控和提供者撤销不起作用 | 不符 |
| 2.2.3 | 被冻结或销毁的币仍保持完全储备支持且可对账 | 销毁向 address(this) 而非 address(0) 发出 Transfer,且未触动净发行计数器;从事件重建的供应量会发生漂移 |
关注 |
第 6.5.3 段:高风险操作不得由单方进行
要求。 高风险操作应设计为任何单一当事方都不能单方面执行,例如通过多签协议;指引还列出了进一步缓释措施,如速度限制和延时(时间锁)控制。
合约的做法。 从治理合约的 authorizationMatrix(完整矩阵见 1.2 的表格)读取,供应和应急操作各需要一个角色,配额为 1:
| 操作 | 所需签名 |
|---|---|
mintToDeposit |
ROLE_C x1 |
burnFrom |
ROLE_C x1 |
freeze |
ROLE_C x1 |
pause |
ROLE_D x1 |
destroyBlackFunds |
ROLE_D x1 |
分歧点。 铸造、销毁、暂停和冻结各可由一个密钥执行。这不符合“不得由单方单方面进行”的要求。也没有时间锁:如 1.2 所示,一旦达到法定人数,动作就在同一笔交易中执行,因此指引点名的缓释措施之一——时间延迟——也不存在。合约确实实现了供应速度限制(whenWithinRiskThresholds),因此该缓释措施存在,但它不能替代操作本身的多签控制。
第 6.5.4 段:职责隔离与即时撤销
要求。 不同操作应在不同获授权人员之间隔离,且获授权人员的权限应可立即撤销。
合约的做法。 一个外部拥有账户持有六个角色(发行、冻结、KYC 管理以及全部四个审计角色),因此执行和审计角色重叠。角色变更本身也只需要角色 A 加角色 B(见 1.2),因此同一个小组既决定谁被授权,又能执行和升级。而且,如果签名者的角色之后被撤销,已计入的签名不会被重新验证。
分歧点。 职责集中而非隔离,且撤销不即时:被撤销签名者早先的批准仍会计入之后的执行。
第 6.5.5 段:每次代码变更都须审计;正确、一致、无漏洞
要求。 合格的第三方应为每次代码变更审计智能合约,并确认它们:(i)实现正确;(ii)与预期功能一致;(iii)以高度置信度无漏洞。
合约的做法。 当前实现是 7 月 10 日升级安装的实现(见 1.5 的追踪),第一部分的缺陷就在其中。
分歧点。 我们无法在链下看到是否进行了审计,但无论是否进行,结果都不达标。由于 isActive 和提供者撤销没有做到其名称所示的功能,条件(i)和(ii)未满足;而由于第一部分的缺陷存在于部署代码中,(iii)也未满足。
合规控制的有效性
要求。 指引的生命周期模型(黑名单、冻结、白名单、KYC)假定这些控制是有效的。
合约的做法。 如 1.1 所示,KYC 门控和提供者撤销在部署代码中不起作用。
分歧点。 一个必须起作用的控制不起作用。这是一个实质性的差距,而不是形式问题。
第 2.2.3 段:被冻结或销毁的币仍保持完全储备支持且可对账
要求。 因执法行动而被冻结或销毁的稳定币应保持完全储备支持,以便供应量与储备金可以对账。
合约的做法。 受监管稳定币通常的补救流程是销毁坏地址上的资金,然后作为单独铸造向受害者重新发行等额资金(USDT 的 destroyBlackFunds 加 issue 就是这样运作的)。HKDAP 的 destroy 会减少 _totalSupply,这属于销毁,但它从不增加 balances[address(this)];它向 address(this) 而非 address(0) 发出 Transfer;并且它不触动铸币上限所用的净发行计数器:
// contracts/hce/ControllableAHKD.sol
628 function destroyBlackFunds(address _blackListedUser) external override whenNotPaused onlySupplyDestroyer() {
636 uint dirtyFunds = balanceOf(_blackListedUser);
637 balances[_blackListedUser] = 0;
638 _totalSupply = _totalSupply - dirtyFunds ;
639 emit DestroyedBlackFunds(_blackListedUser, dirtyFunds);
640 emit Transfer(_blackListedUser, address(this), dirtyFunds);
641 }
分歧点。 状态变化是销毁,但事件却表示代币转移到合约,而合约从未持有任何代币(balances[address(this)] 保持为零)。索引器会把合约并不持有的代币记到 address(this) 头上,从事件重建的总供应量将与链上不匹配。这也会混淆补救本身:由于代币被销毁而非停放,向受害者重新发行必须是新铸造,但误导性的 Transfer(..., address(this), ...) 暗示合约现在托管了这些代币并能转发它们,而它不能。干净地销毁到 address(0) 再加上单独重新发行,才是既正确又可对账的。按现状,储备对账所依赖的链上会计会偏离链的真实状态。这是一个关注点,而不是完全通过。
我们将这些发现限定在链上可见的范围内。储备金是否得到完全支持、密钥是否存放在 HSM(硬件安全模块)或气隙环境中、以及交易在签名前是否在链下模拟,这些从合约中不可见,我们也不对其作任何断言。
结论
本审查的两个维度得出一致图景。作为软件,HKDAP 包含功能性缺陷,包括不按书面设计执行的合规控制,并表现出若干上线前构建的迹象。作为受监管稳定币,其若干链上属性与 HKMA 指引的具体条款冲突。基于链上证据,并搁置我们无法看到的链下事项,已部署合约尚未达到商业稳定币应达到的标准。
接下来有两点观察,值得直说。
第一,在公链上发行改变了合规决策发生的位置。像“任何单一当事方都不应能单方面行动”这样的要求,是否被满足取决于部署代码中的角色检查,而该代码是公开的。实现——而非牌照或文档——才是这类要求实际被满足或未满足的地方,任何人都可以验证是哪一种。
第二,“Beta Access(测试版访问)”标签不会改变已部署代码的风险状况。该合约在以太坊主网上线,由真实密钥管理,并代表对港币的索取权。因此,无论它如何标注,都应达到生产级标准。
具体发现背后有一条共同主线。该架构以定制形式重新发明了生态系统已经提供并经过大规模审计的原语:在可以使用 Safe 多签配合 OpenZeppelin 的 AccessManager 和 TimelockController 的地方,它从零编写了审批引擎和角色层;用 handwritten 链表集合替代 EnumerableSet;用修改版代理替代标准 TransparentUpgradeableProxy;用手写 ERC-20 替代 OpenZeppelin 的 ERC-20。本审查中的大多数缺陷存在于这些定制机制中,而非在复用标准组件的部分。 它读起来像是按通用软件的方式做抽象,而不是用链上开发所偏好的小型、经审计的构建块组合而成;在链上开发中,每一层定制抽象同时也是 gas、攻击面和升级风险。用那些标准组件构建的设计会更小、更安全、更易于审查,并且会带有当前系统所缺少的东西:时间锁带来的审议窗口,以及具名、清晰的角色。
这里描述的问题是可以解决的。在高风险操作上恢复多签、将执行与审计分离、修复 KYC 撤销逻辑、统一转账检查、移除调试代码、以及要求每次升级前进行第三方审计,可以解决其中大部分问题。使用已验证源码部署在主网,也正是使本次审查成为可能的原因,也是正确的默认做法。BlockSec 所做的正是这种持续的链上安全与合规审查,我们很乐意提供帮助。



