Back to Blog

HKDAPステーブルコインのセキュリティレビュー:稼働中、ライセンス取得済み、しかし準備不足

Phalcon Security
2026年8月14日
27 min read
Key Insights
  • HKDAPのKYC取り消しはデッドコードであり、そのKYC証明はオンチェーンで検証されることがない

  • 単一の鍵でミント、バーン、一時停止、またはフリーズが可能であり、2つの鍵ですべてのアップグレードとロールが制御されている

  • いくつかのオンチェーンの特性がHKMAのステーブルコイン発行者ガイドラインから逸脱している

HKDAP(Anchorpoint)に関するBlockSecのセキュリティおよびコンプライアンスレビュー、2026年8月13日時点。

TL;DR。 香港で発行された初の規制対象ステーブルコインであるHKDAPの、Ethereumメインネット上で稼働しているデプロイ済みコントラクトをレビューしたところ、本番運用の準備が整っていないことが判明しました。KYCおよび取消(revocation)コントロールは記述されたとおりに機能しておらず、ガバナンスは単一のキーがミント、バーン、フリーズを実行できるほど集中しており、そのオンチェーン上のいくつかの特性はHKMA自身のガイドラインと矛盾しています。その根底には共通の問題があります:このコントラクトは、エコシステムが既に提供し、大規模に監査を受けているプリミティブ(マルチシグ、アクセス制御レイヤー、タイムロック、ERC-20自体)を、ゼロから再発明しているという点です。そして、以下の欠陥の大部分は、標準コンポーネントを再利用している部分ではなく、そのカスタム機構の中に存在しています。「Beta Access」というラベルは、そのギャップを埋めるものではありません。

2026年8月12日、AnchorpointはHKDAPの第1フェーズを開始しました。これは香港ドルのステーブルコインです。これは重要なローンチです。Standard Chartered Bank(香港)がHKTおよびAnimoca Brandsと共同で主導するジョイントベンチャーであるAnchorpointは、36の申請者の中からHKMAが付与したわずか2つのステーブルコイン発行者ライセンスのうちの1つを保有しており、HKDAPは香港のStablecoins Ordinance(ステーブルコイン条例)のもとで発行された最初期のステーブルコインの一つです。

規制対象銀行によるほとんどの製品と異なり、HKDAPは直接検査可能です。Ethereumメインネット上で稼働し、そのコントラクトのソースコードはEtherscan上で検証(verified)されています。これは有用な特性です:この方式で発行されるステーブルコインにおいて、発行、送金、フリーズを管理するルールは文書に記述されるのではなく、誰でも読むことができ、記述されたとおりに正確に実行されるコード内に実装されています。ライセンスは主張(claim)であり、デプロイされたコントラクトはその主張の実装であり、それは公開されています。

これにより具体的なレビューが可能になり、それが私たちの行ったことです。私たちはデプロイ済みコントラクトを2つの軸で検証しました。第一に、ソフトウェアとして:正確で本番品質のものか?第二に、規制対象ステーブルコインとして:そのオンチェーン上の振る舞いはHKMAのGuideline on Supervision of Licensed Stablecoin Issuers(免許ステーブルコイン発行者の監督に関するガイドライン)に合致しているか?

その結果は両軸において一貫していました。このコントラクトには、記述されたとおりに機能しないコンプライアンスコントロールを含む、複数の機能的欠陥が存在します。そのガバナンスは非常に集中しており、いくつかの高リスク操作は単一のキーで実行可能です。そして、そのオンチェーン上の特性のいくつかは、HKMAガイドラインの特定の条項と矛盾しています。私たちの評価では、ベータ版であってもこのコントラクトは、商用ステーブルコインが求める品質基準を満たしていません。

本稿の残りの部分では、2026年8月13日時点の当該レビューを提示します。私たちのレビューは、公開デプロイされたコードおよび観察可能なオンチェーン上の事実に基づいています。リザーブの裏付けやオフチェーンのキー管理といった、ブロックチェーンが証明できない事項について、私たちは主張を行いません。

