Back to Blog

HKDAP Stablecoin Security Review: Live, Licensed, Not Ready

Phalcon Security
August 14, 2026
26 min read
Key Insights
  • HKDAP's KYC revocation is dead code and its KYC proof is never validated on-chain

  • A single key can mint, burn, pause, or freeze; two keys control all upgrades and roles

  • Several on-chain properties diverge from the HKMA stablecoin issuer guideline

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

TL;DR. We reviewed the deployed contract of HKDAP, the first regulated stablecoin issued in Hong Kong, live on Ethereum mainnet, and it is not production-ready. Its KYC and revocation controls do not work as written, its governance is concentrated enough that a single key can mint, burn, or freeze, and several of its on-chain properties conflict with the HKMA's own guideline. A common thread runs underneath: the contract reinvents from scratch the primitives the ecosystem already provides and audits at scale (a multisig, an access-control layer, a timelock, the ERC-20 itself), and most of the defects live in that custom machinery rather than in the parts that reuse standard components. The "Beta Access" label does not close that gap.

On August 12, 2026, Anchorpoint launched the first phase of HKDAP, a Hong Kong dollar stablecoin. It is a significant launch. Anchorpoint, a joint venture led by Standard Chartered Bank (Hong Kong) with HKT and Animoca Brands, holds one of only two stablecoin issuer licences the HKMA has granted, out of 36 applicants, and HKDAP is among the first stablecoins issued under Hong Kong's Stablecoins Ordinance.

Unlike most products from a regulated bank, HKDAP is directly inspectable. It runs on Ethereum mainnet, and its contract source is verified on Etherscan. This is a useful property: for a stablecoin issued this way, the rules that govern issuance, transfer, and freezing are not described in a document, they are implemented in code that anyone can read and that executes exactly as written. A licence is a claim; the deployed contract is the implementation of that claim, and it is public.

That makes a concrete review possible, and that is what we did. We examined the deployed contract along two axes. First, as software: is it correct and production-grade? Second, as a regulated stablecoin: does its on-chain behavior match the HKMA's Guideline on Supervision of Licensed Stablecoin Issuers?

The findings are consistent along both axes. The contract contains multiple functional defects, including compliance controls that do not work as written. Its governance is highly concentrated, with several high-risk operations executable by a single key. And a number of its on-chain properties conflict with specific clauses of the HKMA guideline. Our assessment is that, even as a beta, the contract does not meet the quality bar a commercial stablecoin requires.

The rest of this article presents that review, current as of August 13, 2026. Our review is based on publicly deployed code and observable on-chain facts. We make no claims about matters the blockchain cannot establish, such as reserve backing or off-chain key custody.

How we found the contract

We started from the issuer, not from a token list. Anchorpoint's company presence links to its site at anchorpoint.hk. The Beta Access page names the deployment: Ethereum mainnet, proxy 0x87622385F960fcCB3121d6D0A9513bd1D9Bed6cA, whose source is verified on Etherscan.

From there we mapped the full system on-chain: the token proxy and its implementation (ControllableAHKD, 0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc), the governance contract that administers it (0x47dd47a776902bee03abdd5aeff43f41b0ffbc2b), the top-level role registry (0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c), and five compliance modules, each with its own governance contract. Every relationship below was verified by reading storage slots and calling view functions on mainnet, not inferred from source alone.

What the contract looks like

The three layers of HKDAP: a governance control plane administers the token, and the token queries five compliance modules on every transfer. image.png

HKDAP's token does not use OpenZeppelin's standard ERC-20 reference implementation; its ERC-20 logic is written from scratch (this is where several of the bugs below come from). The system has three layers:

  • The token. ControllableAHKD, behind an upgradeable proxy. A controlled ERC-20 with mint, burn, pause, and forced-destroy, plus compliance checks wired into every transfer.
  • A home-grown M-of-N governance engine. Every privileged action (upgrade, mint, burn, pause, blacklist, freeze, change a compliance module) goes through a request, approve, execute ceremony rather than a plain multisig.
  • Five compliance modules. Blacklist, freezing, a KYC activation service, and deposit and redemption whitelists. Each module is itself a proxy governed by its own control-authority contract.

