Back to Blog

香港數字資產政策穩定幣安全審查:已上線、已獲牌照、尚未準備就緒

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

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

  • 多項鏈上屬性與金管局穩定幣發行人指引存在偏差

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

TL;DR。 我們審查了 HKDAP 已部署在以太坊主網上的合約;HKDAP 是香港首個受監管的穩定幣,而我們的結論是它尚未達到可上線生產(production-ready)的標準。其 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),以及五個合規模組,每個模組都有自己的治理合約。以下每一項關係都是透過讀取儲存槽並呼叫主網上的 view 函式來驗證的,而非僅從原始碼推斷。

合約的外觀

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

HKDAP 的代幣並不使用 OpenZeppelin 的標準 ERC-20 參考實作;其 ERC-20 邏輯是從零撰寫的(下文多個錯誤正源於此)。該系統有三層:

  • 代幣。 ControllableAHKD,位於可升級代理之後。這是一個受控的 ERC-20,具備 mint、burn、pause、forced-destroy,並在每筆轉移中接入合規檢查。
  • 自製的 M-of-N 治理引擎。 每一項特權動作(升級、鑄造、銷毀、暫停、黑名單、凍結、變更合規模組)都經過請求、批准、執行的儀式流程,而非單純的多重簽章。
  • 五個合規模組。 黑名單、凍結、KYC 啟用服務,以及存款與贖回白名單。每個模組本身都是一個代理,由自己的控制權限合約治理。

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

第 1 部分:安全性與錯誤

本部分的調查結果總結如下;每一列都會在所指章節中詳細說明。

領域 發現 位置 影響
1.1 KYC 與撤銷控制失效 KYC 撤銷是死程式碼 TokenHolderActivationServerLibrary.sol:221-235 isActive 忽略提供者取消註冊(迴圈永遠不會執行,而且使用 == 而非 =);fail-open。
驗證者取消註冊從不設定 INACTIVE TokenHolderActivationServer.sol:504-511 「已取消註冊」的驗證者仍可為錢包啟用與停用;第二次呼叫會 revert。
KYC 證明從未在鏈上驗證 TokenHolderActivationServer.sol:575-578 證明被傳入但被丟棄;任何證明,包括空字串,都會通過。
免費轉移豁免可被拆分繞過 ControllableAHKD.sol:337-535 freeTransferLimit 是按每次呼叫檢查,而非累計;若用作未通過 KYC 活動的上限,可透過拆分為低於上限的多筆轉移來繞過。
1.2 治理過度集中 高風險操作是單一簽章 authorizationMatrix;mint tx 0xa7e53c…b33d7 mint / burn / freeze / KYC deactivate(角色 C)、pause / destroy(角色 D)、blacklist / unfreeze(角色 F)各自只需一把金鑰即可執行。
一對金鑰(A + B)可升級一切 authorizationMatrix 代幣與全部五個模組的 upgradeTo 都是角色 A + B;兩個人就可以替換任何實作。
同一對金鑰可改寫所有角色 registry 0xa728… authorizationMatrix 註冊表上的 grantRole / revokeRole 也是角色 A + B(ADMIN_ROLE 僅由合約持有;DEFAULT_ADMIN_ROLE = address(0));沒有任何外部金鑰能直接變更角色。
一個地址持有六個角色 role registry 0xa728… 0x2f7f00… 持有角色 C,加上 ADMIN_TOKEN_HOLDER 與全部四個 auditor 角色;執行與稽核重疊。
撤銷不是即時的;沒有時間鎖 HybridControlEngine.sol:158-227 已計數的簽章在角色被撤銷後不會被重新驗證;執行與最後一個簽章在同一筆交易內原子完成,沒有任何延遲。
引擎的稽核軌跡不可靠 HybridControlEngine.sol:36-129, 177-196 evtApprove 發出 address(0) 作為簽署者;有效請求清單使用 nonce 0 同時作為真實 id 與空標記,因此反向遍歷會漏掉第一個請求。
1.3 轉移檢查不一致 transfertransferFrom 執行不同的規則 ControllableAHKD.sol:313-502 白名單 early-return 僅透過 transferFrom 繞過 KYC;checkingMode 檢查不同的當事方;同一筆轉移受到不同規則管轄。
1.4 上線前建置的跡象 debug 日誌、錯誤註解、角色名稱/雜湊不符、測試上傳 UpgradeableProxy.sol:115-119ControllableAHKD.sol:25 代理 fallback 中有 console.log(永久 gas);代理註解與程式碼矛盾;角色名稱/雜湊不符;上傳 117 個檔案(含測試);optimizer runs = 0。
1.5 唯一一次升級仍留下缺陷 追溯那一次升級 tx 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 啟用的次數多於被停用的次數時即為 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.entryCount0 > N,所以迴圈主體永遠不會執行;即使執行,第 231 行使用的是 ==,這是一個結果被丟棄的比較運算,而它應該用 = 來賦值。因此 isActive 只回傳計數器比較的結果,完全忽略提供者撤銷。這是 fail-open:取消註冊一個被入侵的 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 會發生 underflow 並 revert。

