logo
登录/注册
香港首个持牌港元稳定币 HKDAP,代码质量却远未达标

香港首个持牌港元稳定币 HKDAP,代码质量却远未达标

香港首个持牌港元稳定币 HKDAP,代码质量却远未达标

来源:
BlockSec谈区块链安全
08/14 17:12更新

TL;DR HKDAP 是香港第一个持牌的港元稳定币,已经在以太坊主网上线。我们把它部署的合约从头到尾读了一遍,结论是这份代码还达不到生产可用的水平。KYC 和撤销这两项控制,代码里写了,可实际并不生效;权限也太集中,一个私钥就能增发、销毁、冻结;另外还有好几处链上行为,和 HKMA 自己发布的指引对不上。这些问题追到底是同一个来源:多签、权限管理、时间锁,乃至 ERC-20,这些社区里早就成熟、也经过大量审计的现成组件,这份合约几乎都自己重写了一遍,缺陷大多就出在这些重写的部分。它现在挂着“Beta Access”的名头,但这掩盖不了上面这些问题。

2026 年 8 月 12 日,Anchorpoint 启动了 HKDAP 的第一阶段,港元稳定币这几天在社交媒体和社区中被大量讨论。这是因为这次上线确实有分量:Anchorpoint 由渣打银行(香港)牵头,联合 HKT 和 Animoca Brands 共同出资,拿到了 HKMA 至今只发出两张的稳定币发行牌照中的一张,而当时一共有 36 家申请、最后只批了两家。HKDAP 也是《稳定币条例》生效之后,最早发行的稳定币之一。

和大多数银行产品不同,HKDAP 的代码是可以直接查看的。这是因为它运行在以太坊主网上,合约源码在 Etherscan 上已经验证,任何人都能读到。既然代码是公开的,我们干脆把这份上链的合约,从头到尾读了一遍。

读这份合约,我们主要看两个方面。一是把它当成一份普通软件,看它写得对不对、够不够生产级;二是把它当成一款受监管的稳定币,看它在链上的实际行为,符不符合 HKMA 那份《持牌稳定币发行人监管指引》。这两个方面得出的结论一致:哪怕只按 Beta 阶段来要求,这份合约也达不到一款商用稳定币该有的水准。

本文报告这一次审阅的结果。所有结论都截至 2026 年 8 月 13 日,只基于公开部署的代码和链上可见的事实。至于链上证实不了的部分,比如储备够不够、私钥在链下怎么保管,我们就不下结论了。

合约代码获取

在 Anchorpoint 的官网 anchorpoint.hk 上,Beta Access 页面直接写明了部署位置:以太坊主网,代理合约地址是0x87622385F960fcCB3121d6D0A9513bd1D9Bed6cA,它的源码在 Etherscan 上已经验证

拿到这个代币的实现之后,我们沿着它把整套系统在链上梳理了一遍:包括代币代理和它的实现合约(ControllableAHKD,0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc)、管理它的治理合约(0x47dd47a776902bee03abdd5aeff43f41b0ffbc2b)、位于最上层的角色注册表(0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c),以及五个合规模块,每个模块还各自带着一个治理合约。下面提到的每一条关系,我们都通过读取存储槽、调用链上的 view 函数逐一核实过,而不是仅凭源码推断。

智能合约架构概览

1HKDAP 的三层结构 —— 治理控制面管理代币,代币在每笔转账时向五个合规模块发问

HKDAP 的代币并没有采用 OpenZeppelin 标准的 ERC-20 实现,它的 ERC-20 逻辑是从零手写的,后面不少 bug 都来自这里。整个系统分成三层。

  • 代币层。 也就是 ControllableAHKD,它藏在一个可升级代理的后面,是一个受控的 ERC-20,支持增发、销毁、暂停和强制销毁,并且在每一笔转账里都加入了合规检查。
  • 一个自研的 M-of-N 治理引擎。 系统里每一个特权操作,包括升级、增发、销毁、暂停、拉黑、冻结、更换合规模块,都要走一遍 request、approve、execute 的流程,而不是使用一个普通的多签。
  • 五个合规模块。 分别是黑名单、冻结、一个 KYC 激活服务,以及存款和赎回两个白名单。每个模块本身也是一个代理,各自由一个 control authority 合约来治理。