Structurally, this same "control authority plus proxy plus implementation" unit repeats six times (the token plus five modules), and all six control authorities resolve their roles at a single registry. All control in the system ultimately converges on this one registry, and who can rewrite the roles inside it comes down to a very small set of signer keys, as we show in detail below.

Part 1: Security and bugs

The findings in this part are summarized below; each row is detailed in the section noted.

Area Finding Where Effect
1.1 KYC and revocation controls fail KYC revocation is dead code TokenHolderActivationServerLibrary.sol:221-235 isActive ignores provider de-registration (the loop never runs, and uses == not =); fail-open.
Verifier de-registration never sets INACTIVE TokenHolderActivationServer.sol:504-511 A "de-registered" verifier can still onboard and de-activate wallets; a second call reverts.
KYC proof never validated on-chain TokenHolderActivationServer.sol:575-578 The proof is passed in but discarded; any proof, including an empty string, passes.
Free-transfer exemption can be split around ControllableAHKD.sol:337-535 freeTransferLimit is checked per call, not cumulatively; if used as a cap on un-KYC'd activity, it can be bypassed by splitting into sub-limit transfers.
1.2 Governance is over-concentrated High-risk operations are single-signature authorizationMatrix; mint tx 0xa7e53c…b33d7 mint / burn / freeze / KYC-deactivate (role C), pause / destroy (role D), blacklist / unfreeze (role F) each execute with one key.
One pair of keys (A + B) upgrades everything authorizationMatrix upgradeTo for the token and all five modules is role A + B; two people can replace any implementation.
The same pair rewrites every role registry 0xa728… authorizationMatrix grantRole / revokeRole on the registry are also role A + B (ADMIN_ROLE is held only by the contract; DEFAULT_ADMIN_ROLE = address(0)); no external key can change roles directly.
One address holds six roles role registry 0xa728… 0x2f7f00… holds role C plus ADMIN_TOKEN_HOLDER and all four auditor roles; execution and audit overlap.
Revocation is not immediate; no timelock HybridControlEngine.sol:158-227 A counted signature is not re-validated after a role is revoked; execution is atomic with the final signature, with no delay.
The engine's audit trail is unreliable HybridControlEngine.sol:36-129, 177-196 evtApprove emits address(0) as the signer; the active-request list uses nonce 0 as both a real id and the empty marker, so reverse traversal misses the first request.
1.3 Inconsistent transfer checks transfer and transferFrom enforce different rules ControllableAHKD.sol:313-502 The whitelist early-return bypasses KYC only via transferFrom; checkingMode checks different parties; the same transfer is governed differently.
1.4 Signs of a pre-production build debug logs, wrong comment, name/hash mismatch, tests uploaded UpgradeableProxy.sol:115-119; ControllableAHKD.sol:25 console.log in the proxy fallback (permanent gas); proxy comment contradicts the code; role name/hash mismatch; 117 files incl. tests uploaded; optimizer runs = 0.
1.5 The one upgrade left defects in place tracing the single upgrade tx 0x742372…85136, 0xa7a400…630c Deploy Apr 28 → upgrade Jul 10; the A + B ceremony's two transactions were one block apart (~12s); every Part 1 defect is in the installed implementation.

1.1 The KYC and revocation controls do not work as written

Under the regulation, the B-side (institutional) users HKDAP serves are required to pass KYC. In the contract, that means it has to do at least two things: enforce KYC on transfers, so that a wallet which has not passed KYC is blocked, and revoke access when an identity provider or a holder is removed. In HKDAP, that path is broken in three independent places.

KYC revocation is dead code. The token gates transfers by calling isActive(address) on the KYC module. isActive is meant to do two things: first, set a base value from a per-wallet counter, active when that wallet has been KYC-activated more times than it has been de-activated; then narrow that value to false if any identity provider that vouched for the wallet has since been de-registered. The two cover two kinds of revocation, revoking one holder's KYC (the counter) and revoking a whole provider so that every wallet it onboarded falls with it (the loop). Only the first happens.