コントラクトの発見方法

私たちはトークンリストからではなく、発行者から出発しました。Anchorpointの企業サイトはanchorpoint.hkにリンクしています。Beta Accessページにはデプロイメントの詳細が記載されています:Ethereumメインネット、プロキシ0x87622385F960fcCB3121d6D0A9513bd1D9Bed6cA、そのソースはEtherscan上で検証済みです。

そこから私たちはオンチェーン上でシステム全体をマッピングしました:トークンプロキシとその実装(ControllableAHKD0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc)、それを管理するガバナンスコントラクト(0x47dd47a776902bee03abdd5aeff43f41b0ffbc2b)、トップレベルのロールレジストリ(0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c)、そしてそれぞれ独自のガバナンスコントラクトを持つ5つのコンプライアンスモジュールです。以下の各関係は、ソースコードのみから推測したのではなく、ストレージスロットを読み取り、メインネット上でview関数を呼び出すことで検証されました。

コントラクトの構造

HKDAPの3つのレイヤー:ガバナンス制御プレーンがトークンを管理し、トークンはすべての送金時に5つのコンプライアンスモジュールに問い合わせます。 image.png

HKDAPのトークンはOpenZeppelinの標準ERC-20リファレンス実装を使用しておらず、そのERC-20ロジックはゼロから書かれています(以下のバグの多くはここから来ています)。このシステムには3つのレイヤーがあります:

  • トークン。 ControllableAHKD、アップグレード可能なプロキシの背後にあります。ミント、バーン、ポーズ、強制破壊(forced-destroy)機能を備えた制御されたERC-20で、すべての送金にコンプライアンスチェックが組み込まれています。
  • 自作のM-of-Nガバナンスエンジン。 すべての特権操作(アップグレード、ミント、バーン、ポーズ、ブラックリスト登録、フリーズ、コンプライアンスモジュールの変更)は、単純なマルチシグではなく、リクエスト、承認、実行という一連の儀式(ceremony)を経ます。
  • 5つのコンプライアンスモジュール。 ブラックリスト、フリーズ、KYCアクティベーションサービス、そしてデポジットおよびリデンプションのホワイトリストです。各モジュール自体もプロキシであり、独自の制御権限(control authority)コントラクトによって管理されています。

構造的には、この「制御権限+プロキシ+実装」というユニットが(トークンと5つのモジュールで)6回繰り返されており、この6つの制御権限すべてが単一のレジストリでロールを解決します。システム内のすべての制御は最終的にこの1つのレジストリに集約され、その中のロールを書き換えられるかどうかは、以下で詳しく示すように、非常に少数の署名キーに帰着します。

パート1:セキュリティとバグ

このパートの調査結果は以下にまとめられています。各行は対応するセクションで詳述されています。