这套结构有一个关键特点:“control authority 加代理加实现”这个组合,在系统里一共重复了六次(代币加上五个模块),而这六个 control authority 最终都把角色解析到同一个注册表上。也就是说,整个系统的控制权最终都汇到这一个注册表上;而谁能改写注册表里的角色,掌握在很小的一撮签名私钥手里,这一点我们在后面会具体看到。

Part 1:安全与 bug

这一部分的发现先汇总在下面这张表里,每一条都会在对应的小节里展开。

1Part 1 安全与 bug 发现汇总

1.1 KYC 和撤销控制虽然写了,但并不生效

按照监管要求,HKDAP 面向的 B 端用户都需要通过 KYC。落到合约上,这意味着它至少要做到两件事:一是在转账时强制 KYC,把没有通过 KYC 的地址拦下来;二是当某个身份提供方、或者某个持有人被移除时,能够撤销他对应的权限。可在 HKDAP 里,这条本该完整的路径,在三个互不相关的地方分别断掉了。

先看 KYC 撤销,它是一段死代码。 代币在每一笔转账时,都会调用 KYC 模块的 isActive(address) 来做检查。这个函数本应完成两步:第一步,根据一个计数器给结果定一个初始值;第二步,如果有任何一个曾经为这个钱包做过背书的身份提供方后来被注销了,就把这个初始值收紧为 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 最终只返回了计数器那一步的比较结果,完全跳过了提供方撤销这一环。这带来的是一种“失效即放行”(fail-open)的效果:当你注销一个已经被攻破的 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。按照接口的说明,这里的 proof 应该是“一个 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 }

其实,这个 proof 是被完整地传进函数里的:registerOrRenew 会把提交上来的 kycProof 一路传下去,最后作为第二个参数交给 _checkKYCProof。问题在于,到了 575 行,这个参数落进了一个连名字都没有的形参里,函数上方的注释也没有提到它,函数体内更是一次都没有读取过它。函数只是确认了提供方处于激活状态,就在 577 行返回了 true。换句话说,proof 被送到了门口,却被直接丢掉,而不是拿去校验。这并不是外部人可以利用的漏洞,因为只有激活的验证方才能走到这一步;但在链上,合约对 KYC 证据没有做任何校验,是否可信完全取决于链下的验证方。一旦某个验证方被攻破、或者不够尽责,它就可以用任意 proof(哪怕是一个空字符串)把任意钱包激活。

把这三处放在一起,意味着一件事:一款稳定币最应该守住的那项合规能力,也就是既能把关、也能撤销的能力,虽然写在了代码里,却并没有真正生效。

除了上面这三处,还有一个和 KYC 相关的漏洞:免检额度可以靠拆单绕过。 合约里有一个 freeTransferLimit,金额低于它的转账可以跳过 isActive(KYC)那道检查。问题在于,这道门槛只按单笔金额来判,合约并不累计某个地址、某段时间里已经走掉多少免检额度。所以,一旦它被当成对未 KYC 地址的额度上限来用,一个地址只要把一笔大额拆成若干笔、每笔都压在额度以下,就能把任意总额转出去,这个上限也就形同虚设。

1.2 治理过度集中,高危操作仍然是单签

我们把每一个角色、以及每一个角色的持有者,都在链上完整枚举了一遍。在讲具体条目之前,有两点需要先交代。