// 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    }

Line 221 is a real assignment (=), and it is the only one that takes effect: active becomes deactivationCount < activationCount. The loop (lines 226-235) is what should narrow that value, but it fails twice. Its condition on line 226, entryCount > chainedItemList.entryCount, is 0 > N, so the body never runs; and even if it did, line 231 uses ==, a comparison whose result is discarded, where it should assign with =. isActive therefore returns the counter comparison alone and ignores provider revocation entirely. This is fail-open: de-registering a compromised KYC provider does not stop the wallets it onboarded from transacting. And because unregisterVerifier does not touch those counters, a revoked provider's wallets keep a positive count and stay active.

Provider revocation is broken on the other side too. unregisterVerifier only moves a linked-list node; it never sets the provider's directory status to 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    }

Because the status stays ACTIVE, a "de-registered" verifier can still register and de-activate holders, the branch meant to reactivate a provider is unreachable, and a second unregisterVerifier call underflows and reverts.

The KYC proof is never validated on-chain. When an authorized verifier registers or renews a wallet through registerOrRenew (gated so that only a currently active verifier can call it), the flow reaches _checkKYCProof, which is meant to validate the submitted proof against the provider's scheme. Per the interface, that proof is "a URI for an Oracle, or a signed hash from a verifiable source." The function ignores it:

// 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    }

The proof does reach this function. registerOrRenew carries the submitted kycProof down through _registerOrRenew and passes it in as the second argument. But on line 575 that argument lands in a parameter with no name at all, which the doc comment above the function also omits, and the body never reads it. The function only confirms the provider is active and returns true on line 577, so the proof is discarded rather than checked. This is not an outsider bypass, since only an active verifier can reach it, but on-chain the contract performs no validation of the KYC evidence, so its integrity rests entirely on the off-chain verifier. A compromised or careless verifier can activate any wallet with any proof, including an empty string.

Together, these three mean that a core compliance property, the ability to gate and to revoke access, does not work as written.

Beyond these three, there is a related weakness: the free-transfer exemption can be split around. The token has a freeTransferLimit, and a transfer below it skips the isActive (KYC) check. But the limit is compared only against the amount of the current transfer; the contract keeps no cumulative total per address or period. So if the limit is used as a cap on un-KYC'd activity, a holder can move an arbitrary total by splitting it into repeated transfers each just under the limit, which makes the cap ineffective.

1.2 Governance is over-concentrated, and high-risk operations are single-signature

We enumerated every role and every role holder on-chain. Two things stand out before the specifics.

First, the roles that authorize the M-of-N ceremonies have no readable names. In the deployed configuration they appear only as 32-byte hashes, and none of them matches a named role constant in the verified source (the named roles such as SUPPLY_CONTROLLER_ROLE are held by the contracts themselves, not by the signers). We label the six signer roles A to F. That the most powerful roles in the system are opaque identifiers is itself a weakness: it makes the governance harder to review than named roles would.

Second, the number of holders is small. The table below is read from the role registry on-chain; addresses are abbreviated.

Role (our label) On-chain hash Holder(s) What it authorizes
A 0x37f0f656… 0x747356d525bf47f495af7c6e42f3ab9b8ba87a4b first signature for upgradeTo / changeAdmin / setControlAuthority
B 0x95c27f81… 0x8117a657df7a1c399231bfe26a096eb8f8ad5973, 0x5d9d9b28e83382e694916b0feeaf7a9badbb659c, 0xebb73f78e253539acceb4e6cc287095788eee5d4 the second signature on nearly every two-signature action
C 0xfa2fe896… 0x2f7f00cc5334fe2861e485ff610f74890a0316ed mintToDeposit, burnFrom, freeze, KYC deactivate (single)
D 0x3dce3265… 0x5092af62a1625fa57404557d3ad417474f3f494c pause, destroyBlackFunds, register/unregister deposit & redemption addresses, registerVerifier (single)
E 0xfd21a76d… 0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2 change the compliance modules (setBlacklistServer, and so on)
F 0x510ac1ff… 0x7daabe5092d3feea5cc19ddbb5380a4eac38f89c addBlackList, unfreeze (single)