KYC 證明從未在鏈上驗證。 當授權的驗證者透過 registerOrRenew 註冊或續期一個錢包時(該函式被限制為只有目前 active 的驗證者可以呼叫),流程會到達 _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 行,該參數落在一個完全沒有名稱的參數中,函式上方的文件註解也省略了它,而函式主體從不讀取它。該函式只確認提供者是 active,並在第 577 行回傳 true,因此證明被丟棄而非被檢查。這不是外部人士的繞過,因為只有 active 的驗證者能到達此處,但在鏈上,合約不會對 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 deactivate(單一)
D 0x3dce3265… 0x5092af62a1625fa57404557d3ad417474f3f494c pausedestroyBlackFunds、註冊/取消註冊存款與贖回地址、registerVerifier(單一)
E 0xfd21a76d… 0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2 變更合規模組(setBlacklistServer 等)
F 0x510ac1ff… 0x7daabe5092d3feea5cc19ddbb5380a4eac38f89c addBlackListunfreeze(單一)

從表格中可以得出幾點。

單一簽署者的高風險操作。 大多數高風險操作只需要單一角色,配額為一。下表是從代幣治理合約與五個模組治理合約的 authorizationMatrix 在鏈上讀取的(除非顯示 + B 第二個簽章,否則皆為單一簽章):

操作 管轄者 所需簽章
mintToDepositburnFrom token C x1
freezebatchFreeze freezing module C x1
deactivateadminDeactivate(KYC) KYC module C x1
pausedestroyBlackFunds token D x1
registerunregister(存款/贖回) directory modules D x1
registerVerifierunregisterVerifier KYC module D x1
addBlackListbatchBlackList blacklist module F x1
unfreeze freezing module F x1
removeBlackList blacklist module F x1 + B x1
setBlacklistServersetFreezingServersetCheckingMode token E x1 + B x1
directory-server 與 supply-limit 設定器 token D x1 + B x1
upgradeTo / changeAdmin / setControlAuthority(代幣與每個模組)、unpause token + modules A x1 + B x1

發行、凍結與 KYC 停用都是單一簽章(全部在角色 C 下);暫停、銷毀、目錄變更與驗證者註冊是角色 D 下的單一簽章;黑名單與解除凍結是角色 F 下的單一簽章。只有升級與設定變更需要第二個簽章。請注意不對稱性:freezeaddBlackList 只需要一個簽章,而 removeBlackList 需要兩個簽章,因此限制一個帳戶比解除限制更容易。

這不只是對矩陣的閱讀;它也可以在一筆實際的 mint 中觀察到。截至撰寫本文時最近一次的發行,tx 0xa7e53c…b33d7,是一筆由唯一的角色 C 帳戶(0x2f7f00cc5334fe2861e485ff610f74890a0316ed)發送至代幣治理合約的單一交易。它呼叫 request,而同一筆交易就達到 quorum 並發出從 address(0) 鑄造的 Transfer 事件,沒有獨立的批准交易,也沒有第二位簽署者。因為發行在請求者自己的交易內完成結算,一把金鑰同時請求並執行了 mint。