第一,那些用来授权 M-of-N 流程的角色,并没有可读的名字。在部署之后的配置里,它们只是一串 32 字节的哈希,没有任何一个能对应上源码里带名字的角色常量(像 SUPPLY_CONTROLLER_ROLE 这类有名字的角色,是由合约自己持有的,并不是分给签名人的)。为了方便讨论,我们把这六个签名角色分别记作 A 到 F。系统里权力最大的这几个角色,偏偏是一串没有名字、看不出含义的哈希,这本身就是一个弱点,因为它让整套治理比使用具名角色时更难被人看懂。

第二,这些角色的持有者数量很少。下面这张表来自链上的角色注册表,其中的地址做了缩写。

2:链上角色、持有者与授权范围

从这张表可以看出几件事。

第一,很多高危操作都是单签。 大多数高危操作只需要一个角色、并且配额为一。下面这张表来自代币治理合约、以及五个模块治理合约的 authorizationMatrix,凡是没有标出 + B 第二签的,都是单签。

3:各操作所需的签名(读自授权矩阵)

具体来说,增发、冻结、KYC 停用都是单签,全部归角色 C;暂停、强制销毁、目录改动、验证方登记是角色 D 的单签;拉黑和解冻则是角色 F 的单签。真正需要第二个签名的,只有升级和修改配置。这里还有一个不太对称的地方:freeze 和 addBlackList 只要一个签名就能执行,removeBlackList 却需要两个签名,也就是说,限制一个账户,比解除对它的限制还要容易。

这不只是从矩阵里读出来的账面结论,链上一笔真实的增发就能印证它。写这篇文章时,最近的一笔增发是交易 0xa7e53c…b33d7:角色 C 唯一的持有者(0x2f7f00cc5334fe2861e485ff610f74890a0316ed)向代币治理合约发起了一笔交易,调用的是 request;就在同一笔交易里,票数被凑齐,代表增发的 Transfer(从 address(0) 出账)也一并发了出来,既没有第二笔审批交易,也没有第二个签名人。整个增发就在发起人自己这一笔交易里完成了。也就是说,一个私钥既发起、又执行了这次增发。

引擎里也完全没有时间锁。当最后一个必需的签名到位时,操作会立刻在同一笔交易里执行,中间没有留下任何可以复核、撤回或提出异议的时间窗口。引擎虽然记录了一个 executedAt 时间戳,却从来不会去检查它。所以,即便是需要两个签名的操作,也会在第二个私钥签名的那一刻立刻生效。

第二,一对私钥就能升级整个系统。 升级代币,和升级全部五个模块,用的是同一个要求:A 加 B。由于 A 是一个账户,B 是三个共用同一个角色的账户,因此只要两个人配合,就能替换掉系统里任何一个实现。

改角色也归这一对私钥管。 谁持有哪个角色,只能在注册表 HybridControlledAuthority(0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c)上、通过它自己的 ceremony 来增删,没有哪把外部私钥能绕过它直接改。而链上读出的授权矩阵显示,grantRole、revokeRole 这些操作的门槛和升级完全一样:角色 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 管理这三项权力会同时丢失。

这几个审计角色也有两个问题。 一是它把合规名单的只读接口锁在角色后面,想控制谁能读;可这是公链,底层存储谁都能直接读(我们查这套系统靠的就是读存储槽),有没有这个角色名单都是公开的,这道限制形同虚设,也说明设计没把“自己在公链上”当回事。二是集中:四个审计角色都在同样的 23 个账户手里,而执行角色 A、C、D、F 的持有者也在这 23 个里,于是能增发、销毁、冻结、拉黑的那些私钥,同时又坐在名义上复核这些操作的那一组里。

第四,撤销并不是即时生效的。 在这套流程引擎里,签名人的角色只在签名的当时被检查一次,配额随即减一;此前的签名人不会被重新检查一遍。所以,即便你事后撤销了某个角色,也收不回那张已经计入的票。

// 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 同时当成一个真实请求的编号和“空”的标记,于是依赖反向遍历的监控或审批工具,会漏掉编号为 0 的那一笔请求。这两点都不致命,但对一个需要清晰审计轨迹的受监管系统来说,都是减分项。