Several points follow from the table.

Single-signer high-risk operations. Most high-risk operations require a single role at a quota of one. The table below is read on-chain from the authorizationMatrix of the token's governance contract and of the five module governance contracts (single signature unless a + B second signature is shown):

Operation Governed by Required signatures
mintToDeposit, burnFrom token C x1
freeze, batchFreeze freezing module C x1
deactivate, adminDeactivate (KYC) KYC module C x1
pause, destroyBlackFunds token D x1
register, unregister (deposit / redemption) directory modules D x1
registerVerifier, unregisterVerifier KYC module D x1
addBlackList, batchBlackList blacklist module F x1
unfreeze freezing module F x1
removeBlackList blacklist module F x1 + B x1
setBlacklistServer, setFreezingServer, setCheckingMode token E x1 + B x1
directory-server and supply-limit setters token D x1 + B x1
upgradeTo / changeAdmin / setControlAuthority (token and every module), unpause token + modules A x1 + B x1

Issuance, freezing, and KYC de-activation are single-signature (all under role C); pause, destroy, directory changes, and verifier registration are single-signature under role D; blacklisting and unfreezing are single-signature under role F. Only upgrades and configuration changes take a second signature. Note the asymmetry: freeze and addBlackList need one signature while removeBlackList needs two, so restricting an account is easier than releasing it.

This is not just a reading of the matrix; it is observable in a live mint. The most recent issuance at the time of writing, tx 0xa7e53c…b33d7, is a single transaction sent by the sole role-C account (0x2f7f00cc5334fe2861e485ff610f74890a0316ed) to the token's governance contract. It calls request, and that same transaction reaches quorum and emits the mint Transfer from address(0), with no separate approval transaction and no second signer. Because issuance settles inside the requester's own transaction, one key both requested and executed the mint.

There is also no timelock anywhere in the engine. The moment the last required signature lands, the action executes in that same transaction, with no delay in which it could be reviewed, cancelled, or contested; the engine records an executedAt timestamp but never checks one. So even the two-signature operations settle instantly once the second key signs.

One pair of keys upgrades everything. Upgrading the token and upgrading all five compliance modules use the same requirement, A plus B. Since A is one account and B is three accounts sharing a role, two people can replace any implementation in the system.

The same pair also controls the role table. Roles can only be granted and revoked on the registry (HybridControlledAuthority, 0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c), through its own ceremony; no external key can change them directly. And its authorizationMatrix, read on-chain, requires the same signatures for a role change as for an upgrade: role A plus role B. So the two people who can replace any implementation can also add a key to role C, remove an existing holder, and rewrite the entire role table. That two-signature gate is better than the single-signature operations above, but it is still a low bar for the root of the system, since role A is a single account with no redundancy.

One address, six roles. The holder of role C (0x2f7f00cc5334fe2861e485ff610f74890a0316ed) also holds the named ADMIN_TOKEN_HOLDER_ROLE (KYC verifier management) and is a member of all four auditor roles (BLACKLIST_AUDITOR_ROLE, FREEZING_AUDITOR_ROLE, WHITELIST_AUDITOR_ROLE, and AFL_TOKEN_AUDITOR_HOLDER_ROLE). Compromise of that one key is loss of issuance, freezing, and KYC administration at once.

The auditor roles have two problems of their own. First, they gate the read-only getters for the compliance lists, apparently to control who can read them, but on a public chain that is pointless: the underlying storage is readable by anyone (reading storage slots is how we mapped this system), so the lists are public either way, and the restriction shows the design did not account for being on a public chain. Second, concentration: all four auditor roles sit with the same 23 accounts, and the holders of execution roles A, C, D, and F are among them, so the same keys that mint, burn, freeze, and blacklist also sit in the group meant to review those actions.

Revocation is not immediate. In the ceremony engine, a signer's role is checked once and the quota is decremented; earlier signers are never re-validated, so revoking a role afterward does not retract a vote already counted:

// 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        }