領域 調査結果 場所 影響
1.1 KYCと取消コントロールが機能しない KYC取消がデッドコード TokenHolderActivationServerLibrary.sol:221-235 isActiveはプロバイダーの登録解除を無視する(ループが決して実行されず、===ではなく使われている);フェイルオープン(fail-open)。
検証者の登録解除がINACTIVEを設定しない TokenHolderActivationServer.sol:504-511 「登録解除された」検証者は依然としてウォレットをオンボードおよび非活性化できる;2回目の呼び出しはリバートする。
KYC証明がオンチェーンで検証されない TokenHolderActivationServer.sol:575-578 証明は渡されるが破棄される;空文字列を含む任意の証明が通過する。
無料送金の免除を分割して回避できる ControllableAHKD.sol:337-535 freeTransferLimitは呼び出しごとにチェックされ、累積ではない;未KYC活動の上限として使用される場合、上限未満の送金に分割することで回避できる。
1.2 ガバナンスが過度に集中している 高リスク操作がシングルシグ authorizationMatrix;ミントtx 0xa7e53c…b33d7 ミント/バーン/フリーズ/KYC非活性化(ロールC)、ポーズ/破壊(ロールD)、ブラックリスト登録/フリーズ解除(ロールF)はそれぞれ単一のキーで実行される。
一組のキー(A + B)がすべてをアップグレードできる authorizationMatrix トークンおよび5つのモジュールすべてのupgradeToはロールA + B;2人で任意の実装を置き換えられる。
同じ組がすべてのロールを書き換える レジストリ0xa728… authorizationMatrix レジストリ上のgrantRolerevokeRoleもロールA + B(ADMIN_ROLEはコントラクト自身のみが保持;DEFAULT_ADMIN_ROLE = address(0));外部キーが直接ロールを変更することはできない。
1つのアドレスが6つのロールを保持 ロールレジストリ0xa728… 0x2f7f00…はロールCに加えADMIN_TOKEN_HOLDERおよび4つの監査人ロールすべてを保持;実行と監査が重複している。
取消が即時ではない;タイムロックなし 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 プロキシのフォールバック内のconsole.log(永続的なガスコスト);プロキシのコメントがコードと矛盾;ロール名/ハッシュの不一致;テストを含む117ファイルがアップロードされている;オプティマイザーの実行回数=0。
1.5 唯一のアップグレードでも欠陥が残されたまま 単一のアップグレードの追跡 tx 0x742372…851360xa7a400…630c デプロイ4月28日→アップグレード7月10日;A + Bの儀式の2つのトランザクションは1ブロック(約12秒)差だった;パート1のすべての欠陥がインストールされた実装内に存在する。

1.1 KYCと取消コントロールが記述通りに機能しない

規制のもとでは、HKDAPが対応するB側(法人)ユーザーはKYCを通過することが求められます。コントラクト内では、これは少なくとも2つのことを行う必要があることを意味します:送金時にKYCを強制し、KYCを通過していないウォレットをブロックすること、そしてIDプロバイダーまたはホルダーが削除された際にアクセスを取り消すことです。HKDAPでは、この経路が3つの独立した箇所で破綻しています。

KYC取消がデッドコードです。 トークンはKYCモジュール上でisActive(address)を呼び出すことで送金をゲートします。isActiveは2つのことを行うべきです:まず、ウォレットごとのカウンターから基本値を設定し、そのウォレットが非活性化された回数よりも活性化された回数が多い場合にアクティブとします;次に、そのウォレットを保証したIDプロバイダーがその後登録解除された場合、その値をfalseに絞り込みます。この2つは2種類の取消をカバーします:1つのホルダーのKYCの取消(カウンター)と、プロバイダー全体の取消(そのプロバイダーがオンボードしたすべてのウォレットもそれに伴って取り消されるようにするループ)です。実際に起きるのは最初の1つだけです。

// 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行目は実際の代入(=)であり、これが唯一有効になるものです:activedeactivationCount < 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のままであるため、「登録解除された」検証者は依然としてホルダーを登録および非活性化できます。プロバイダーを再活性化するために意図された分岐は到達不能であり、2回目の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を経て下に伝え、それを第2引数として渡します。しかし575行目でこの引数は名前を持たないパラメータに落ち着き、この関数の上のドキュメントコメントもそれを省略しており、その本体は決してこれを読みません。この関数は577行目でプロバイダーがアクティブであることを確認するだけでtrueを返すため、証明はチェックされるのではなく破棄されます。これは部外者によるバイパスではありません(アクティブな検証者のみがここに到達できるため)が、オンチェーンでは、コントラクトはKYCの証拠に対する検証を一切行わず、その完全性はオフチェーンの検証者に完全に依存しています。不正または不注意な検証者は、空文字列を含む任意の証明で任意のウォレットを活性化できます。

これらの3点を合わせると、コンプライアンスの中核的な特性であるアクセスをゲートし取り消す能力が、記述されたとおりに機能していないことを意味します。