1.3 transfer 和 transferFrom 两条路径,检查规则并不一致

这两者之间有一部分差异,是合理的。在 transferFrom 里,发起人 msg.sender 是一个获得授权的支出方(spender),而不是资金的真正来源,所以代码在每一种模式下都会专门去检查真正的来源 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 这是一个还没有完成的构建

除了上面这些具体的逻辑 bug,代码库还有几处特征表明,被部署到主网上的,其实是一个还没有完成的预生产构建。

第一,生产代码里还留着调试日志。 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 的文档,那段文档写的是 admin 永远不会穿透到实现,但这份合约偏偏做了相反的事:117 行那道守卫被注释掉了,admin 实际上是可以穿透过去的。一个相信注释的审阅者,会因此把信任边界判断错。

第三,角色的名字和它的哈希对不上。 “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 个文件,连项目的测试套件也一起传了上去,等于把内部测试和边界用例直接交到了读者手上。最近这一次升级,改动的内容只是清理了几个编译器告警,过程中并没有第三方审计。此外,优化器被设成了运行零次,结果反而让一款高频使用的代币在热点路径上更费 gas,而不是更省。

这些问题单独拿出来,任何一个都算不上严重,但把它们放在一起,就足以说明,这份代码并没有走过一份在主网上管理资金的合约本应经历的发布流程。

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…,正是整个 Part 1 所描述的那一个。至于升级之前有没有做过第三方审计,我们从链上无法判断;但可以确定的是,即便做过,Part 1 里的这些缺陷也全都保留了下来。

Part 2:它和香港的稳定币框架对得上吗

香港的《稳定币条例》已经在 2025 年 8 月 1 日生效,持牌发行人受 HKMA 的《持牌稳定币发行人监管指引》监管。我们只挑选了那些一份智能合约靠自己就能满足的条款来做对照;至于储备背书、托管、以及链下的私钥流程,都不在一次链上审阅所能覆盖的范围之内。对每一条,我们都会说清楚三件事:指引要求什么、合约实际做了什么、两者在哪里出现了分歧。下面先把结论汇总在这张表里,后面再逐条展开。

4HKMA 条款对照汇总

条款 6.5.3:高危操作不能由一方单独完成

指引的要求。 高危操作在设计上,应该做到没有任何单独一方能够独自完成,比如采用一套多签协议;除此之外,指引还列出了速率上限、时间延迟(也就是时间锁)等进一步的缓解手段。

合约的实际做法。 从治理合约的 authorizationMatrix 读出来(完整的矩阵见 1.2 那张表),供应类和应急类的操作,各自都只需要一个角色、配额为一。

5:条款 6.5.3 涉及的单签操作

实现和指引不一致的地方。 增发、销毁、暂停、冻结,每一项都能由一个私钥单独完成,这不满足“不能由任何单独一方完成”这条要求。同时,合约里也没有时间锁:正如 1.2 所说,一旦票数凑齐,操作就在同一笔交易里执行,所以指引点名的另一个缓解手段,也就是时间延迟,同样是缺席的。合约确实实现了一个供应速率上限(whenWithinRiskThresholds),所以这个缓解手段算是有的;但它并不能替代对这些操作本身设置多签。

条款 6.5.4:职责分离,以及即时撤销

指引的要求。 不同的操作,应该分配给不同的授权人来负责;而一个授权人的权限,应该能够被即时撤销。

合约的实际做法。 一个外部账户身兼六个角色(增发、冻结、KYC 管理,再加上四个审计角色),执行角色和审计角色因此重叠。而且改角色本身也只要 A 加 B(见 1.2),也就是说,决定“谁被授权”的,和能执行、能升级的,是同一小撮人。另外,一个已经计入的签名,也不会因为签名人的角色随后被撤销而重新校验。

实现和指引不一致的地方。 职责并没有被分开,而是集中在了一起;撤销也不是即时的,因为一个被撤销的签名人此前投出的票,仍然会计入之后的执行。