引擎中也完全沒有時間鎖。最後一個必要簽章一落地,動作就在同一筆交易中執行,沒有任何可供審查、取消或爭議的延遲;引擎會記錄 executedAt 時間戳,但從不檢查它。因此,即使是雙簽章操作,也會在第二把金鑰簽署後立即結算。

一對金鑰可以升級一切。 升級代幣與升級全部五個合規模組使用相同的要求:A 加上 B。由於 A 是一個帳戶,而 B 是共享一個角色的三個帳戶,因此兩個人就可以替換系統中的任何實作。

同一對金鑰也控制角色表。 角色只能在註冊表(HybridControlledAuthority0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c)上透過其自身的儀式授予與撤銷;沒有任何外部金鑰能直接變更它們。而它的 authorizationMatrix(在鏈上讀取)對角色變更的要求與升級相同:角色 A 加上角色 B。因此,能夠替換任何實作的這兩個人,也可以為角色 C 新增一把金鑰、移除現有持有者,並改寫整個角色表。這個雙簽章門檻比上述的單一簽章操作要好,但對於系統的根來說仍然是低標準,因為角色 A 是單一帳戶,沒有任何冗餘。

一個地址,六個角色。 角色 C 的持有者(0x2f7f00cc5334fe2861e485ff610f74890a0316ed)同時持有具名的 ADMIN_TOKEN_HOLDER_ROLE(KYC 驗證者管理),並且是全部四個 auditor 角色(BLACKLIST_AUDITOR_ROLEFREEZING_AUDITOR_ROLEWHITELIST_AUDITOR_ROLEAFL_TOKEN_AUDITOR_HOLDER_ROLE)的成員。入侵這一把金鑰,等於同時失去發行、凍結與 KYC 管理能力。

Auditor 角色本身也有兩個問題。 首先,它們限制合規清單的唯讀 getter,顯然是為了控制誰能讀取它們,但在公開鏈上這是沒有意義的:底層儲存任何人都可以讀取(讀取儲存槽正是我們繪製這個系統的方法),所以這些清單無論如何都是公開的,而這個限制顯示設計時並未考慮到它位於公開鏈上。其次,集中度:全部四個 auditor 角色都落在相同的 23 個帳戶中,而且執行角色 A、C、D 與 F 的持有者也在其中,因此負責 mint、burn、freeze 與 blacklist 的同一批金鑰,同時也身處原本用來審查這些動作的群體中。

撤銷不是即時的。 在儀式引擎中,簽署者的角色只被檢查一次,配額就會被遞減;先前的簽署者不會被重新驗證,因此在事後撤銷某個角色,並不能收回已經被計入的投票:

// 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 路徑中被查詢;在該路徑中,白名單成員資格會觸發 early return,從而繞過 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 上線前建置的跡象

除了具體的邏輯錯誤之外,程式碼庫的若干特性也顯示,一個上線前建置被部署到了主網。

生產環境中的 debug 日誌。 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 個檔案,包括專案的測試套件,到公開區塊瀏覽器上,這等於把內部測試與邊界情況交給了讀者。最近一次升級只改了 compiler-warning 清理,沒有第三方審計在流程中。而且 optimizer 設為零 runs,這使得一個被頻繁使用的代幣的熱路徑變得更昂貴,而不是更便宜。

這些單獨來看都不嚴重。但它們合在一起顯示,這段程式碼沒有經過一個持有主網價值之合約所應有的發布紀律。

1.5 合約的唯一一次升級,追溯

該代理被升級過一次。它於 2026 年 4 月 28 日部署,實作為 0x8ff12fe3bed22d9e40afb4b98ef4dee28d94699e;2026 年 7 月 10 日,它被升級到目前的 0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc。因為升級是鏈上的治理動作,我們可以確切看到是誰批准的。

