Обзор безопасности и соответствия требованиям HKDAP (Anchorpoint) от BlockSec, по состоянию на 13 августа 2026 года.
Краткое содержание. Мы изучили развернутый контракт HKDAP — первого регулируемого стейблкоина, выпущенного в Гонконге, работающего в основной сети Ethereum, и он не готов к промышленной эксплуатации. Его механизмы KYC и отзыва прав не работают так, как задумано, его управление сконцентрировано настолько, что один ключ может выпускать, сжигать или замораживать токены, а несколько его on-chain свойств противоречат собственному руководству HKMA. Есть общая нить: контракт заново изобретает с нуля примитивы, которые экосистема уже предоставляет и проверяет в промышленных масштабах (мультисиг, слой контроля доступа, таймлок, сам стандарт ERC-20), и большинство дефектов находится именно в этой самодельной механике, а не в частях, использующих стандартные компоненты. Метка «Beta Access» этот разрыв не закрывает.
12 августа 2026 года Anchorpoint запустила первую фазу HKDAP — стейблкоин, привязанный к гонконгскому доллару. Это значимый запуск. Anchorpoint, совместное предприятие под руководством Standard Chartered Bank (Hong Kong) с участием HKT и Animoca Brands, владеет одной из всего двух лицензий эмитента стейблкоинов, выданных HKMA, из 36 заявителей, и HKDAP — один из первых стейблкоинов, выпущенных в рамках гонконгского Закона о стейблкоинах.
В отличие от большинства продуктов регулируемого банка, HKDAP можно напрямую проверить. Он работает в основной сети Ethereum, а исходный код его контракта верифицирован на Etherscan. Это полезное свойство: для стейблкоина, выпущенного таким образом, правила, регулирующие выпуск, перевод и заморозку, не описаны в документе — они реализованы в коде, который любой может прочитать и который выполняется именно так, как написан. Лицензия — это заявление; развернутый контракт — реализация этого заявления, и она публична.
Это делает возможным конкретный обзор, и именно это мы и сделали. Мы изучили развернутый контракт по двум осям. Во-первых, как программное обеспечение: является ли он корректным и соответствующим промышленным стандартам? Во-вторых, как регулируемый стейблкоин: соответствует ли его on-chain поведение Руководству HKMA по надзору за лицензированными эмитентами стейблкоинов?
Выводы согласуются по обеим осям. Контракт содержит множество функциональных дефектов, включая механизмы соответствия требованиям, которые не работают так, как написаны. Его управление сильно централизовано, при этом несколько операций высокого риска может выполнить одним ключом. И ряд его on-chain свойств противоречит конкретным пунктам руководства HKMA. Наша оценка такова: даже как бета-версия, контракт не соответствует уровню качества, который требуется для коммерческого стейблкоина.
Остальная часть этой статьи представляет собой этот обзор, актуальный на 13 августа 2026 года. Наш обзор основан на публично развернутом коде и наблюдаемых on-chain фактах. Мы не делаем никаких утверждений о вопросах, которые блокчейн не может подтвердить, таких как обеспечение резервами или хранение ключей вне цепочки.
Как мы нашли контракт
Мы начали с эмитента, а не со списка токенов. Присутствие компании Anchorpoint ссылается на её сайт anchorpoint.hk. Страница Beta Access указывает развертывание: основная сеть Ethereum, прокси 0x87622385F960fcCB3121d6D0A9513bd1D9Bed6cA, исходный код которого верифицирован на Etherscan.
Отсюда мы составили карту всей системы on-chain: прокси токена и его реализацию (ControllableAHKD, 0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc), контракт управления, который его администрирует (0x47dd47a776902bee03abdd5aeff43f41b0ffbc2b), реестр ролей верхнего уровня (0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c), а также пять модулей соответствия требованиям, каждый со своим собственным контрактом управления. Каждая связь ниже была проверена путем чтения слотов хранилища и вызова view-функций в основной сети, а не выведена только из исходного кода.
Как выглядит контракт
Три слоя HKDAP: панель управления администрирует токен, и токен запрашивает пять модулей соответствия требованиям при каждом переводе.

