返回博客

HKDAP稳定币安全审查:已上线、已持牌,但尚未就绪

Phalcon Security
2026年8月14日
阅读约 27 分钟
核心要点
  • HKDAP的KYC撤销是无效代码,其KYC证明从未在链上进行验证

  • 单一密钥可以铸造、销毁、暂停或冻结;两个密钥控制所有升级和角色

  • 多个链上属性与香港金融管理局(HKMA)稳定币发行人指引存在偏离

A BlockSec 对 HKDAP (Anchorpoint) 的安全与合规审查,截至 2026 年 8 月 13 日。

TL;DR。 我们审查了 HKDAP 的已部署合约——香港发行的第一种受监管稳定币,运行在以太坊主网上——它尚未达到生产就绪标准。其 KYC 和撤销控制并未按书面描述工作,其治理足够集中,单个密钥即可铸造、销毁或冻结,而且其若干链上属性与香港金管局自己的指引相冲突。一个共同主线贯穿其中:该合约从头重新发明了生态系统已经提供并大规模审计过的原语(多签名、访问控制层、时间锁、ERC-20 本身),而大多数缺陷存在于这些定制机制中,而非存在于复用标准组件的部分。“Beta Access”标签并不能弥合这一差距。

2026 年 8 月 12 日,Anchorpoint 启动了 HKDAP 的第一阶段,一种港元稳定币。这是一次重要的发布。Anchorpoint 是由渣打银行(香港)牵头、与 HKT 和 Animoca Brands 共同成立的合资企业,持有 香港金管局在 36 家申请者中仅发放的两张稳定币发行人牌照之一,而 HKDAP 是香港《稳定币条例》下首批发行的稳定币之一。

与受监管银行的大多数产品不同,HKDAP 可以直接检查。它运行在以太坊主网上,其合约源码已在 Etherscan 上验证。这是一个有用的特性:对于以这种方式发行的稳定币,管理发行、转账和冻结的规则不是写在文件中,而是实现在任何人都能阅读、并且严格按书面代码执行的代码中。牌照是一种声明;已部署的合约就是该声明的实现,而且它是公开的。

这使得具体审查成为可能,而我们也正是这样做的。我们从两个维度审视了已部署的合约。第一,作为软件:它是否正确、达到生产级别?第二,作为受监管稳定币:它的链上行为是否符合香港金管局的 《持牌稳定币发行人监管指引》

两个维度的发现是一致的。该合约包含多个功能性缺陷,包括未按书面描述工作的合规控制。其治理高度集中,若干高风险操作可由单个密钥执行。而且其若干链上属性与香港金管局指引的具体条款相冲突。我们的评估是:即使作为测试版,该合约也未达到商业稳定币所需的质量标准。

本文其余部分介绍该审查,截至 2026 年 8 月 13 日的最新情况。我们的审查基于公开部署的代码和可观察的链上事实。对于区块链无法证实的事项,例如储备支持或链下密钥托管,我们不作任何断言。

我们如何找到该合约

我们从发行人入手,而不是从代币列表入手。Anchorpoint 的公司页面链接到其网站 anchorpoint.hk。Beta Access 页面写明了部署信息:以太坊主网,代理 0x87622385F960fcCB3121d6D0A9513bd1D9Bed6cA,其 源码已在 Etherscan 上验证

从那里,我们在链上映射了整个系统:代币代理及其实现(ControllableAHKD0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc)、管理它的治理合约(0x47dd47a776902bee03abdd5aeff43f41b0ffbc2b)、顶层角色注册表(0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c),以及五个合规模块,每个模块都有自己的治理合约。下文中的每个关系都是通过在主网上读取存储槽和调用 view 函数验证的,而非仅从源码推断。

合约的结构

HKDAP 的三层结构:治理控制平面管理代币,代币在每次转账时查询五个合规模块。 image.png