upgradeTo 需要角色 A 加上角色 B。這次升級是兩個相鄰區塊中的兩筆交易,相隔約十二秒:

  • 請求,角色 A,來自 0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2,在區塊 25500519(tx 0x742372…85136);
  • 批准,角色 B,來自 0x3795300b31429f9d37b0dc805528d9390ce87c50,在區塊 25500520(tx 0xa7a400…630c),該交易達到 quorum 並在同一筆交易中執行升級。

有兩件事特別突出。首先,整個雙簽章儀式在一個區塊間隔內完成。從請求到執行只隔了一個區塊;第二位簽署者沒有任何窗口可以在變更上線前獨立審查。

其次,兩位簽署者都無法從目前角色註冊表中辨識出來。角色此後已經輪換:請求者 0xa9315a… 不再持有角色 A(它今天持有角色 E),而第二位簽署者 0x3795300b… 現在完全不持有任何角色。只讀取目前的註冊表,無法告訴你是誰授權了這次升級;只有交易歷史可以。這就是從另一側看到的「撤銷不是即時」屬性:角色會變動,因此「誰持有什麼」的快照並不是「誰做了什麼」的記錄。

而且這次升級並沒有修復本審查中的任何缺陷。它所安裝的實作 0xe42d38b0… 正是第 1 部分所描述的實作。我們無法從鏈上判斷升級前是否進行了第三方審計;我們能說的是,如果有的話,第 1 部分的缺陷仍然在它之後存活。

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

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

HKMA 條文 要求 合約分歧之處 結論
6.5.3 高風險操作不得是單方面的(多重簽章,以及速度限制或時間鎖等緩解措施) mint、burn、pause、freeze 各自只需一把金鑰即可執行;沒有時間鎖(執行與最後一個簽章原子完成) 分歧
6.5.4 在授權人員之間分隔職責;立即撤銷授權 一個帳戶持有六個角色,因此執行與稽核重疊;已計數的批准在事後撤銷後仍然有效 分歧
6.5.5 每次程式碼變更都需第三方審計;正確、一致、無漏洞 第 1 部分的缺陷存在於已部署的實作中;isActive 與提供者撤銷沒有做到其名稱所示的事 分歧
合規控制的有效性 黑名單、凍結、白名單與 KYC 控制必須有效 已部署程式碼中的 KYC 限制與提供者撤銷無法運作 分歧
2.2.3 被凍結或銷毀的貨幣仍須完全擔保且可對帳 destroy 發出 Transferaddress(this) 而非 address(0),且不觸及淨發行計數器;從事件重建的供應量會偏移 需關注

第 6.5.3 段:高風險操作不得是單方面的

要求。 高風險操作的設計應使任何單一方都無法單方面執行,例如透過多重簽章協議;指引並列出進一步的緩解措施,例如速度限制與時間延遲(timelock)控制。

合約做了什麼。 從治理合約的 authorizationMatrix 讀取(完整矩陣見 1.2 的表格),供應與緊急操作各自只需要單一角色,配額為一:

操作 所需簽章
mintToDeposit ROLE_C x1
burnFrom ROLE_C x1
freeze ROLE_C x1
pause ROLE_D x1
destroyBlackFunds ROLE_D x1

分歧之處。 鑄造、銷毀、暫停與凍結都可以由一把金鑰執行。這不符合「任何單一方都不能單方面」的要求。而且沒有時間鎖:如 1.2 所示,一旦達到 quorum,動作就會在同一筆交易中執行,因此指引所點名的緩解措施之一——時間延遲——也不存在。合約確實實作了供應速度限制(whenWithinRiskThresholds),所以該緩解措施存在,但它不能取代操作本身的多重簽章控制。

第 6.5.4 段:職責分隔與立即撤銷

要求。 不同的操作應在不同的授權人員之間分隔,且授權人員的權限應可立即撤銷。