Finally, the engine's own audit trail is unreliable. On every approval, the evtApprove event emits address(0) as the signer rather than the real approver; the actual signer survives only in the transaction sender and an internal record, so event logs cannot attribute who approved a request. And the active-request list uses nonce 0 as both a real request id and its empty marker, so monitoring or approval tooling that walks the list backward misses the request at nonce 0. Neither is critical, but for a regulated system that needs a clean audit trail, both subtract from it.

1.3 Transfer controls are inconsistent between transfer and transferFrom

Part of the difference between the two is expected. In transferFrom the initiator, msg.sender, is an approved spender rather than the source of the funds, so the code checks the real source from explicitly in every mode; transfer does not need to, because there msg.sender is the source. That adaptation is reasonable. Two other differences are not explained by the initiator, and they leave the same economic action governed by different rules.

First, the deposit and redemption whitelists are consulted only in the transferFrom path, where membership triggers an early return that bypasses the isActive (KYC) check. transfer never consults them. Whether a recipient is whitelisted has nothing to do with who initiated the transfer, so the same recipient is subject to KYC through transfer but can skip it through 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" ) ) ;

Second, checkingMode means different things across the two functions. In transfer, "Source" mode checks the sender; in transferFrom, "Source" mode checks the spender (msg.sender), while from is checked in every mode. So a single configuration setting enforces two different policies depending on the entry point.

The effect is that the transfer controls of a regulated token depend on which function is used, which makes them hard to reason about and, depending on configuration, avoidable.

1.4 Signs of a pre-production build

Beyond the specific logic bugs, several properties of the codebase indicate that a pre-production build was deployed to mainnet.

Debug logging in production. hardhat/console.log calls remain throughout, including inside the proxy's fallback, which runs on every user transaction. Because the proxy is not itself upgradeable, this overhead is permanent:

// 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    }

A proxy header comment that describes the opposite of the code. The same file carries OpenZeppelin's TransparentUpgradeableProxy documentation, which states the admin can never fall through to the implementation. This contract deliberately does the reverse: the guard on line 117 is commented out, and the admin does fall through. A reviewer trusting the comment would mis-model the trust boundary.

Role names that do not match their hashes. The "elevated risk" role is declared with the same constant name but a different keccak string in the token and in the modules, which produces two different roles:

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

The deployment avoids trouble only because each module holds its own copy; a second role name (AFL_TOKEN_HOLDER_AUDITOR_ROLE) is transposed the same way.

Other signs. The proxy's verification bundle uploaded 117 files, including the project's test suite, to the public explorer, which hands a reader the internal tests and edge cases. The most recent upgrade changed only compiler-warning cleanup, with no third-party audit in the loop. And the optimizer is set to zero runs, which makes the hot paths of a heavily used token more expensive rather than less.

None of these is individually severe. Together, they indicate the code did not go through the release discipline expected of a contract that holds value on mainnet.

1.5 The contract's one upgrade, traced

The proxy has been upgraded once. It was deployed on April 28, 2026 with implementation 0x8ff12fe3bed22d9e40afb4b98ef4dee28d94699e, and on July 10, 2026 it was upgraded to the current 0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc. Because an upgrade is an on-chain governance action, we can see exactly who approved it.

upgradeTo requires role A plus role B. The upgrade was two transactions in adjacent blocks, about twelve seconds apart:

  • the request, role A, from 0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2, in block 25500519 (tx 0x742372…85136);
  • the approval, role B, from 0x3795300b31429f9d37b0dc805528d9390ce87c50, in block 25500520 (tx 0xa7a400…630c), which reached quorum and performed the upgrade in the same transaction.

Two things stand out. First, the entire two-signature ceremony completed within a single block interval. From request to execution was one block; there was no window in which the second signer could review independently before the change went live.

Second, neither signer is identifiable from the current role registry. The roles have since been rotated: the requester 0xa9315a… no longer holds role A (it holds role E today), and the second signer 0x3795300b… holds no role at all now. Reading the registry as it stands would not tell you who authorized the upgrade; only the transaction history does. This is the "revocation is not immediate" property seen from the other side: roles move, so a snapshot of who holds what is not a record of who did what.