これら3点に加え、関連する弱点があります:無料送金の免除は分割して回避できます。 トークンにはfreeTransferLimitがあり、それ未満の送金はisActive(KYC)チェックをスキップします。しかし、この上限は現在の送金額に対してのみ比較され、コントラクトはアドレスまたは期間ごとの累積合計を保持していません。したがって、この上限が未KYC活動の上限として使用される場合、ホルダーは上限未満の反復送金に分割することで任意の合計額を移動でき、その上限を無効化できます。

1.2 ガバナンスが過度に集中しており、高リスク操作がシングルシグである

私たちはオンチェーン上のすべてのロールとすべてのロール保持者を列挙しました。具体的な内容の前に、2つの点が目立ちます。

第一に、M-of-N儀式を承認するロールには読み取り可能な名前がありません。デプロイされた設定では、これらは32バイトのハッシュとしてのみ表示され、そのいずれも検証済みソース内の名前付きロール定数と一致しません(SUPPLY_CONTROLLER_ROLEのような名前付きロールは、署名者ではなくコントラクト自身が保持しています)。私たちは6つの署名者ロールをAからFと表記します。システム内で最も強力なロールが不透明な識別子であること自体が弱点です:これはガバナンスを、名前付きロールの場合よりもレビューしにくくします。

第二に、保持者の数が少ないです。以下の表はオンチェーン上のロールレジストリから読み取ったものです;アドレスは省略されています。

ロール(当社の表記) オンチェーンハッシュ 保持者 何を承認するか
A 0x37f0f656… 0x747356d525bf47f495af7c6e42f3ab9b8ba87a4b upgradeTochangeAdminsetControlAuthorityの最初の署名
B 0x95c27f81… 0x8117a657df7a1c399231bfe26a096eb8f8ad59730x5d9d9b28e83382e694916b0feeaf7a9badbb659c0xebb73f78e253539acceb4e6cc287095788eee5d4 ほぼすべての2署名操作における2番目の署名
C 0xfa2fe896… 0x2f7f00cc5334fe2861e485ff610f74890a0316ed mintToDepositburnFromfreeze、KYC非活性化(単独)
D 0x3dce3265… 0x5092af62a1625fa57404557d3ad417474f3f494c pausedestroyBlackFunds、デポジット・リデンプションアドレスの登録/登録解除、registerVerifier(単独)
E 0xfd21a76d… 0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2 コンプライアンスモジュールの変更(setBlacklistServerなど)
F 0x510ac1ff… 0x7daabe5092d3feea5cc19ddbb5380a4eac38f89c addBlackListunfreeze(単独)

表からいくつかの点が導かれます。

シングルシグの高リスク操作。 ほとんどの高リスク操作は、定数1のロール1つを必要とします。以下の表は、トークンのガバナンスコントラクトおよび5つのモジュールガバナンスコントラクトのauthorizationMatrixからオンチェーンで読み取ったものです(2番目の署名が示されていない場合はシングルシグです):

操作 管理元 必要な署名
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の下でシングルシグです。アップグレードと設定変更のみが2番目の署名を必要とします。非対称性に注目してください:freezeaddBlackListは1つの署名しか必要としませんが、removeBlackListは2つを必要とします。つまり、アカウントを制限する方が解除するよりも容易です。

これはマトリックスの読解にとどまらず、実際のミントで観察可能です。本稿執筆時点の最新の発行、tx 0xa7e53c…b33d7は、単一のロールC保持アカウント(0x2f7f00cc5334fe2861e485ff610f74890a0316ed)からトークンのガバナンスコントラクトに送信された単一のトランザクションです。これはrequestを呼び出し、その同じトランザクションが定足数に到達し、address(0)からのミントTransferを発行しますが、別の承認トランザクションや2番目の署名者はありません。発行が要求者自身のトランザクション内で完結するため、1つのキーがミントの要求と実行の両方を行いました。

エンジン内にはどこにもタイムロックがありません。最後の必要な署名が入った瞬間、その操作は同じトランザクション内で実行され、レビュー、キャンセル、または異議申し立てを行える遅延はありません;エンジンはexecutedAtタイムスタンプを記録しますが、これを一度もチェックしません。したがって、2署名操作でも、2番目のキーが署名した瞬間に即座に決済されます。

