Back to Blog

HKDAP穩定幣安全審查:已上線、已持牌,但尚未準備就緒

Phalcon Security
August 14, 2026
27 min read
Key Insights
  • HKDAP 的 KYC 撤銷為無效程式碼,其 KYC 證明從未在鏈上進行驗證

  • 單一金鑰即可鑄造、銷毀、暫停或凍結;兩個金鑰即可控制所有升級與角色

  • 若干鏈上屬性與香港金融管理局(HKMA)穩定幣發行人指引存在差異

A BlockSec 對 HKDAP(Anchorpoint)的安全與合規審查,截至 2026 年 8 月 13 日。

TL;DR。 我們審查了 HKDAP 已部署的合約;HKDAP 是香港首個受監管的穩定幣,已在以太坊主網上線,而它尚未達到可正式上線的生產標準。其 KYC 與撤銷控制機制無法按書面運作,其治理權過度集中,以致單一金鑰即可鑄造、銷毀或凍結,而且其多項鏈上屬性與香港金管局的指引相牴觸。背後貫穿著一個共同主軸:該合約從零重造了生態系統早已提供並大規模審計過的基本元件(多簽、存取控制層、時間鎖、ERC-20 本身),而大部分缺陷都存在於那些自製機制中,而非重用標準元件的部分。「Beta 存取」標籤並未縮小這個差距。

2026 年 8 月 12 日,Anchorpoint 推出了 HKDAP 的第一階段,這是一種港幣穩定幣。這是一次重大的推出。Anchorpoint 是由渣打銀行(香港)主導、與香港電訊(HKT)和 Animoca Brands 合資成立的公司,持有香港金管局在 36 家申請者中僅發出的兩個穩定幣發行人牌照之一,而 HKDAP 是香港《穩定幣條例》下首批發行的穩定幣之一。

與受監管銀行的大多數產品不同,HKDAP 可以直接被檢查。它在以太坊主網上運行,其合約原始碼已在 Etherscan 上驗證。這是一個有用的特性:對於以這種方式發行的穩定幣,規範發行、轉移和凍結的規則不是寫在文件中,而是實現在任何人都能閱讀且完全按書面執行的程式碼中。牌照是一種聲明;已部署的合約就是該聲明的實作,而且是公開的。

這使得具體的審查成為可能,而這正是我們所做的。我們沿兩個軸線檢查了已部署的合約。第一,作為軟體:它是否正確且達到生產級別?第二,作為受監管的穩定幣:其鏈上行為是否符合香港金管局的《持牌穩定幣發行人監管指引》

兩個軸線的結果是一致的。該合約包含多個功能缺陷,包括無法按書面運作的合規控制。其治理權高度集中,多項高風險操作可由單一金鑰執行。而且其多項鏈上屬性與香港金管局指引的具體條文相牴觸。我們的評估是,即使作為測試版,該合約也未達到商業穩定幣所需的品質標準。

本文其餘部分呈現該審查結果,截至 2026 年 8 月 13 日。我們的審查基於公開部署的程式碼和可觀察的鏈上事實。我們對區塊鏈無法確定的事項不作任何主張,例如儲備支持或鏈下金鑰保管。

我們如何找到該合約

我們從發行人開始,而不是從代幣清單開始。Anchorpoint 的公司頁面連結到其網站 anchorpoint.hk。Beta 存取頁面指出了部署位置:以太坊主網,代理合約 0x87622385F960fcCB3121d6D0A9513bd1D9Bed6cA,其原始碼已在 Etherscan 上驗證

我們從那裡在鏈上繪製了整個系統的地圖:代幣代理及其實作(ControllableAHKD0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc)、管理它的治理合約(0x47dd47a776902bee03abdd5aeff43f41b0ffbc2b)、頂層角色註冊表(0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c),以及五個合規模組,每個都有自己的治理合約。以下每個關係都是透過在主網上讀取儲存槽和呼叫檢視函式來驗證的,而不是僅從原始碼推斷。

合約的外觀

HKDAP 的三層結構:治理控制平面管理代幣,代幣在每次轉移時查詢五個合規模組。 image.png

HKDAP 的代幣不使用 OpenZeppelin 的標準 ERC-20 參考實作;其 ERC-20 邏輯是從頭編寫的(下文多個錯誤的來源就在這裡)。該系統有三層:

  • 代幣。 ControllableAHKD,位於可升級代理之後。一個受控的 ERC-20,具有鑄造、銷毀、暫停和強制銷毀功能,並在每次轉移中都接入了合規檢查。
  • 一個自製的 M-of-N 治理引擎。 每個特權操作(升級、鑄造、銷毀、暫停、黑名單、凍結、變更合規模組)都經過請求、批准、執行的儀式,而不是普通的多簽。
  • 五個合規模組。 黑名單、凍結、KYC 啟動服務,以及存款和贖回白名單。每個模組本身都是一個由其自己的控制權限合約治理的代理。