条款 6.5.5:每次代码变更都要审计,并保证正确、一致、无漏洞

指引的要求。 每一次代码变更,都应该由一个合格的第三方来审计这份智能合约,并确认它满足三点:一是实现正确,二是与预期功能一致,三是有较高把握不存在漏洞。

合约的实际做法。 现在这个实现,正是 7 月 10 日那次升级装上去的(在 1.5 里还原过),而 Part 1 里的那些缺陷,全都活在它里面。

实现和指引不一致的地方。 链下究竟有没有审计过,我们无法看到;但无论审没审过,最终的结果都达不到这条标准。因为 isActive 和提供方撤销都没有在做它们名字所说的事情,所以第一点和第二点都不成立;而既然 Part 1 的缺陷确实留在了部署代码里,第三点也不成立。

合规控制到底有没有效

指引的要求。 指引里的生命周期模型(黑名单、冻结、白名单、KYC),默认这些控制都是真正有效的。

合约的实际做法。 正如前面 1.1 所说,在部署的代码里,KYC 把关和提供方撤销都没有在工作。

实现和指引不一致的地方。 一项被要求有效的控制,实际上并不有效。这是一个实质性的缺失,而不是走个形式。

条款 2.2.3:被冻结或被销毁的币,仍然要足额背书、可以对账

指引的要求。 因执法行动而被冻结或被销毁的稳定币,仍然应该保持足额背书,好让供应和储备能够对得上账。

合约的实际做法。 对一款受监管的稳定币来说,通常的处置办法,是先把坏地址上的钱销毁,之后再单独增发一笔,把等额的币重新发给受害人(USDT 的 destroyBlackFunds 加 issue 就是这样做的)。HKDAP 的强制销毁确实减少了 _totalSupply,这一步算是销毁没错,但它从不给 balances[address(this)] 记账,发出去的 Transfer 指向的是 address(this) 而不是 address(0),而且它也没有去改动增发上限所依赖的净发行计数。

// 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,而是从零手写了一套审批引擎和角色层;它没有用 EnumerableSet,而是自己写了一个链表集合;它没有用标准的 TransparentUpgradeableProxy,而是改了一版代理;甚至连 ERC-20 都是手写的,而没有用 OpenZeppelin 现成的。本文里的这些缺陷,绝大多数都出在这些自己实现的部分,而用了现成组件的地方,反而没有出什么问题。它给人的整体感觉,更像是用做通用软件的那一套思路去做抽象,而不是按照链上开发的习惯,用一小块一小块、经过审计的现成积木拼起来。而在链上,每多一层自己实现的抽象,同时也就意味着多一份 gas、多一片攻击面、多一重升级风险。如果改用这些现成组件来搭建,整套设计会更小、更稳、也更容易审计,而且会自带这套系统目前所缺的东西:一个由时间锁带来的冷静期,以及一批有名字、能看懂的角色。

好在这些问题都不是无解的。把高危操作重新改回多签、把执行和审计分开、修好 KYC 撤销那段逻辑、统一两条转账路径上的检查、删掉调试代码、并且要求每次升级之前都做一次第三方审计,就能解决其中大部分。而把合约部署到主网、并公开验证源码,恰恰是这次审阅得以进行的前提,这也是一个正确的默认做法。像这样持续地对链上安全和合规做审阅,正是我们 BlockSec 在做的事情,我们也很乐意提供帮助。如有安全审计需求,可BlockSec官网或联系contact@blocksec.com。

本网站所提供的所有信息仅供参考之用。本网站不保证信息的准确性、有效性、及时性和完整性。任何依赖于本网站所提供信息的行为,均由用户自行承担风险。
喜欢
收藏
分享
123
Techub Signal
文章:0
暂无数据~
加入社群

Techub 是一个全球性的科技社区,旨在为初创企业提供支持和资源,我们的使命是成为全球最受欢迎的科技社区。

备案号粤ICP备2023094802号