And the upgrade did not fix any of the defects in this review. The implementation it installed, 0xe42d38b0…, is the one all of Part 1 describes. We cannot tell from the chain whether a third-party audit was performed before the upgrade; what we can say is that if one was, the defects in Part 1 survived it.

Part 2: Does it match Hong Kong's stablecoin framework?

Hong Kong's Stablecoins Ordinance took effect on August 1, 2025, and licensed issuers are supervised under the HKMA's Guideline on Supervision of Licensed Stablecoin Issuers. We compared only the clauses a smart contract can satisfy on its own; reserve backing, custody, and off-chain key ceremonies are out of scope for an on-chain review. For each clause below we state what the guideline requires, what the contract does, and where the two diverge. The comparison is summarized here and detailed in the sections that follow.

HKMA clause What it requires Where the contract diverges Verdict
6.5.3 High-risk operations must not be unilateral (multi-signature, and mitigants such as velocity limits or timelocks) mint, burn, pause, and freeze each execute with one key; no timelock (execution is atomic with the final signature) Diverges
6.5.4 Segregate duties across authorised persons; revoke authority immediately one account holds six roles, so execution and audit overlap; a counted approval survives a later revocation Diverges
6.5.5 Third-party audit for every code change; correct, consistent, free of vulnerabilities the Part 1 defects are live in the deployed implementation; isActive and provider revocation do not do what they are named to do Diverges
Effectiveness of compliance controls blacklist, freeze, whitelist, and KYC controls must be effective KYC gating and provider revocation are non-functional in the deployed code Diverges
2.2.3 Frozen or destroyed coins remain fully backed and reconcilable destroy emits Transfer to address(this) rather than address(0) and leaves the net-issuance counters untouched; supply reconstructed from events drifts Concern

Paragraph 6.5.3: high-risk operations must not be unilateral

What it requires. High-risk operations should be designed so that no single party can perform them unilaterally, for example through a multi-signature protocol, and the guideline lists further mitigants such as velocity limits and time-delayed (timelock) controls.

What the contract does. Read from the governance contract's authorizationMatrix (the full matrix is in the table in 1.2), the supply and emergency operations each require a single role, at a quota of one:

Operation Required signature
mintToDeposit ROLE_C x1
burnFrom ROLE_C x1
freeze ROLE_C x1
pause ROLE_D x1
destroyBlackFunds ROLE_D x1

Where it diverges. Minting, burning, pausing, and freezing can each be executed by one key. This does not meet the "no single party unilaterally" requirement. Nor is there a timelock: as shown in 1.2, once quorum is reached the action executes in the same transaction, so a time delay, one of the mitigants the guideline names, is also absent. The contract does implement a supply velocity limit (whenWithinRiskThresholds), so that mitigant is present, but it does not substitute for multi-signature control on the operations themselves.

Paragraph 6.5.4: segregation of duties and immediate revocation

What it requires. Different operations should be segregated across different authorised persons, and an authorised person's authority should be revocable immediately.

What the contract does. One externally owned account holds six roles (issuance, freezing, KYC administration, and all four auditor roles), so the execution and audit roles overlap. Role changes themselves also require only role A plus role B (see 1.2), so the same small group decides who is authorized and can execute and upgrade. And a signature already counted is not re-validated if the signer's role is later revoked.

Where it diverges. Duties are concentrated rather than segregated, and revocation is not immediate: a revoked signer's earlier approval still counts toward a later execution.

Paragraph 6.5.5: audit every code change; correct, consistent, no vulnerabilities

What it requires. A qualified third party should audit the smart contracts for every code change, and confirm they are (i) implemented correctly, (ii) consistent with the intended functionality, and (iii) free of vulnerabilities to a high level of confidence.

What the contract does. The current implementation is the one installed by the July 10 upgrade (traced in 1.5), and the defects in Part 1 are live in it.

Where it diverges. We cannot see off-chain whether an audit was performed, but the outcome does not meet the standard either way. Because isActive and provider revocation do not do what they are named to do, conditions (i) and (ii) are not met; and with the defects in Part 1 present in the deployed code, (iii) is not met either.