一組のキーがすべてをアップグレードします。 トークンと5つのコンプライアンスモジュールすべてのアップグレードは、同じ要件、A + Bを使用します。Aは1つのアカウントであり、Bは1つのロールを共有する3つのアカウントであるため、2人でシステム内の任意の実装を置き換えることができます。

同じ組がロールテーブルも制御します。 ロールは、レジストリ(HybridControlledAuthority0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c)上でのみ、独自の儀式を通じて付与および取り消すことができます;外部キーが直接これらを変更することはできません。そしてオンチェーンで読み取ったauthorizationMatrixは、ロール変更にアップグレードと同じ署名を要求します:ロールA + ロールB。したがって、任意の実装を置き換えられる2人は、ロールCにキーを追加すること、既存の保持者を削除すること、そしてロールテーブル全体を書き換えることもできます。この2署名のゲートは上記のシングルシグ操作よりも良いものですが、システムの根本に対しては依然として低いハードルです。ロールAが冗長性のない単一のアカウントであるためです。

1つのアドレスに6つのロール。 ロールCの保持者(0x2f7f00cc5334fe2861e485ff610f74890a0316ed)は、名前付きのADMIN_TOKEN_HOLDER_ROLE(KYC検証者管理)も保持し、4つの監査人ロール(BLACKLIST_AUDITOR_ROLEFREEZING_AUDITOR_ROLEWHITELIST_AUDITOR_ROLEAFL_TOKEN_AUDITOR_HOLDER_ROLE)のすべてのメンバーでもあります。この1つのキーが侵害されると、発行、フリーズ、KYC管理が同時に失われます。

監査人ロールには独自の2つの問題があります。 第一に、これらはコンプライアンスリストの読み取り専用のゲッターをゲートしており、誰が読み取れるかを制御するように見えますが、パブリックチェーン上ではそれは無意味です:基盤となるストレージは誰でも読み取り可能であるため(ストレージスロットの読み取りは、私たちがこのシステムをマッピングした方法です)、リストはどちらにせよ公開されており、この制限は設計がパブリックチェーン上にあることを考慮していなかったことを示しています。第二に、集中:4つの監査人ロールすべてが同じ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は資金の源泉ではなく承認された支出者であるため、コードはすべてのモードで実際の源泉であるfromを明示的にチェックします;transferでは、msg.senderが源泉であるため、これは必要ありません。この調整は合理的です。他の2つの違いは、開始者によって説明されるものではなく、同じ経済的行為が異なる規則によって管理される結果を生んでいます。

第一に、デポジットおよびリデンプションのホワイトリストは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は2つの関数間で異なる意味を持ちます。transferでは、「Source」モードは送信者をチェックします;transferFromでは、「Source」モードは支出者(msg.sender)をチェックし、fromはすべてのモードでチェックされます。したがって、単一の設定値がエントリポイントに応じて2つの異なるポリシーを実施することになります。

その結果、規制対象トークンの送金コントロールは使用される関数に依存することになり、これは論理の追跡を困難にし、設定によっては回避可能なものにします。

1.4 本番前ビルドの兆候

特定のロジックバグに加えて、コードベースのいくつかの特性は、本番前のビルドがメインネットにデプロイされたことを示しています。

本番環境でのデバッグロギング。 hardhat/console.log呼び出しがコード全体に残っており、その中にはプロキシのフォールバック(すべてのユーザートランザクションで実行される)も含まれます。プロキシ自体はアップグレード可能ではないため、このオーバーヘッドは永続的です:

// 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文字列で宣言されており、2つの異なるロールを生成しています:

// 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') ;

このデプロイメントは、各モジュールが独自のコピーを保持しているためにのみ問題を回避しています;2番目のロール名(AFL_TOKEN_HOLDER_AUDITOR_ROLE)も同じように転置されています。