合約做了什麼。 一個外部擁有帳戶持有六個角色(發行、凍結、KYC 管理,以及全部四個 auditor 角色),因此執行與稽核角色重疊。角色變更本身也只要求角色 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 段:被凍結或銷毀的貨幣仍須完全擔保且可對帳

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

合約做了什麼。 受監管穩定幣常見的補救流程是銷毀不良地址的資金,然後在之後以另一筆獨立 mint 向受害者發行等額資金(這就是 USDT 的 destroyBlackFunds 加上 issue 的運作方式)。HKDAP 的 destroy 會減少 _totalSupply,這是一次銷毀,但它從不貸記 balances[address(this)],它發出的是 Transferaddress(this) 而非 address(0),而且它不觸及 mint 上限所使用的淨發行計數器:

// 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),而從事件重建的總供應量將與鏈上不符。這也混淆了補救流程本身:因為代幣被銷毀而非停放,向受害者的重新發行必須是一筆全新的 mint,但誤導性的 Transfer(..., address(this), ...) 卻暗示合約目前保管著這些代幣,可以將它們轉發出去,而實際上它不能。乾淨地銷毀到 address(0) 再另外重新發行,既正確又可對帳。按照目前的寫法,儲備對帳所依賴的鏈上會計會偏離鏈上的真實狀態。這是一個需要關注的問題,而不是乾淨的通過。

我們將這些發現限制在鏈上所能顯示的範圍。儲備是否完全擔保、金鑰是否位於 HSM 或氣隙環境中,以及交易在簽署前是否在鏈下模擬,都不是這份合約所能顯示的,我們對此不做任何宣稱。

結論

這份審查的兩個向度呈現出一致的圖像。作為軟體,HKDAP 包含功能缺陷,包括無法按原始設計執行的合規控制,並顯示多個上線前建置的跡象。作為受監管的穩定幣,其若干鏈上屬性與 HKMA 指引的具體條文相牴觸。根據鏈上證據,並先擱置我們無法看到的鏈下事項,已部署的合約尚未達到商業穩定幣應有的標準。

有兩點觀察值得明確說出。

首先,在公開鏈上發行會改變合規決策的位置。「任何單一方都不應能單方面行動」這樣的要求,是由已部署程式碼中的角色檢查來滿足或不滿足,而該程式碼是公開的。實作本身——而非牌照或文件——才是這類要求真正被滿足或未滿足的地方,而且任何人都可以驗證是哪一種。

其次,「Beta Access」標籤並不能改變已部署程式碼的風險概況。該合約已在以太坊主網上線,由真實金鑰管理,並代表對港幣的請求權。因此,無論它被貼上什麼標籤,都應以生產標準來衡量。

在具體發現之下有一條共同主線。這個架構以特製形式重新發明了生態系統早已提供並已大規模審計的元件:一個從零撰寫的批准引擎與角色層,而 Safe 多重簽章加上 OpenZeppelin 的 AccessManager 與 TimelockController 就足夠了;以手寫的鏈結串列集合取代 EnumerableSet;以修改過的代理取代標準的 TransparentUpgradeableProxy;以手寫的 ERC-20 取代 OpenZeppelin 的版本。本審查中的大部分缺陷存在於這套客製機器中,而不是在重複使用標準元件的部分。 它讀起來像是以通用軟體的方式進行抽象化,而不是由鏈上開發所偏好的、小而經審計的建構塊組合而成;在鏈上開發中,每一層客製抽象同時也是 gas、攻擊面與升級風險。由這些標準元件組成的設計會更小、更安全、更容易審查,而且會具備這個系統目前缺乏的東西:來自時間鎖的審議窗口,以及具名、可讀的角色。

這裡描述的問題都是可以處理的。在高風險操作上恢復多重簽章、將執行與稽核分開、修復 KYC 撤銷邏輯、統一轉移檢查、移除 debug 程式碼,以及在每次升級前要求第三方審計,就能解決大部分問題。在主網上部署並驗證原始碼,也正是讓這份審查成為可能的原因,而且這是正確的預設做法。這類持續性的鏈上安全與合規審查正是 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