在結構上,這個「控制權限加代理加實作」的單元重複了六次(代幣加上五個模組),而所有六個控制權限都在一個註冊表中解析其角色。系統中的所有控制最終都匯聚到這一個註冊表,而誰能改寫其中的角色,最終取決於一個非常小的簽署者金鑰集合,如下文詳述。

第 1 部分:安全性與錯誤

本部分的發現總結如下;每一行在所指出的章節中詳細說明。

領域 發現 位置 影響
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;mint 交易 0xa7e53c…b33d7 鑄造/銷毀/凍結/KYC 停用(角色 C)、暫停/銷毀(角色 D)、黑名單/解凍(角色 F)各自只需一把金鑰即可執行。
一對金鑰(A + B)可升級所有東西 authorizationMatrix 代幣和全部五個模組的 upgradeTo 都是角色 A + B;兩個人可以替換任何實作。
同一對金鑰可改寫所有角色 註冊表 0xa728… authorizationMatrix 註冊表上的 grantRolerevokeRole 也是角色 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 轉移檢查不一致 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.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,後者本應根據提供者的方案驗證提交的證明。根據介面,該證明是「一個 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 upgradeTochangeAdminsetControlAuthority 的第一個簽名
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
upgradeTochangeAdminsetControlAuthority(代幣和每個模組)、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 個檔案,包括專案的測試套件,到公開的瀏覽器,這給了讀者內部測試和邊界情況。最近一次升級只變更了編譯器警告清理,沒有第三方審計參與其中。而且 optimizer 設定為零 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… 正是第 1 部分所有內容所描述的。我們無法從鏈上判斷升級前是否進行了第三方審計;我們能說的是,如果有的話,第 1 部分的缺陷在審計後仍然存在。

第 2 部分:它是否符合香港的穩定幣框架?

香港的《穩定幣條例》於 2025 年 8 月 1 日生效,持牌發行人受香港金管局的《持牌穩定幣發行人監管指引》監督。我們只比較了智慧合約可以獨自滿足的條文;儲備支持、保管和鏈下金鑰儀式不在鏈上審查的範圍內。對於以下每一條,我們說明指引要求什麼、合約做了什麼,以及兩者在哪裡出現分歧。比較總結如下,並在後續章節詳細說明。

香港金管局條文 要求 合約分歧之處 判定
6.5.3 高風險操作不得由單一人士進行(多簽,以及速度限制或時間鎖等緩減措施) 鑄造、銷毀、暫停和凍結各自只需一把金鑰即可執行;沒有時間鎖(執行與最終簽名原子完成) 分歧
6.5.4 在獲授權人士之間分隔職責;立即撤銷權限 一個帳戶持有六個角色,因此執行和審計重疊;已計算的批准在之後的撤銷中仍然有效 分歧
6.5.5 每次程式碼變更都需第三方審計;正確、一致、無漏洞 第 1 部分的缺陷存在於已部署的實作中;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 中追蹤),而第 1 部分的缺陷就在其中。

分歧之處。 我們無法從鏈下看到是否進行了審計,但無論如何結果都不符合標準。因為 isActive 和提供者撤銷沒有做到其名稱所宣示的功能,條件(i)和(ii)未滿足;而第 1 部分的缺陷存在於已部署的程式碼中,(iii)也未滿足。

合規控制的有效性

要求。 指引的生命週期模型(黑名單、凍結、白名單、KYC)假定這些控制是有效的。

合約做了什麼。 如 1.1 所示,在已部署的程式碼中,KYC 門控和提供者撤銷不起作用。

分歧之處。 一項本應運作的控制沒有運作。這是一個實質性的差距,而不是形式上的問題。

第 2.2.3 段:被凍結或銷毀的幣仍須完全支持且可對帳

要求。 因執法行動而被凍結或銷毀的穩定幣應保持完全支持,以便供應和儲備可以被對帳。

合約做了什麼。 受監管穩定幣的常見補救流程是銷毀不良地址的資金,然後作為單獨的鑄造向受害者重新發行等額資金(這就是 USDT 的 destroyBlackFundsissue 的運作方式)。HKDAP 的銷毀會減少 _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 存取」標籤並未改變已部署程式碼的風險概況。合約在以太坊主網上活躍運行,由真實金鑰管理,並代表對港幣的索取權。因此,無論它如何標籤,都應以生產標準來要求。

在具體發現之下有一條共同主軸。該架構以訂製形式重新發明了生態系統早已提供並大規模審計過的基本元件:一個從零開始的批准引擎和角色層,而 Safe 多簽加上 OpenZeppelin 的 AccessManager 和 TimelockController 就能勝任;用自製的鏈結串列集合代替 EnumerableSet;用修改過的代理代替標準的 TransparentUpgradeableProxy;以及用手寫的 ERC-20 代替 OpenZeppelin 的。本審查中的大部分缺陷都存在於那些自製機制中,而非重用標準元件的部分。 它讀起來像一個以通用軟體的方式抽象化的系統,而不是由鏈上開發所偏好的小型、已審計建構塊組合而成;在鏈上開發中,每一層自訂抽象也都是 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