HKDAP 的代币没有使用 OpenZeppelin 的标准 ERC-20 参考实现;其 ERC-20 逻辑是从头编写的(下文几个 bug 正源于此)。系统有三层:

  • 代币。 ControllableAHKD,位于一个可升级代理之后。这是一个受控制的 ERC-20,支持 mint、burn、pause 和 forced-destroy,并在每次转账中接入合规检查。
  • 自研的 M-of-N 治理引擎。 每一个特权操作(升级、铸造、销毁、暂停、拉黑、冻结、更改合规模块)都要经过 request、approve、execute 仪式,而不是普通的多签名。
  • 五个合规模块。 黑名单、冻结、KYC 激活服务,以及存款和赎回白名单。每个模块本身都是一个代理,由各自的控制权限合约治理。

在结构上,这种“控制权限 + 代理 + 实现”的单元重复了六次(代币加五个模块),而所有六个控制权限都在一个注册表中解析其角色。系统中的所有控制最终都汇聚到这个注册表,而谁能改写其中的角色,最终取决于极少数签名者密钥,如下文详述。

第一部分:安全与漏洞

本部分的发现总结如下;每一行在所指出的章节中有详细说明。

领域 发现 位置 影响
1.1 KYC 与撤销控制失效 KYC 撤销是死代码 TokenHolderActivationServerLibrary.sol:221-235 isActive 忽略提供方注销(循环从不运行,且用的是 == 而非 =);失败即放行。
验证者注销从不设置 INACTIVE TokenHolderActivationServer.sol:504-511 “已注销”的验证者仍可为钱包注册和停用;第二次调用会 revert。
KYC 证明从未在链上验证 TokenHolderActivationServer.sol:575-578 证明被传入但被丢弃;任何证明(包括空字符串)都能通过。
免费转账豁免可被拆分绕过 ControllableAHKD.sol:337-535 freeTransferLimit 是按每次调用检查,而非累计;如果用作未通过 KYC 活动的上限,可通过拆分为低于限额的多次转账来绕过。
1.2 治理过度集中 高风险操作是单签名 authorizationMatrix;铸造交易 0xa7e53c…b33d7 mint / burn / freeze / KYC 停用(角色 C)、pause / destroy(角色 D)、blacklist / unfreeze(角色 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 evtApproveaddress(0) 作为签名者发出;活动请求列表将 nonce 0 既用作真实 id 又用作空标记,因此反向遍历会遗漏第一个请求。
1.3 转账检查不一致 transfertransferFrom 执行不同规则 ControllableAHKD.sol:313-502 白名单提前返回只在 transferFrom 中绕过 KYC;checkingMode 检查不同的对象;同一笔转账受到不同规则约束。
1.4 生产前构建的迹象 调试日志、错误注释、名称/哈希不匹配、测试文件被上传 UpgradeableProxy.sol:115-119ControllableAHKD.sol:25 代理 fallback 中的 console.log(永久 gas 开销);代理注释与代码矛盾;角色名称/哈希不匹配;上传了 117 个文件(含测试);optimizer runs = 0。
1.5 唯一一次升级未修复缺陷 追踪那次唯一的升级 交易 0x742372…851360xa7a400…630c 4 月 28 日部署 → 7 月 10 日升级;A + B 仪式的两笔交易相隔一个区块(约 12 秒);第一部分的所有缺陷都在已安装的实现中。

1.1 KYC 与撤销控制并未按书面描述工作

根据监管要求,HKDAP 服务的 B 端(机构)用户必须通过 KYC。在合约中,这意味着它必须至少做两件事:在转账时强制执行 KYC,使未通过 KYC 的钱包被阻止;以及在身份提供方或持有人被移除时撤销访问权限。在 HKDAP 中,这条路径在三个独立的位置都是坏的。

KYC 撤销是死代码。 代币通过调用 KYC 模块的 isActive(address) 来门禁转账。isActive 本应做两件事:首先,根据每个钱包的计数器设置一个基础值——当该钱包被 KYC 激活的次数多于被停用的次数时即为活跃;然后,如果任何为该钱包担保过的身份提供方已被注销,则将该值收窄为 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.entryCount0 > N,所以循环体从不执行;即使执行,第 231 行也用 == 做比较,其结果被丢弃,而这里本应使用 = 赋值。因此 isActive 只返回计数器比较的结果,完全忽略提供方注销。这是失败即放行:注销一个被入侵的 KYC 提供方并不能阻止它接入的钱包继续交易。而且因为 unregisterVerifier 不接触这些计数器,被注销提供方的钱包会保持正计数,继续处于活跃状态。

提供方撤销在另一侧也是坏的。 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 会下溢并 revert。

KYC 证明从未在链上验证。 当获授权的验证者通过 registerOrRenew 注册或续期一个钱包时(该函数被门禁,只有当前活跃的验证者可以调用),流程会到达 _checkKYCProof,后者本应根据提供方的方案验证提交的证明。按接口说明,该证明是“Oracle 的 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 行,该参数落在一个完全没有名字的参数中,函数上方的文档注释也同样省略了它,函数体从不读取它。该函数只确认提供方处于活跃状态,并在第 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… 0x8117a657df7a1c399231bfe26a096eb8f8ad59730x5d9d9b28e83382e694916b0feeaf7a9badbb659c0xebb73f78e253539acceb4e6cc287095788eee5d4 几乎所有双签名操作的第二签名
C 0xfa2fe896… 0x2f7f00cc5334fe2861e485ff610f74890a0316ed mintToDepositburnFromfreeze、KYC 停用(单签名)
D 0x3dce3265… 0x5092af62a1625fa57404557d3ad417474f3f494c pausedestroyBlackFunds、注册/注销存款和赎回地址、registerVerifier(单签名)
E 0xfd21a76d… 0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2 更改合规模块(setBlacklistServer 等)
F 0x510ac1ff… 0x7daabe5092d3feea5cc19ddbb5380a4eac38f89c addBlackListunfreeze(单签名)

从上表可以得出几点。

单签名高风险操作。 大多数高风险操作只需一个角色,配额为 1。下表是在链上从代币治理合约和五个模块治理合约的 authorizationMatrix 中读取的(除非显示 + B 第二签名,否则均为单签名):

操作 治理合约 所需签名
mintToDepositburnFrom 代币合约 C x1
freezebatchFreeze 冻结模块 C x1
deactivateadminDeactivate(KYC) KYC 模块 C x1
pausedestroyBlackFunds 代币合约 D x1
registerunregister(存款 / 赎回) 目录模块 D x1
registerVerifierunregisterVerifier KYC 模块 D x1
addBlackListbatchBlackList 黑名单模块 F x1
unfreeze 冻结模块 F x1
removeBlackList 黑名单模块 F x1 + B x1
setBlacklistServersetFreezingServersetCheckingMode 代币合约 E x1 + B x1
目录服务器与供应上限设置器 代币合约 D x1 + B x1
upgradeTo / changeAdmin / setControlAuthority(代币和每个模块)、unpause 代币合约 + 模块 A x1 + B x1

发行、冻结和 KYC 停用都是单签名(均在角色 C 下);暂停、销毁、目录变更和验证者注册在角色 D 下为单签名;黑名单和解除冻结在角色 F 下为单签名。只有升级和配置变更需要第二签名。注意这种不对称:freezeaddBlackList 只需要一个签名,而 removeBlackList 需要两个,因此限制一个账户比解除限制更容易。

这不仅仅是对矩阵的解读;它可以在一次真实铸造中观察到。撰写本文时最近一次发行,交易 0xa7e53c…b33d7,是由唯一的角色 C 账户(0x2f7f00cc5334fe2861e485ff610f74890a0316ed)发送到代币治理合约的单笔交易。它调用 request,而同一笔交易达到法定人数,并发出从 address(0) 铸造的 Transfer,没有单独的批准交易,也没有第二个签名者。由于发行在请求者自己的交易内完成,一个密钥既请求又执行了铸造。

引擎中也没有任何时间锁。最后一个所需签名落下的那一刻,操作就在同一笔交易中执行,没有任何可供审查、取消或提出异议的延迟;引擎记录了一个 executedAt 时间戳,但从不检查它。因此,即使是双签名操作,一旦第二个密钥签名,也会立即结算。

一对密钥可升级一切。 升级代币和升级全部五个合规模块都使用相同要求:A + B。由于 A 是一个账户,B 是共享同一角色的三个账户,两个人即可替换系统中的任何实现。

同一对密钥还控制角色表。 角色只能在注册表(HybridControlledAuthority0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c)上通过其自身仪式授予和撤销;没有任何外部密钥能直接更改它们。而且从链上读取的 authorizationMatrix 显示,角色变更与升级所需签名相同:角色 A 加角色 B。因此,能够替换任何实现的两个人,也可以向角色 C 添加密钥、移除现有持有者、并重写整个角色表。这个双签名门槛比上面的单签名操作更好,但作为系统根部的门槛仍然很低,因为角色 A 是单一账户,没有冗余。

一个地址,六个角色。 角色 C 的持有者(0x2f7f00cc5334fe2861e485ff610f74890a0316ed)还持有名为 ADMIN_TOKEN_HOLDER_ROLE 的角色(KYC 验证者管理),并且是全部四个审计角色的成员(BLACKLIST_AUDITOR_ROLEFREEZING_AUDITOR_ROLEWHITELIST_AUDITOR_ROLEAFL_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 transfertransferFrom 之间的转账控制不一致

两者之间的部分差异是预期的。在 transferFrom 中,发起者 msg.sender 是获批准的 spender,而不是资金源,因此代码在每种模式下都显式检查真实来源 fromtransfer 不需要这样做,因为在那里 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”模式检查 spender(msg.sender),而 from 在每种模式下都会被检查。因此,同一个配置设置会根据入口点执行两种不同的策略。

结果是,受监管代币的转账控制取决于使用哪个函数,这使得它们难以推理,并且根据配置的不同,可能被绕过。

1.4 生产前构建的迹象

除了具体的逻辑错误之外,代码库的若干属性表明,一个生产前构建被部署到了主网。

生产环境中的调试日志。 hardhat/console.log 调用遍布各处,包括代理的 fallback 内部,而 fallback 在每笔用户交易中都会执行。由于代理本身不可升级,这一开销是永久的:

// 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 行的守卫被注释掉,管理员确实会落入实现。相信该注释的审查者会错误地建模信任边界。

角色名称与其哈希不匹配。 “提升风险”角色在代币和模块中使用相同的常量名但不同的 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。该升级是相邻区块中的两笔交易,相隔约 12 秒:

  • 请求,角色 A,来自 0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2,区块 25500519(交易 0x742372…85136);
  • 批准,角色 B,来自 0x3795300b31429f9d37b0dc805528d9390ce87c50,区块 25500520(交易 0xa7a400…630c),该交易达到法定人数并在同一笔交易中执行了升级。

有两点很突出。首先,整个双签名仪式在一个区块间隔内完成。从请求到执行只隔了一个区块;在变更生效之前,第二个签名者没有任何独立审查的时间窗口。

其次,从当前角色注册表中无法识别出任何一个签名者。角色此后已经轮换:请求者 0xa9315a… 不再持有角色 A(今天持有角色 E),而第二个签名者 0x3795300b… 现在完全不持有任何角色。阅读当前注册表并不能告诉你谁授权了这次升级;只有交易历史可以。这正是从另一侧看到的“撤销并非即时生效”属性:角色会变动,因此谁持有什么的快照并不是谁做了什么事的记录。

而且这次升级没有修复本审查中的任何缺陷。它安装的实现 0xe42d38b0… 正是第一部分所描述的那个。我们无法从链上判断升级前是否进行了第三方审计;我们只能说,如果进行了,第一部分的缺陷仍然在审计后存在。

第二部分:它是否符合香港的稳定币监管框架?

香港的 《稳定币条例》于 2025 年 8 月 1 日生效,持牌发行人在香港金管局的《持牌稳定币发行人监管指引》下接受监管。我们只比较了智能合约自身能够满足的条款;储备支持、托管和链下密钥仪式不属于链上审查的范围。对以下每个条款,我们说明指引要求什么、合约做了什么、以及两者在哪里偏离。比较结果汇总于此,并在以下各节详述。

香港金管局条款 要求 合约偏离之处 结论
6.5.3 高风险操作不得由单方完成(多签名,以及速度限制或时间锁等缓释措施) mint、burn、pause、freeze 都可由一个密钥执行;没有时间锁(执行与最终签名原子完成) 偏离
6.5.4 在获授权人员之间分离职责;立即撤销权限 一个账户持有六个角色,执行与审计重叠;已计入的批准在事后撤销后仍然有效 偏离
6.5.5 每次代码变更都需第三方审计;正确、一致、无漏洞 第一部分的缺陷已存在于已部署的实现中;isActive 和提供方撤销并未履行其名称所示的功能 偏离
合规控制的有效性 黑名单、冻结、白名单和 KYC 控制必须有效 KYC 门禁和提供方撤销在已部署代码中不生效 偏离
2.2.3 被冻结或销毁的币仍须有完全储备且可对账 destroy 向 address(this) 而非 address(0) 发出 Transfer,并且不调整净发行计数器;根据事件重建的供应量会发生漂移 令人担忧

第 6.5.3 段:高风险操作不得由单方完成

要求。 高风险操作应被设计为任何单一一方都无法单方面执行,例如通过多签名协议;指引还列出了速度限制和时间延迟(timelock)控制等进一步缓释措施。

合约的实际行为。 从治理合约的 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 的 destroyBlackFundsissue 就是这样运作的)。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 包含功能性缺陷,包括未按书面描述执行的合规控制,并显示出若干生产前构建的迹象。作为受监管稳定币,其若干链上属性与香港金管局指引的具体条款相冲突。基于链上证据,并撇开我们无法看到的链下事项,已部署的合约尚未达到商业稳定币应达到的标准。

由此得出两点观察,值得直白地说出来。

首先,在公共链上发行改变了合规决策发生的位置。像“任何一方都不得单方面行动”这样的要求,是由已部署代码中的角色检查来满足或不满足的,而该代码是公开的。实现,而不是牌照或文档,才是此类要求实际被满足或未满足的地方,任何人都能验证是哪种情况。

其次,“Beta Access”标签不会改变已部署代码的风险状况。该合约已在以太坊主网上运行,由真实密钥管理,并代表对港元的索偿权。因此,无论它被贴什么标签,都应按生产标准来要求。

一个共同主线贯穿于具体发现之下。该架构以定制形式重新发明了生态系统已经提供并大规模审计过的原语:在可以使用 Safe 多签名加上 OpenZeppelin 的 AccessManager 和 TimelockController 的地方,它从头写了一个审批引擎和角色层;用手写的链表集合代替 EnumerableSet;用修改过的代理代替标准的 TransparentUpgradeableProxy;用手写的 ERC-20 代替 OpenZeppelin 的实现。本审查中的大多数缺陷都存在于这些定制机制中,而非存在于复用标准组件的部分。 它读起来像一个按通用软件方式抽象出来的系统,而不是由链上开发所青睐的经过审计的小构建块组合而成;在链上开发中,每一层自定义抽象同时也是 gas、攻击面和升级风险。基于这些标准组件构建的设计会更小、更安全、更易于审查,并且会自带当前系统所缺乏的东西:来自时间锁的审议窗口,以及有名称、清晰可读的角色。

这里描述的问题是可以解决的。恢复高风险操作的多签名、将执行与审计分离、修复 KYC 撤销逻辑、统一转账检查、移除调试代码,并在每次升级前要求第三方审计,即可解决其中大部分问题。在主网上部署并验证源码也恰恰是使本次审查成为可能的原因,也是正确的默认做法。BlockSec 所做的正是这种持续的链上安全与合规审查,我们很乐意提供帮助。

用 Phalcon Security 获得实时防护

光靠审计还不够。Phalcon Security 实时发现攻击,并在攻击执行途中将其拦下。

Phalcon Security