Effectiveness of the compliance controls

What it requires. The guideline's lifecycle model (blacklist, freeze, whitelist, KYC) presumes those controls are effective.

What the contract does. As shown in 1.1, KYC gating and provider revocation are not functional in the deployed code.

Where it diverges. A control that is required to work does not work. This is a substantive gap, not a formality.

Paragraph 2.2.3: frozen or destroyed coins remain fully backed and reconcilable

What it requires. Stablecoins that are frozen or destroyed by enforcement action should remain fully backed, so that supply and reserves can be reconciled.

What the contract does. The usual remediation flow for a regulated stablecoin is to burn the funds at a bad address and later re-issue an equal amount to the victim as a separate mint (this is how USDT's destroyBlackFunds plus issue works). HKDAP's destroy reduces _totalSupply, which is a burn, but it never credits balances[address(this)], it emits a Transfer to address(this) instead of address(0), and it leaves the net-issuance counters that the mint limit uses untouched:

// 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    }

Where it diverges. The state change is a burn, but the event says the tokens moved to the contract, and nothing is ever held there (balances[address(this)] stays zero). An indexer would credit address(this) with tokens it does not hold, and the total supply reconstructed from events would not match the chain. It also confuses the remediation itself: because the tokens are burned rather than parked, a re-issue to the victim must be a fresh mint, yet the misleading Transfer(..., address(this), ...) suggests the contract now custodies them and could forward them, which it cannot. A clean burn to address(0) plus a separate re-issue would be both correct and reconcilable. As written, the on-chain accounting that a reserve reconciliation depends on drifts from the chain's true state. This is a concern rather than a clean pass.

We limit these findings to what the chain shows. Whether reserves are fully backed, whether keys sit in an HSM or an air-gapped environment, and whether transactions are simulated off-chain before signing are not visible from the contract, and we make no claim about them.

Conclusion

The picture is consistent across both axes of the review. As software, HKDAP contains functional defects, including compliance controls that do not execute as written, and it shows several signs of a pre-production build. As a regulated stablecoin, several of its on-chain properties conflict with specific clauses of the HKMA guideline. On the on-chain evidence, and setting aside the off-chain matters we cannot see, the deployed contract does not yet meet the standard a commercial stablecoin should meet.

Two observations follow, and they are worth stating plainly.

First, issuing on a public chain changes where compliance is decided. A requirement such as "no single party should be able to act unilaterally" is satisfied or not by the role checks in the deployed code, and that code is public. The implementation, rather than the licence or the documentation, is where such a requirement is actually met or missed, and anyone can verify which.

Second, the "Beta Access" label does not change the risk profile of the deployed code. The contract is live on Ethereum mainnet, administered by real keys, and represents a claim on Hong Kong dollars. It should therefore be held to production standards regardless of how it is labeled.

A common thread runs under the specific findings. The architecture reinvents, in bespoke form, primitives the ecosystem already provides and has audited at scale: a from-scratch approval engine and role layer where a Safe multisig with OpenZeppelin's AccessManager and TimelockController would do; a hand-written linked-list collection in place of EnumerableSet; a modified proxy in place of the standard TransparentUpgradeableProxy; and a hand-written ERC-20 in place of OpenZeppelin's. Most of the defects in this review live in that custom machinery, not in the parts that reuse standard components. It reads like a system abstracted the way general-purpose software is, rather than composed from the small, audited building blocks that on-chain development favors, where every layer of custom abstraction is also gas, attack surface, and upgrade risk. A design built from those standard components would be smaller, safer, and easier to review, and it would come with the things this system currently lacks: a deliberation window from a timelock, and named, legible roles.

The issues described here are addressable. Restoring multi-signature on high-risk operations, separating execution from audit, fixing the KYC revocation logic, unifying the transfer checks, removing the debug code, and requiring a third-party audit before each upgrade would resolve most of them. Deploying on mainnet with verified source is also what made this review possible, and it is the right default. Continuous on-chain security and compliance review of this kind is what we do at BlockSec, and we are glad to help.

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