その他の兆候。 プロキシの検証バンドルは、プロジェクトのテストスイートを含む117個のファイルを公開エクスプローラーにアップロードしており、これは読者に内部テストとエッジケースを渡すことになります。最新のアップグレードはコンパイラの警告修正のみを変更したもので、サードパーティの監査は介入していません。そしてオプティマイザーはランが0に設定されており、これは頻繁に使用されるトークンのホットパスを、安くするどころか高くします。

これらのいずれも単独では重大ではありません。しかし、これらが合わさることで、メインネット上で価値を保有するコントラクトに求められるリリースの規律をこのコードが経ていないことを示しています。

1.5 コントラクトの唯一のアップグレードの追跡

このプロキシは1度アップグレードされています。実装0x8ff12fe3bed22d9e40afb4b98ef4dee28d94699eと共に2026年4月28日にデプロイされ、2026年7月10日に現在の0xe42d38b05d7ff702193ac5ccedb411b7298ffbfcにアップグレードされました。アップグレードはオンチェーンのガバナンス操作であるため、誰がこれを承認したかを正確に確認できます。

upgradeToはロールA + ロールBを要求します。このアップグレードは隣接するブロック内の2つのトランザクションで、約12秒の間隔でした:

  • リクエスト、ロールA、0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2から、ブロック25500519(tx 0x742372…85136);
  • 承認、ロールB、0x3795300b31429f9d37b0dc805528d9390ce87c50から、ブロック25500520(tx 0xa7a400…630c)、これが定足数に達し、同じトランザクション内でアップグレードを実行しました。

2つの点が目立ちます。第一に、2署名の儀式全体が単一のブロック間隔内で完了しました。リクエストから実行まで1ブロックであり、2番目の署名者がその変更が有効になる前に独立してレビューできる窓はありませんでした。

第二に、現在のロールレジストリからはどちらの署名者も特定できません。ロールはその後ローテーションされています:要求者0xa9315a…はもはやロールAを保持しておらず(現在はロールEを保持しています)、2番目の署名者0x3795300b…は現在何のロールも保持していません。現状のレジストリを読んでも、誰がこのアップグレードを承認したかは分からず、トランザクション履歴のみがそれを教えてくれます。これは「取消が即時ではない」という特性を別の側面から見たものです:ロールは移動するため、誰が何を保持しているかのスナップショットは、誰が何をしたかの記録にはなりません。

そしてこのアップグレードは、本レビューにある欠陥を何も修正していません。それがインストールした実装0xe42d38b0…は、パート1で説明されているものそのものです。私たちはチェーンから、このアップグレード前にサードパーティの監査が実施されたかどうかを判断できません;私たちが言えるのは、もしそれが実施されていたとしても、パート1の欠陥はそれを乗り越えて生き残ったということです。

パート2:香港のステーブルコインフレームワークに合致しているか?

香港のStablecoins Ordinance(ステーブルコイン条例)は2025年8月1日に施行され、免許を受けた発行者はHKMAのGuideline on Supervision of Licensed Stablecoin Issuersのもとで監督されています。私たちは、スマートコントラクト単独で満たせる条項のみを比較しました;リザーブの裏付け、カストディ、オフチェーンのキー儀式は、オンチェーンレビューの範囲外です。以下の各条項について、ガイドラインが要求する内容、コントラクトが実際に行っている内容、そして両者が異なる点を記述します。比較は以下に要約され、続くセクションで詳述されます。

