Back to Blog

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

Phalcon Security
August 14, 2026
27 min read
Key Insights
  • HKDAP 的 KYC 撤销功能是死代码,其 KYC 证明从未在链上得到验证

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

  • 多项链上属性与香港金管局稳定币发行人指引存在偏差

A BlockSec security and compliance review of HKDAP (Anchorpoint), as of August 13, 2026.

TL;DR。 我们审查了 HKDAP 已部署的合约。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 上验证

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

合约结构概览

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

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

  • 代币。 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 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 个文件(含测试);优化器 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 会下溢并回滚。

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 行,该参数落在一个完全没有名字的参数中,函数上方的文档注释也省略了它,函数体从未读取它。该函数只确认提供方处于活跃状态,并在第 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 mintToDepositburnFromfreeze、KYC 停用(单签名)
D 0x3dce3265… 0x5092af62a1625fa57404557d3ad417474f3f494c pausedestroyBlackFunds、注册/注销存款与赎回地址、registerVerifier(单签名)
E 0xfd21a76d… 0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2 更改合规模块(setBlacklistServer 等)
F 0x510ac1ff… 0x7daabe5092d3feea5cc19ddbb5380a4eac38f89c addBlackListunfreeze(单签名)

从该表可以得出几点。

单签名高风险操作。 大多数高风险操作只需要一个角色,配额为 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 下为单签名。只有升级和配置更改需要第二个签名。注意这种不对称: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 是获得批准的支出者,而非资金来源,因此代码在每种模式下都显式检查真实来源 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”模式检查支出者(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 行的检查被注释掉,管理员确实会穿透。信任该注释的审查者会错误地建模信任边界。

角色名称与其哈希不匹配。 “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 个文件,包括项目的测试套件,这等于把内部测试和边界情况交给读者。最近一次升级只做了编译器警告清理,过程中没有第三方审计。优化器设置为 zero runs,这使得一个被大量使用的代币的热点路径成本更高,而不是更低。

这些单独来看都不严重。合在一起,它们表明该代码没有经历过一个在主网上持有价值的合约所应具备的发布纪律。

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 的《持牌稳定币发行人监管指引》监管。我们只比较了智能合约本身能够满足的条款;储备支持、托管和链下密钥仪式不在链上审查范围内。对于下面的每个条款,我们说明指引要求什么、合约做了什么、以及两者在哪里分歧。比较在此汇总,并在后续章节中详述。

HKMA 条款 要求 合约分歧点 结论
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 段:高风险操作不得是单方面的

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

合约做法。 从治理合约的 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 包含功能性缺陷,包括未按书面方式执行的合规控制,并显示出多个生产前构建的迹象。作为受监管稳定币,其若干链上属性与 HKMA 指引的具体条款相冲突。根据链上证据,并撇开我们无法看到的链下事项,该已部署合约尚未达到商业稳定币应达到的标准。

由此得出两点观察,值得直率地说明。

第一,在公链上发行改变了合规决策的所在。像“任何单一一方都不应能单方面行动”这样的要求,是否满足取决于已部署代码中的角色检查,而该代码是公开的。真正满足或未能满足此类要求的是实现本身,而非牌照或文档,任何人都可以验证是哪一种情况。

第二,“Beta Access”标签不会改变已部署代码的风险状况。该合约在以太坊主网上运行,由真实密钥管理,代表着对港币的索取权。因此,无论其如何标注,都应按照生产标准来要求。

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

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

Get Real-Time Protection with Phalcon Security

Audits alone are not enough. Phalcon Security detects attacks in real time and blocks threats mid-flight.

phalcon security