Токен HKDAP не использует стандартную эталонную реализацию ERC-20 от OpenZeppelin; его логика ERC-20 написана с нуля (именно отсюда происходят несколько описанных ниже ошибок). Система состоит из трёх слоёв:
- Токен.
ControllableAHKD, за обновляемым прокси. Контролируемый ERC-20 с функциями выпуска, сжигания, паузы и принудительного уничтожения, плюс проверки соответствия требованиям, встроенные в каждый перевод. - Самодельный движок управления M-из-N. Каждое привилегированное действие (обновление, выпуск, сжигание, пауза, чёрный список, заморозка, изменение модуля соответствия требованиям) проходит через церемонию запрос-подтверждение-выполнение, а не через простой мультисиг.
- Пять модулей соответствия требованиям. Чёрный список, заморозка, сервис активации KYC, а также белые списки депозитов и погашений. Каждый модуль сам является прокси, управляемым собственным контрактом контролирующего органа.
Структурно этот же блок «контролирующий орган плюс прокси плюс реализация» повторяется шесть раз (токен плюс пять модулей), и все шесть контролирующих органов разрешают свои роли через единый реестр. Весь контроль в системе в итоге сходится к этому единственному реестру, а то, кто может перезаписать роли внутри него, сводится к очень небольшому набору ключей подписантов, как мы подробно показываем ниже.
Часть 1: Безопасность и ошибки
Ниже приведены выводы из этой части; каждая строка подробно рассмотрена в указанном разделе.
| Область | Вывод | Где | Эффект |
|---|---|---|---|
| 1.1 Механизмы KYC и отзыва не работают так, как написаны | Отзыв KYC — мёртвый код | TokenHolderActivationServerLibrary.sol:221-235 |
isActive игнорирует отзыв регистрации провайдера (цикл никогда не выполняется, и используется ==, а не =); отказ в открытом режиме (fail-open). |
| Отмена регистрации верификатора никогда не устанавливает статус INACTIVE | TokenHolderActivationServer.sol:504-511 |
«Дерегистрированный» верификатор всё ещё может подключать и деактивировать кошельки; повторный вызов приводит к откату. | |
| Доказательство KYC никогда не проверяется on-chain | TokenHolderActivationServer.sol:575-578 |
Доказательство передаётся, но игнорируется; проходит любое доказательство, включая пустую строку. | |
| Исключение по лимиту бесплатных переводов можно обойти дроблением | ControllableAHKD.sol:337-535 |
freeTransferLimit проверяется для каждого вызова отдельно, а не накопительно; если использовать его как лимит на активность без KYC, его можно обойти, разбив на переводы ниже лимита. |
|
| 1.2 Управление чрезмерно централизовано | Операции высокого риска — однофакторная подпись | authorizationMatrix; транзакция выпуска 0xa7e53c…b33d7 |
mint / burn / freeze / деактивация KYC (роль C), pause / destroy (роль D), чёрный список / разморозка (роль F) выполняются одним ключом каждая. |
| Одна пара ключей (A + B) обновляет всё | authorizationMatrix |
upgradeTo для токена и всех пяти модулей — это роль A + B; два человека могут заменить любую реализацию. |
|
| Та же пара переписывает каждую роль | реестр 0xa728… authorizationMatrix |
grantRole / revokeRole в реестре также требуют роли 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 Несогласованные проверки перевода | transfer и transferFrom применяют разные правила |
ControllableAHKD.sol:313-502 |
Раннее завершение из-за белого списка обходит KYC только через transferFrom; checkingMode проверяет разные стороны; один и тот же перевод регулируется по-разному. |
| 1.4 Признаки предпроизводственной сборки | debug-логи, неверный комментарий, несовпадение имени/хэша, загружены тесты | UpgradeableProxy.sol:115-119; ControllableAHKD.sol:25 |
console.log в fallback-функции прокси (постоянный расход газа); комментарий прокси противоречит коду; несовпадение имени и хэша роли; загружено 117 файлов, включая тесты; optimizer runs = 0. |
| 1.5 Единственное обновление оставило дефекты на месте | трассировка единственного обновления | tx 0x742372…85136, 0xa7a400…630c |
Развёртывание 28 апр. → обновление 10 июл.; две транзакции церемонии A + B были в одном блоке друг от друга (~12 сек); каждый дефект из Части 1 присутствует в установленной реализации. |
1.1 Механизмы KYC и отзыва не работают так, как написаны
Согласно регулированию, пользователи стороны B (институциональные), которых обслуживает HKDAP, обязаны проходить KYC. В контракте это означает, что он должен делать как минимум две вещи: применять KYC при переводах, чтобы кошелёк, не прошедший KYC, блокировался, и отзывать доступ при удалении провайдера идентификации или держателя. В HKDAP этот путь нарушен в трёх независимых местах.
Отзыв KYC — мёртвый код. Токен ограничивает переводы, вызывая isActive(address) в модуле KYC. 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.entryCount, эквивалентно 0 > N, поэтому тело никогда не выполняется; и даже если бы выполнялось, строка 231 использует ==, сравнение, результат которого отбрасывается, тогда как должно быть присвоение с =. Поэтому isActive возвращает только сравнение счётчиков и полностью игнорирует отзыв провайдера. Это отказ в открытом режиме (fail-open): дерегистрация скомпрометированного KYC-провайдера не останавливает транзакции кошельков, которые он подключил. И поскольку unregisterVerifier не трогает эти счётчики, кошельки отозванного провайдера сохраняют положительный счётчик и остаются активными.
Отзыв провайдера нарушен и с другой стороны. unregisterVerifier только перемещает узел связанного списка; он никогда не устанавливает статус провайдера в каталоге на INACTIVE:
// contracts/hce/erc20/modules/TokenHolderActivationServer.sol
504 function _unregisterVerifier(string calldata _verifierId) internal {
505 _removeToList(_verifierId, ACTIVE_IDENTIY_PROVIDER, "60a");
507 uint256 _ipIdx = identityProviders.length - 1;
508 uint256 _newIdx = identityProvidersByStatuss.length + 1;
510 _addToList(_ipIdx, _newIdx, INACTIVE_IDENTIY_PROVIDER, _verifierId, "60b");
511 }
Поскольку статус остаётся ACTIVE, «дерегистрированный» верификатор всё ещё может регистрировать и деактивировать держателей, ветка, предназначенная для повторной активации провайдера, недостижима, а повторный вызов unregisterVerifier вызывает underflow и откат.
Доказательство KYC никогда не проверяется on-chain. Когда авторизованный верификатор регистрирует или продлевает кошелёк через registerOrRenew (доступ к которому имеет только текущий активный верификатор), поток достигает _checkKYCProof, которая должна проверять представленное доказательство по схеме провайдера. Согласно интерфейсу, это доказательство — «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 этот аргумент попадает в параметр без имени вовсе, который также отсутствует в комментарии к документации над функцией, и тело функции его никогда не читает. Функция только подтверждает, что провайдер активен, и возвращает true в строке 577, поэтому доказательство отбрасывается, а не проверяется. Это не обход извне, поскольку достичь этого может только активный верификатор, но on-chain контракт не выполняет никакой проверки доказательства KYC, поэтому его целостность полностью зависит от внецепочечного верификатора. Скомпрометированный или небрежный верификатор может активировать любой кошелёк с любым доказательством, включая пустую строку.
Вместе эти три момента означают, что ключевое свойство соответствия требованиям — способность ограничивать и отзывать доступ — не работает так, как написано.
Помимо этих трёх, есть смежная слабость: исключение по лимиту бесплатных переводов можно обойти дроблением. У токена есть freeTransferLimit, и перевод ниже него пропускает проверку isActive (KYC). Но лимит сравнивается только с суммой текущего перевода; контракт не хранит накопительную сумму по адресу или периоду. Так что если лимит используется как ограничение на активность без KYC, держатель может переместить произвольную общую сумму, разбив её на повторяющиеся переводы, каждый чуть ниже лимита, что делает ограничение неэффективным.
1.2 Управление чрезмерно централизовано, и операции высокого риска — однофакторная подпись
Мы перечислили каждую роль и каждого держателя роли on-chain. Прежде чем перейти к деталям, стоит отметить две вещи.
Во-первых, роли, авторизующие церемонии M-из-N, не имеют читаемых имён. В развёрнутой конфигурации они появляются только как 32-байтные хэши, и ни один из них не совпадает с именованной константой роли в верифицированном исходном коде (именованные роли, такие как SUPPLY_CONTROLLER_ROLE, принадлежат самим контрактам, а не подписантам). Мы обозначаем шесть ролей подписантов буквами от A до F. То, что самые мощные роли в системе непрозрачны, само по себе является слабостью: это делает управление сложнее для проверки, чем если бы роли были именованными.
Во-вторых, число держателей мало. Таблица ниже считана из реестра ролей on-chain; адреса сокращены.
| Роль (наше обозначение) | Хэш on-chain | Держатель(и) | Что авторизует |
|---|---|---|---|
| A | 0x37f0f656… |
0x747356d525bf47f495af7c6e42f3ab9b8ba87a4b |
первая подпись для upgradeTo / changeAdmin / setControlAuthority |
| B | 0x95c27f81… |
0x8117a657df7a1c399231bfe26a096eb8f8ad5973, 0x5d9d9b28e83382e694916b0feeaf7a9badbb659c, 0xebb73f78e253539acceb4e6cc287095788eee5d4 |
вторая подпись почти для каждого действия с двумя подписями |
| C | 0xfa2fe896… |
0x2f7f00cc5334fe2861e485ff610f74890a0316ed |
mintToDeposit, burnFrom, freeze, деактивация KYC (одна подпись) |
| D | 0x3dce3265… |
0x5092af62a1625fa57404557d3ad417474f3f494c |
pause, destroyBlackFunds, регистрация/дерегистрация адресов депозита и погашения, registerVerifier (одна подпись) |
| E | 0xfd21a76d… |
0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2 |
изменение модулей соответствия требованиям (setBlacklistServer и т.п.) |
| F | 0x510ac1ff… |
0x7daabe5092d3feea5cc19ddbb5380a4eac38f89c |
addBlackList, unfreeze (одна подпись) |
Из таблицы следует несколько моментов.
Однофакторные операции высокого риска. Большинство операций высокого риска требуют одной роли с квотой в единицу. Таблица ниже считана on-chain из authorizationMatrix контракта управления токена и пяти контрактов управления модулями (одна подпись, если не указана вторая подпись + B):
| Операция | Управляется | Требуемые подписи |
|---|---|---|
mintToDeposit, burnFrom |
токен | C x1 |
freeze, batchFreeze |
модуль заморозки | C x1 |
deactivate, adminDeactivate (KYC) |
модуль KYC | C x1 |
pause, destroyBlackFunds |
токен | D x1 |
register, unregister (депозит / погашение) |
модули каталогов | D x1 |
registerVerifier, unregisterVerifier |
модуль KYC | D x1 |
addBlackList, batchBlackList |
модуль чёрного списка | F x1 |
unfreeze |
модуль заморозки | F x1 |
removeBlackList |
модуль чёрного списка | F x1 + B x1 |
setBlacklistServer, setFreezingServer, setCheckingMode |
токен | E x1 + B x1 |
| настройки сервера каталога и лимита выпуска | токен | D x1 + B x1 |
upgradeTo / changeAdmin / setControlAuthority (токен и каждый модуль), unpause |
токен + модули | A x1 + B x1 |
Выпуск, заморозка и деактивация KYC — однофакторные (все в рамках роли C); пауза, уничтожение, изменения каталога и регистрация верификатора — однофакторные под ролью D; чёрный список и разморозка — однофакторные под ролью F. Только обновления и изменения конфигурации требуют второй подписи. Обратите внимание на асимметрию: freeze и addBlackList требуют одну подпись, тогда как removeBlackList требует две — то есть ограничить учётную запись легче, чем освободить её.
Это не просто прочтение матрицы; это наблюдаемо в реальном выпуске. Самый последний выпуск на момент написания, tx 0xa7e53c…b33d7, — единая транзакция, отправленная единственной учётной записью с ролью C (0x2f7f00cc5334fe2861e485ff610f74890a0316ed) в контракт управления токена. Она вызывает request, и та же самая транзакция достигает кворума и испускает событие выпуска Transfer от address(0), без отдельной транзакции подтверждения и без второго подписанта. Поскольку выпуск завершается внутри собственной транзакции запросившего, один ключ и запросил, и выполнил выпуск.
Также в движке вовсе нет таймлока. В момент, когда поступает последняя требуемая подпись, действие выполняется в той же транзакции, без задержки, во время которой его можно было бы рассмотреть, отменить или оспорить; движок записывает метку времени executedAt, но никогда её не проверяет. Так что даже операции с двумя подписями завершаются немедленно, как только подписан второй ключ.
Одна пара ключей обновляет всё. Обновление токена и обновление всех пяти модулей соответствия требованиям используют одно и то же требование — A плюс B. Поскольку A — это одна учётная запись, а B — три учётные записи, разделяющие одну роль, два человека могут заменить любую реализацию в системе.
Та же пара также контролирует таблицу ролей. Роли можно выдавать и отзывать только через реестр (HybridControlledAuthority, 0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c), через его собственную церемонию; никакой внешний ключ не может изменить их напрямую. А его authorizationMatrix, считанная on-chain, требует те же подписи для изменения роли, что и для обновления: роль A плюс роль B. Так что два человека, которые могут заменить любую реализацию, также могут добавить ключ в роль C, удалить существующего держателя и переписать всю таблицу ролей. Этот двухфакторный барьер лучше, чем однофакторные операции выше, но всё же это низкая планка для корня системы, поскольку роль A — это одна учётная запись без резервирования.
Один адрес — шесть ролей. Держатель роли C (0x2f7f00cc5334fe2861e485ff610f74890a0316ed) также держит именованную роль ADMIN_TOKEN_HOLDER_ROLE (управление верификаторами KYC) и является членом всех четырёх ролей аудитора (BLACKLIST_AUDITOR_ROLE, FREEZING_AUDITOR_ROLE, WHITELIST_AUDITOR_ROLE и AFL_TOKEN_AUDITOR_HOLDER_ROLE). Компрометация этого одного ключа означает одновременную потерю управления выпуском, заморозкой и администрированием KYC.
Роли аудитора имеют две собственные проблемы. Во-первых, они ограничивают доступ к доступным только для чтения функциям-геттерам списков соответствия требованиям, видимо, чтобы контролировать, кто может их читать, но в публичной цепочке это бессмысленно: базовое хранилище доступно для чтения кому угодно (чтение слотов хранилища — именно так мы составили карту этой системы), поэтому списки в любом случае публичны, и это ограничение показывает, что дизайн не учитывал работу в публичной цепочке. Во-вторых, концентрация: все четыре роли аудитора занимают те же 23 учётные записи, а держатели ролей исполнения A, C, D и F входят в их число, поэтому те же ключи, которые выпускают, сжигают, замораживают и вносят в чёрный список, также входят в группу, предназначенную для проверки этих действий.
Отзыв не немедленный. В движке церемоний роль подписанта проверяется один раз, а квота уменьшается; более ранние подписанты никогда не проверяются повторно, поэтому отзыв роли впоследствии не отменяет уже учтённый голос:
// 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 Проверки перевода несогласованы между transfer и transferFrom
Часть разницы между ними ожидаема. В transferFrom инициатор, msg.sender, — это одобренный расходователь, а не источник средств, поэтому код явно проверяет реальный источник from в каждом режиме; transfer этого не требует, так как там msg.sender — это сам источник. Такая адаптация разумна. Но два других отличия не объясняются инициатором и оставляют одно и то же экономическое действие под разными правилами.
Во-первых, белые списки депозитов и погашений консультируются только в пути transferFrom, где принадлежность к списку вызывает раннее завершение, которое обходит проверку isActive (KYC). transfer их вообще не консультирует. Является ли получатель включённым в белый список, не имеет ничего общего с тем, кто инициировал перевод, поэтому один и тот же получатель подлежит KYC через transfer, но может пропустить его через 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 Признаки предпроизводственной сборки
Помимо конкретных логических ошибок, ряд свойств кодовой базы указывает на то, что в основной сети была развёрнута предпроизводственная сборка.
Debug-логирование в продакшене. Вызовы hardhat/console.log остаются повсюду, в том числе внутри 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 закомментирована, и администратор действительно проваливается в реализацию. Рецензент, доверяющий комментарию, неверно смоделировал бы границу доверия.
Имена ролей, не совпадающие с их хэшами. Роль «повышенного риска» декларируется с одинаковым именем константы, но разной строкой 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 файлов, включая тестовый набор проекта, в публичный обозреватель, что даёт читателю внутренние тесты и граничные случаи. Последнее обновление изменило только очистку предупреждений компилятора, без участия сторонней аудиторской проверки. А оптимизатор установлен на ноль запусков, что делает часто используемые пути токена дороже, а не дешевле.
Ни один из этих пунктов сам по себе не критичен. Вместе они указывают на то, что код не прошёл дисциплину релиза, которая ожидается от контракта, хранящего ценность в основной сети.
1.5 Единственное обновление контракта, прослеженное
Прокси был обновлён один раз. Он был развёрнут 28 апреля 2026 года с реализацией 0x8ff12fe3bed22d9e40afb4b98ef4dee28d94699e, а 10 июля 2026 года обновлён до текущей 0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc. Поскольку обновление — это действие on-chain управления, мы можем точно увидеть, кто его подтвердил.
upgradeTo требует роли A плюс роли B. Обновление было двумя транзакциями в соседних блоках, с разницей около двенадцати секунд:
- запрос, роль A, от
0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2, в блоке 25500519 (tx0x742372…85136); - подтверждение, роль B, от
0x3795300b31429f9d37b0dc805528d9390ce87c50, в блоке 25500520 (tx0xa7a400…630c), которое достигло кворума и выполнило обновление в той же транзакции.
Выделяются два момента. Во-первых, вся церемония с двумя подписями завершилась в пределах одного блочного интервала. От запроса до выполнения был один блок; не было окна, в котором второй подписант мог бы независимо рассмотреть изменение до его вступления в силу.
Во-вторых, ни одного из подписантов не удаётся идентифицировать по текущему реестру ролей. Роли с тех пор были ротированы: запросивший 0xa9315a… больше не держит роль A (теперь он держит роль E), а второй подписант 0x3795300b… сейчас не держит никакой роли. Чтение реестра в его текущем виде не сказало бы вам, кто авторизовал обновление; только история транзакций это показывает. Это то же свойство «отзыв не немедленный», но с другой стороны: роли перемещаются, поэтому снимок того, кто что держит, не является записью того, кто что сделал.
И обновление не исправило ни одного дефекта из этого обзора. Установленная им реализация, 0xe42d38b0…, — это та самая, которую описывает вся Часть 1. Мы не можем узнать из блокчейна, была ли проведена сторонняя аудиторская проверка перед обновлением; мы можем сказать только то, что если она была, дефекты из Части 1 её пережили.
Часть 2: Соответствует ли он гонконгской нормативной базе по стейблкоинам?
Гонконгский Закон о стейблкоинах вступил в силу 1 августа 2025 года, и лицензированные эмитенты контролируются в рамках Руководства HKMA по надзору за лицензированными эмитентами стейблкоинов. Мы сравнили только те пункты, которые смарт-контракт может выполнить самостоятельно; обеспечение резервами, хранение и внецепочечные церемонии ключей выходят за рамки on-chain обзора. Для каждого пункта ниже мы указываем, что требует руководство, что делает контракт и где они расходятся. Сравнение суммировано здесь и подробно рассмотрено в следующих разделах.
| Пункт HKMA | Что требуется | Где контракт расходится | Вердикт |
|---|---|---|---|
| 6.5.3 | Операции высокого риска не должны быть односторонними (мультиподпись, а также смягчающие меры, такие как лимиты скорости или таймлоки) | mint, burn, pause и freeze каждая выполняется одним ключом; нет таймлока (выполнение атомарно с финальной подписью) | Расходится |
| 6.5.4 | Разделение обязанностей между авторизованными лицами; немедленный отзыв полномочий | одна учётная запись держит шесть ролей, поэтому исполнение и аудит перекрываются; учтённое подтверждение переживает последующий отзыв | Расходится |
| 6.5.5 | Сторонняя аудиторская проверка каждого изменения кода; корректность, согласованность, отсутствие уязвимостей | дефекты из Части 1 действуют в развёрнутой реализации; isActive и отзыв провайдера не делают того, что должны согласно их названиям |
Расходится |
| Эффективность механизмов соответствия требованиям | механизмы чёрного списка, заморозки, белого списка и KYC должны быть эффективными | ограничение KYC и отзыв провайдера неработоспособны в развёрнутом коде | Расходится |
| 2.2.3 | Замороженные или уничтоженные монеты остаются полностью обеспеченными и сверяемыми | уничтожение испускает Transfer в address(this), а не в address(0), и не изменяет счётчики чистого выпуска; предложение, восстановленное из событий, дрейфует |
Опасение |
Пункт 6.5.3: операции высокого риска не должны быть односторонними
Что требуется. Операции высокого риска должны быть спроектированы так, чтобы ни одна сторона не могла выполнить их в одностороннем порядке, например, через протокол мультиподписи, и руководство перечисляет дополнительные смягчающие меры, такие как лимиты скорости и элементы контроля с временной задержкой (таймлоки).
Что делает контракт. Согласно 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, по достижении кворума действие выполняется в той же транзакции, поэтому временная задержка — одна из смягчающих мер, названных в руководстве, — тоже отсутствует. Контракт реализует лимит скорости выпуска (whenWithinRiskThresholds), поэтому эта смягчающая мера присутствует, но она не заменяет мультиподписной контроль самих операций.
Пункт 6.5.4: разделение обязанностей и немедленный отзыв
Что требуется. Разные операции должны быть разделены между разными авторизованными лицами, и полномочия авторизованного лица должны быть немедленно отзываемыми.
Что делает контракт. Одна учётная запись с внешним владением держит шесть ролей (выпуск, заморозка, администрирование KYC и все четыре роли аудитора), поэтому роли исполнения и аудита перекрываются. Изменения ролей сами также требуют только роли A плюс роли B (см. раздел 1.2), поэтому та же небольшая группа решает, кто авторизован, и может исполнять и обновлять. И уже учтённая подпись не проверяется повторно, если роль подписанта позже отзывается.
Где расходится. Обязанности сконцентрированы, а не разделены, и отзыв не немедленный: ранее данное подтверждение отозванного подписанта всё равно засчитывается для последующего выполнения.
Пункт 6.5.5: аудит каждого изменения кода; корректность, согласованность, отсутствие уязвимостей
Что требуется. Квалифицированная третья сторона должна проводить аудит смарт-контрактов при каждом изменении кода и подтверждать, что они (i) реализованы корректно, (ii) согласуются с предполагаемым функционалом и (iii) свободны от уязвимостей с высокой степенью достоверности.
Что делает контракт. Текущая реализация — та, которая была установлена обновлением 10 июля (прослеженным в разделе 1.5), и дефекты из Части 1 действуют в ней.
Где расходится. Мы не можем увидеть вне цепочки, была ли проведена аудиторская проверка, но результат не соответствует стандарту в любом случае. Поскольку isActive и отзыв провайдера не делают того, что должны согласно их названиям, условия (i) и (ii) не выполнены; а при наличии дефектов из Части 1 в развёрнутом коде условие (iii) также не выполнено.
Эффективность механизмов соответствия требованиям
Что требуется. Жизненный цикл модели руководства (чёрный список, заморозка, белый список, KYC) предполагает, что эти механизмы эффективны.
Что делает контракт. Как показано в разделе 1.1, ограничение KYC и отзыв провайдера неработоспособны в развёрнутом коде.
Где расходится. Механизм, который должен работать по требованию, не работает. Это существенный разрыв, а не формальность.
Пункт 2.2.3: замороженные или уничтоженные монеты остаются полностью обеспеченными и сверяемыми
Что требуется. Стейблкоины, замороженные или уничтоженные в результате принудительных мер, должны оставаться полностью обеспеченными, чтобы предложение и резервы можно было сверить.
Что делает контракт. Обычный поток исправления для регулируемого стейблкоина — сжечь средства на плохом адресе и позже повторно выпустить эквивалентную сумму жертве в виде отдельного выпуска (именно так работает destroyBlackFunds плюс issue в USDT). Уничтожение в HKDAP уменьшает _totalSupply, что является сжиганием, но никогда не начисляет balances[address(this)], испускает Transfer в address(this) вместо address(0), и оставляет неизменными счётчики чистого выпуска, которые использует лимит выпуска:
// contracts/hce/ControllableAHKD.sol
628 function destroyBlackFunds(address _blackListedUser) external override whenNotPaused onlySupplyDestroyer() {
636 uint dirtyFunds = balanceOf(_blackListedUser);
637 balances[_blackListedUser] = 0;
638 _totalSupply = _totalSupply - dirtyFunds ;
639 emit DestroyedBlackFunds(_blackListedUser, dirtyFunds);
640 emit Transfer(_blackListedUser, address(this), dirtyFunds);
641 }
Где расходится. Изменение состояния — это сжигание, но событие говорит, что токены перешли в контракт, и ничто там никогда не хранится (balances[address(this)] остаётся нулевым). Индексатор начислил бы address(this) токены, которых он не держит, и общее предложение, восстановленное из событий, не совпадало бы с блокчейном. Это также вносит путаницу в само исправление: поскольку токены сжигаются, а не хранятся, повторный выпуск жертве должен быть новым выпуском, тем не менее вводящий в заблуждение Transfer(..., address(this), ...) предполагает, что контракт теперь хранит их и может переслать их, чего он не может. Чистое сжигание в address(0) плюс отдельный повторный выпуск было бы одновременно и корректным, и сверяемым. В текущем виде on-chain учёт, от которого зависит сверка резервов, отклоняется от истинного состояния цепочки. Это скорее опасение, чем чёткое несоответствие.
Мы ограничиваем эти выводы тем, что показывает блокчейн. Полностью ли обеспечены резервы, находятся ли ключи в HSM или в изолированной среде, и симулируются ли транзакции вне цепочки перед подписанием — эти вопросы не видны из контракта, и мы не делаем никаких утверждений о них.
Заключение
Картина согласуется по обеим осям обзора. Как программное обеспечение, HKDAP содержит функциональные дефекты, включая механизмы соответствия требованиям, которые не выполняются так, как написаны, и демонстрирует несколько признаков предпроизводственной сборки. Как регулируемый стейблкоин, несколько его on-chain свойств противоречат конкретным пунктам руководства HKMA. На основании on-chain доказательств и оставляя в стороне внецепочечные вопросы, которые мы не можем увидеть, развёрнутый контракт пока не соответствует стандарту, который должен соответствовать коммерческий стейблкоин.
Отсюда следуют два наблюдения, которые стоит сформулировать прямо.
Во-первых, выпуск в публичной цепочке меняет то, где принимается решение о соответствии требованиям. Требование, такое как «ни одна сторона не должна быть способна действовать в одностороннем порядке», выполняется или не выполняется проверками ролей в развёрнутом коде, и этот код публичен. Реализация, а не лицензия или документация, — это место, где такое требование фактически выполняется или не выполняется, и любой может проверить, какое из двух.
Во-вторых, метка «Beta Access» не меняет профиль риска развёрнутого кода. Контракт работает в основной сети Ethereum, администрируется реальными ключами и представляет требование в гонконгских долларах. Поэтому он должен соответствовать промышленным стандартам независимо от того, как он обозначен.
Под конкретными выводами проходит общая нить. Архитектура заново изобретает, в самодельной форме, примитивы, которые экосистема уже предоставляет и проверяет в промышленных масштабах: собственный движок подтверждений и слой ролей с нуля, где справился бы Safe-мультисиг с AccessManager и TimelockController от OpenZeppelin; написанная вручную коллекция связанного списка вместо EnumerableSet; модифицированный прокси вместо стандартного TransparentUpgradeableProxy; и написанный вручную ERC-20 вместо реализации OpenZeppelin. Большинство дефектов в этом обзоре живёт именно в этой самодельной механике, а не в частях, использующих стандартные компоненты. Похоже, что система была абстрагирована так, как это делается в общецелевом программном обеспечении, а не составлена из небольших, проверенных строительных блоков, которые предпочитает разработка on-chain, где каждый слой самодельной абстракции — это также газ, поверхность атаки и риск обновления. Дизайн, построенный из этих стандартных компонентов, был бы меньше, безопаснее и проще для проверки, и он включал бы то, чего в этой системе сейчас нет: окно для обдумывания от таймлока и именованные, понятные роли.
Описанные здесь проблемы устранимы. Восстановление мультиподписи для операций высокого риска, отделение исполнения от аудита, исправление логики отзыва KYC, унификация проверок перевода, удаление debug-кода и требование сторонней аудиторской проверки перед каждым обновлением решили бы большинство из них. Развёртывание в основной сети с верифицированным исходным кодом — это именно то, что сделало возможным этот обзор, и это правильная практика по умолчанию. Непрерывный on-chain обзор безопасности и соответствия требованиям такого рода — это то, чем мы занимаемся в BlockSec, и мы рады помочь.