HKMA条項 要求される内容 コントラクトが異なる点 判定
6.5.3 高リスク操作は単独で実行できてはならない(マルチシグ、および速度制限やタイムロックなどの緩和策) ミント、バーン、ポーズ、フリーズがそれぞれ1つのキーで実行される;タイムロックがない(実行は最終署名と同時にアトミック) 不一致
6.5.4 権限を持つ者の間で職務を分離する;権限を即時に取り消す 1つのアカウントが6つのロールを保持し、実行と監査が重複している;カウントされた承認は後の取消を乗り越えて生き残る 不一致
6.5.5 すべてのコード変更にサードパーティ監査;正確で、一貫性があり、脆弱性がないこと パート1の欠陥がデプロイ済み実装内に生きている;isActiveとプロバイダー取消はその名前が示す動作をしていない 不一致
コンプライアンスコントロールの有効性 ブラックリスト、フリーズ、ホワイトリスト、KYCのコントロールは有効でなければならない KYCゲーティングとプロバイダー取消はデプロイ済みコードにおいて機能していない 不一致
2.2.3 フリーズまたは破壊されたコインは完全に裏付けられ、照合可能でなければならない 破壊はaddress(0)ではなくaddress(this)への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つのキーで実行できます。これは「単独の当事者が単独で実行できない」という要件を満たしていません。タイムロックもありません:1.2で示したように、定足数に達すると同じトランザクション内で操作が実行されるため、ガイドラインが名前を挙げている緩和策の一つである時間遅延も存在しません。このコントラクトは供給速度制限(whenWithinRiskThresholds)を実装しているため、その緩和策は存在しますが、それは操作自体に対するマルチシグ制御の代わりにはなりません。

第6.5.4項:職務の分離と即時取消

要求される内容。 異なる操作は異なる権限を持つ者の間で分離されるべきであり、権限を持つ者の権限は即時に取り消せるべきです。

コントラクトが実際に行っている内容。 1つの外部所有アカウントが6つのロール(発行、フリーズ、KYC管理、そして4つの監査人ロールすべて)を保持しており、実行と監査のロールが重複しています。ロール変更自体もロール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(0)ではなくaddress(this)への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ガイドラインの特定の条項と矛盾しています。オンチェーンの証拠に基づき、私たちが見ることのできないオフチェーンの事項を除外すると、デプロイ済みコントラクトは、商用ステーブルコインが満たすべき基準をまだ満たしていません。

そこから2つの観察が導かれ、これらは明確に述べる価値があります。

第一に、パブリックチェーン上での発行は、コンプライアンスがどこで決定されるかを変えます。「単独の当事者が単独で行動できてはならない」という要件は、デプロイ済みコードのロールチェックによって満たされるか満たされないかが決まり、そのコードは公開されています。この要件が実際に満たされるかどうかを決めるのは、ライセンスや文書ではなく実装であり、誰でもどちらであるかを検証できます。

第二に、「Beta Access」というラベルは、デプロイ済みコードのリスクプロファイルを変えません。このコントラクトはEthereumメインネット上で稼働しており、実際のキーによって管理され、香港ドルに対する請求権を表しています。したがって、それがどのようにラベル付けされていようと、本番基準で評価されるべきです。

具体的な調査結果の根底には共通の問題があります。このアーキテクチャは、エコシステムが既に提供し大規模に監査を受けているプリミティブを、独自の形で再発明しています:Safeマルチシグ、OpenZeppelinのAccessManagerとTimelockControllerで済むはずの部分に、ゼロから作られた承認エンジンとロールレイヤー;EnumerableSetの代わりに手書きのリンクリストコレクション;標準のTransparentUpgradeableProxyの代わりに改変されたプロキシ;そしてOpenZeppelinのERC-20の代わりに手書きのERC-20。本レビューにおける欠陥の大部分は、標準コンポーネントを再利用している部分ではなく、そのカスタム機構の中に存在しています。 これは、オンチェーン開発が好むような小さく監査済みのビルディングブロックから構成されるのではなく、汎用ソフトウェアと同じように抽象化されたシステムのように見えます。カスタム抽象化のあらゆる層は、ガス、攻撃対象領域、そしてアップグレードリスクでもあります。これらの標準コンポーネントから構築された設計は、より小さく、より安全で、レビューしやすいものになり、このシステムが現在欠いているもの、つまりタイムロックによる審議期間と、名前がつけられ判読可能なロールを備えたものになるでしょう。

ここで説明されている問題は対処可能です。高リスク操作でのマルチシグの復元、実行と監査の分離、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