A BlockSec security and compliance review of HKDAP (Anchorpoint), as of August 13, 2026.
TL;DR. Revisamos el contrato desplegado de HKDAP, la primera stablecoin regulada emitida en Hong Kong, en vivo en Ethereum mainnet, y no está lista para producción. Sus controles de KYC y revocación no funcionan como están escritos, su gobernanza está tan concentrada que una sola clave puede acuñar, quemar o congelar, y varias de sus propiedades on-chain entran en conflicto con la propia guía de la HKMA. Un hilo común subyace: el contrato reinventa desde cero los primitivos que el ecosistema ya proporciona y audita a escala (un multisig, una capa de control de acceso, un timelock, el propio ERC-20), y la mayoría de los defectos residen en esa maquinaria personalizada en lugar de en las partes que reutilizan componentes estándar. La etiqueta "Beta Access" no cierra esa brecha.
El 12 de agosto de 2026, Anchorpoint lanzó la primera fase de HKDAP, una stablecoin de dólar de Hong Kong. Es un lanzamiento significativo. Anchorpoint, una empresa conjunta liderada por Standard Chartered Bank (Hong Kong) con HKT y Animoca Brands, posee una de las únicas dos licencias de emisor de stablecoin que la HKMA ha otorgado, de 36 solicitantes, y HKDAP está entre las primeras stablecoins emitidas bajo la Ordenanza de Stablecoins de Hong Kong.
A diferencia de la mayoría de los productos de un banco regulado, HKDAP es directamente inspeccionable. Corre en Ethereum mainnet, y su código fuente está verificado en Etherscan. Esto es una propiedad útil: para una stablecoin emitida de esta manera, las reglas que gobiernan la emisión, transferencia y congelación no se describen en un documento, se implementan en código que cualquiera puede leer y que se ejecuta exactamente como está escrito. Una licencia es una afirmación; el contrato desplegado es la implementación de esa afirmación, y es público.
Eso hace posible una revisión concreta, y eso es lo que hicimos. Examinamos el contrato desplegado a lo largo de dos ejes. Primero, como software: ¿es correcto y de calidad de producción? Segundo, como stablecoin regulada: ¿su comportamiento on-chain coincide con la Guía de la HKMA sobre Supervisión de Emisores de Stablecoins Licenciados?
Los hallazgos son consistentes en ambos ejes. El contrato contiene múltiples defectos funcionales, incluidos controles de cumplimiento que no funcionan como están escritos. Su gobernanza está altamente concentrada, con varias operaciones de alto riesgo ejecutables por una sola clave. Y varias de sus propiedades on-chain entran en conflicto con cláusulas específicas de la guía de la HKMA. Nuestra evaluación es que, incluso como beta, el contrato no alcanza el estándar de calidad que una stablecoin comercial requiere.
El resto de este artículo presenta esa revisión, vigente al 13 de agosto de 2026. Nuestra revisión se basa en código públicamente desplegado y hechos on-chain observables. No hacemos afirmaciones sobre asuntos que la blockchain no puede establecer, como respaldo de reservas o custodia de claves fuera de la cadena.
Cómo encontramos el contrato
Empezamos por el emisor, no por una lista de tokens. La presencia corporativa de Anchorpoint enlaza a su sitio en anchorpoint.hk. La página Beta Access nombra el despliegue: Ethereum mainnet, proxy 0x87622385F960fcCB3121d6D0A9513bd1D9Bed6cA, cuyo código fuente está verificado en Etherscan.
Desde allí mapeamos el sistema completo en la cadena: el proxy del token y su implementación (ControllableAHKD, 0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc), el contrato de gobernanza que lo administra (0x47dd47a776902bee03abdd5aeff43f41b0ffbc2b), el registro de roles de nivel superior (0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c), y cinco módulos de cumplimiento, cada uno con su propio contrato de gobernanza. Cada relación a continuación fue verificada leyendo slots de almacenamiento y llamando funciones de vista en mainnet, no inferida solo del código fuente.
Cómo se ve el contrato

El token de HKDAP no usa la implementación de referencia ERC-20 estándar de OpenZeppelin; su lógica ERC-20 está escrita desde cero (de ahí provienen varios de los errores a continuación). El sistema tiene tres capas:
- El token.
ControllableAHKD, detrás de un proxy actualizable. Un ERC-20 controlado con mint, burn, pause y destroy forzado, más comprobaciones de cumplimiento conectadas a cada transferencia. - Un motor de gobernanza M-de-N casero. Cada acción privilegiada (upgrade, mint, burn, pause, blacklist, freeze, cambiar un módulo de cumplimiento) pasa por una ceremonia de solicitud, aprobación y ejecución en lugar de un multisig simple.
- Cinco módulos de cumplimiento. Blacklist, congelación, un servicio de activación KYC, y listas blancas de depósito y reembolso. Cada módulo es a su vez un proxy gobernado por su propio contrato de autoridad de control.
Estructuralmente, esta misma unidad de "autoridad de control más proxy más implementación" se repite seis veces (el token más cinco módulos), y las seis autoridades de control resuelven sus roles en un único registro. Todo el control del sistema converge finalmente en este único registro, y quién puede reescribir los roles dentro de él se reduce a un conjunto muy pequeño de claves de firmantes, como mostramos en detalle a continuación.
Parte 1: Seguridad y errores
Los hallazgos de esta parte se resumen a continuación; cada fila se detalla en la sección indicada.
| Área | Hallazgo | Dónde | Efecto |
|---|---|---|---|
| 1.1 Los controles de KYC y revocación fallan | La revocación de KYC es código muerto | TokenHolderActivationServerLibrary.sol:221-235 |
isActive ignora la baja de proveedores (el bucle nunca se ejecuta, y usa == en lugar de =); fail-open. |
| La baja de verificadores nunca establece INACTIVE | TokenHolderActivationServer.sol:504-511 |
Un verificador "dado de baja" aún puede incorporar y desactivar carteras; una segunda llamada revierte. | |
| La prueba KYC nunca se valida on-chain | TokenHolderActivationServer.sol:575-578 |
La prueba se pasa pero se descarta; cualquier prueba, incluso una cadena vacía, pasa. | |
| La exención de transferencia libre puede eludirse dividiendo | ControllableAHKD.sol:337-535 |
freeTransferLimit se comprueba por llamada, no acumulativamente; si se usa como tope para actividad sin KYC, puede eludirse dividiendo en transferencias por debajo del límite. |
|
| 1.2 La gobernanza está sobreconcentrada | Las operaciones de alto riesgo son de firma única | authorizationMatrix; tx de mint 0xa7e53c…b33d7 |
acuñar / quemar / congelar / desactivación KYC (rol C), pausar / destruir (rol D), blacklist / descongelar (rol F) se ejecutan cada una con una clave. |
| Un par de claves (A + B) actualiza todo | authorizationMatrix |
upgradeTo para el token y los cinco módulos es rol A + B; dos personas pueden reemplazar cualquier implementación. |
|
| El mismo par reescribe cada rol | registro 0xa728… authorizationMatrix |
grantRole / revokeRole en el registro también son rol A + B (ADMIN_ROLE lo tiene solo el contrato; DEFAULT_ADMIN_ROLE = address(0)); ninguna clave externa puede cambiar roles directamente. |
|
| Una dirección tiene seis roles | registro de roles 0xa728… |
0x2f7f00… tiene el rol C más ADMIN_TOKEN_HOLDER y los cuatro roles de auditor; la ejecución y la auditoría se superponen. |
|
| La revocación no es inmediata; no hay timelock | HybridControlEngine.sol:158-227 |
Una firma contada no se vuelve a validar después de que un rol es revocado; la ejecución es atómica con la firma final, sin demora. | |
| El rastro de auditoría del motor no es confiable | HybridControlEngine.sol:36-129, 177-196 |
evtApprove emite address(0) como firmante; la lista de solicitudes activas usa nonce 0 tanto como id real y marcador de vacío, por lo que el recorrido inverso omite la primera solicitud. |
|
| 1.3 Comprobaciones de transferencia inconsistentes | transfer y transferFrom aplican reglas diferentes |
ControllableAHKD.sol:313-502 |
El retorno anticipado de la lista blanca omite KYC solo vía transferFrom; checkingMode comprueba partes diferentes; la misma transferencia se gobierna de manera distinta. |
| 1.4 Señales de una compilación preproducción | Logs de depuración, comentario incorrecto, nombre/hash de rol no coinciden, pruebas subidas | UpgradeableProxy.sol:115-119; ControllableAHKD.sol:25 |
console.log en el fallback del proxy (gas permanente); el comentario del proxy contradice el código; el nombre/hash de rol no coinciden; 117 archivos incluidas las pruebas subidos; optimizer runs = 0. |
| 1.5 La única actualización dejó los defectos en su lugar | rastreando la única actualización | tx 0x742372…85136, 0xa7a400…630c |
Desplegado el 28 abr → actualizado el 10 jul; las dos transacciones de la ceremonia A + B estuvieron a un bloque de distancia (~12s); cada defecto de la Parte 1 está en la implementación instalada. |
1.1 Los controles de KYC y revocación no funcionan como están escritos
Bajo la regulación, se requiere que los usuarios del lado B (institucionales) a los que sirve HKDAP pasen KYC. En el contrato, eso significa que debe hacer al menos dos cosas: aplicar KYC en las transferencias, de modo que una cartera que no ha pasado KYC esté bloqueada, y revocar el acceso cuando un proveedor de identidad o un titular es eliminado. En HKDAP, ese camino está roto en tres lugares independientes.
La revocación de KYC es código muerto. El token controla las transferencias llamando a isActive(address) en el módulo KYC. isActive se supone que hace dos cosas: primero, establecer un valor base a partir de un contador por cartera, activo cuando esa cartera ha sido activada por KYC más veces de las que ha sido desactivada; luego, reducir ese valor a falso si cualquier proveedor de identidad que avaló a la cartera ha sido dado de baja desde entonces. Los dos cubren dos tipos de revocación: revocar el KYC de un titular (el contador) y revocar a un proveedor completo para que cada cartera que incorporó caiga con él. Solo ocurre lo primero.
// 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 }
La línea 221 es una asignación real (=), y es la única que surte efecto: active se convierte en deactivationCount < activationCount. El bucle (líneas 226-235) es lo que debería reducir ese valor, pero falla dos veces. Su condición en la línea 226, entryCount > chainedItemList.entryCount, es 0 > N, por lo que el cuerpo nunca se ejecuta; e incluso si lo hiciera, la línea 231 usa ==, una comparación cuyo resultado se descarta, cuando debería asignar con =. isActive por lo tanto devuelve únicamente la comparación del contador e ignora por completo la revocación de proveedores. Esto es fail-open: dar de baja a un proveedor KYC comprometido no detiene a las carteras que incorporó para que transaccionen. Y como unregisterVerifier no toca esos contadores, las carteras de un proveedor revocado mantienen un contador positivo y permanecen activas.
La revocación de proveedores también está rota en el otro lado. unregisterVerifier solo mueve un nodo de lista enlazada; nunca establece el estado del directorio del proveedor a 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 }
Debido a que el estado permanece ACTIVE, un verificador "dado de baja" aún puede registrar y desactivar titulares, la rama destinada a reactivar un proveedor es inalcanzable, y una segunda llamada a unregisterVerifier subdesborda y revierte.
La prueba KYC nunca se valida en la cadena. Cuando un verificador autorizado registra o renueva una cartera a través de registerOrRenew (restringido para que solo un verificador actualmente activo pueda llamarlo), el flujo llega a _checkKYCProof, que se supone valida la prueba presentada contra el esquema del proveedor. Según la interfaz, esa prueba es "una URI para un oráculo, o un hash firmado de una fuente verificable". La función la ignora:
// 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 }
La prueba sí llega a esta función. registerOrRenew lleva la kycProof presentada a través de _registerOrRenew y la pasa como segundo argumento. Pero en la línea 575 ese argumento aterriza en un parámetro sin nombre, que el comentario de documentación sobre la función también omite, y el cuerpo nunca lo lee. La función solo confirma que el proveedor está activo y devuelve true en la línea 577, por lo que la prueba se descarta en lugar de comprobarse. Esto no es una evasión externa, ya que solo un verificador activo puede llegar allí, pero en la cadena el contrato no realiza ninguna validación de la evidencia KYC, por lo que su integridad descansa por completo en el verificador fuera de la cadena. Un verificador comprometido o negligente puede activar cualquier cartera con cualquier prueba, incluso una cadena vacía.
En conjunto, estos tres significan que una propiedad central de cumplimiento, la capacidad de controlar y revocar el acceso, no funciona como está escrita.
Además de estos tres, hay una debilidad relacionada: la exención de transferencia libre puede eludirse dividiendo las transferencias. El token tiene un freeTransferLimit, y una transferencia por debajo de él se salta la comprobación isActive (KYC). Pero el límite se compara solo contra el monto de la transferencia actual; el contrato no mantiene ningún total acumulado por dirección o período. Entonces, si el límite se usa como tope para la actividad sin KYC, un titular puede mover un total arbitrario dividiéndolo en transferencias repetidas, cada una justo por debajo del límite, lo que hace ineficaz el tope.
1.2 La gobernanza está sobreconcentrada y las operaciones de alto riesgo son de firma única
Enumeramos todos los roles y todos los titulares de roles en la cadena. Dos cosas destacan antes de los detalles.
Primero, los roles que autorizan las ceremonias M-de-N no tienen nombres legibles. En la configuración desplegada aparecen solo como hashes de 32 bytes, y ninguno coincide con una constante de rol con nombre en el código fuente verificado (los roles con nombre como SUPPLY_CONTROLLER_ROLE los tienen los propios contratos, no los firmantes). Etiquetamos los seis roles de firmante de la A a la F. Que los roles más poderosos del sistema sean identificadores opacos es en sí mismo una debilidad: hace que la gobernanza sea más difícil de revisar que si fueran roles con nombre.
Segundo, el número de titulares es pequeño. La tabla siguiente se lee del registro de roles en la cadena; las direcciones están abreviadas.
| Rol (nuestra etiqueta) | Hash on-chain | Titular(es) | Qué autoriza |
|---|---|---|---|
| A | 0x37f0f656… |
0x747356d525bf47f495af7c6e42f3ab9b8ba87a4b |
primera firma para upgradeTo / changeAdmin / setControlAuthority |
| B | 0x95c27f81… |
0x8117a657df7a1c399231bfe26a096eb8f8ad5973, 0x5d9d9b28e83382e694916b0feeaf7a9badbb659c, 0xebb73f78e253539acceb4e6cc287095788eee5d4 |
la segunda firma en casi toda acción de dos firmas |
| C | 0xfa2fe896… |
0x2f7f00cc5334fe2861e485ff610f74890a0316ed |
mintToDeposit, burnFrom, freeze, desactivación KYC (única) |
| D | 0x3dce3265… |
0x5092af62a1625fa57404557d3ad417474f3f494c |
pause, destroyBlackFunds, registrar/desregistrar direcciones de depósito y reembolso, registerVerifier (única) |
| E | 0xfd21a76d… |
0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2 |
cambiar los módulos de cumplimiento (setBlacklistServer, etc.) |
| F | 0x510ac1ff… |
0x7daabe5092d3feea5cc19ddbb5380a4eac38f89c |
addBlackList, unfreeze (única) |
Varios puntos se derivan de la tabla.
Operaciones de alto riesgo con un solo firmante. La mayoría de las operaciones de alto riesgo requieren un solo rol con cuota de uno. La tabla siguiente se lee en la cadena desde la authorizationMatrix del contrato de gobernanza del token y de los cinco contratos de gobernanza de los módulos (firma única a menos que se muestre una segunda firma + B):
| Operación | Gobernada por | Firmas requeridas |
|---|---|---|
mintToDeposit, burnFrom |
token | C x1 |
freeze, batchFreeze |
módulo de congelación | C x1 |
deactivate, adminDeactivate (KYC) |
módulo KYC | C x1 |
pause, destroyBlackFunds |
token | D x1 |
register, unregister (depósito / reembolso) |
módulos de directorio | D x1 |
registerVerifier, unregisterVerifier |
módulo KYC | D x1 |
addBlackList, batchBlackList |
módulo blacklist | F x1 |
unfreeze |
módulo de congelación | F x1 |
removeBlackList |
módulo blacklist | F x1 + B x1 |
setBlacklistServer, setFreezingServer, setCheckingMode |
token | E x1 + B x1 |
| setters de servidor de directorio y límite de suministro | token | D x1 + B x1 |
upgradeTo / changeAdmin / setControlAuthority (token y cada módulo), unpause |
token + módulos | A x1 + B x1 |
Emisión, congelación y desactivación de KYC son de firma única (todas bajo el rol C); pause, destroy, cambios de directorio y registro de verificadores son de firma única bajo el rol D; blacklisting y unfreezing son de firma única bajo el rol F. Solo las actualizaciones y cambios de configuración requieren una segunda firma. Nótese la asimetría: freeze y addBlackList necesitan una firma mientras que removeBlackList necesita dos, por lo que restringir una cuenta es más fácil que liberarla.
Esto no es solo una lectura de la matriz; es observable en un mint en vivo. La emisión más reciente al momento de escribir esto, tx 0xa7e53c…b33d7, es una sola transacción enviada por la única cuenta con rol C (0x2f7f00cc5334fe2861e485ff610f74890a0316ed) al contrato de gobernanza del token. Llama a request, y esa misma transacción alcanza el quórum y emite el Transfer de mint desde address(0), sin transacción de aprobación separada y sin segundo firmante. Debido a que la emisión se resuelve dentro de la propia transacción del solicitante, una sola clave solicitó y ejecutó el mint.
Tampoco hay timelock en ningún lugar del motor. En el momento en que la última firma requerida llega, la acción se ejecuta en esa misma transacción, sin demora en la que pueda revisarse, cancelarse o impugnarse; el motor registra una marca de tiempo executedAt pero nunca la comprueba. Así que incluso las operaciones de dos firmas se resuelven instantáneamente una vez que la segunda clave firma.
Un par de claves actualiza todo. Actualizar el token y actualizar los cinco módulos de cumplimiento usan el mismo requisito, A más B. Dado que A es una sola cuenta y B son tres cuentas que comparten un rol, dos personas pueden reemplazar cualquier implementación del sistema.
El mismo par también controla la tabla de roles. Los roles solo pueden otorgarse y revocarse en el registro (HybridControlledAuthority, 0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c), a través de su propia ceremonia; ninguna clave externa puede cambiarlos directamente. Y su authorizationMatrix, leída en la cadena, requiere las mismas firmas para un cambio de rol que para una actualización: rol A más rol B. Así que las dos personas que pueden reemplazar cualquier implementación también pueden añadir una clave al rol C, eliminar a un titular existente y reescribir toda la tabla de roles. Esa puerta de dos firmas es mejor que las operaciones de firma única anteriores, pero sigue siendo un listón bajo para la raíz del sistema, ya que el rol A es una sola cuenta sin redundancia.
Una dirección, seis roles. El titular del rol C (0x2f7f00cc5334fe2861e485ff610f74890a0316ed) también tiene el rol con nombre ADMIN_TOKEN_HOLDER_ROLE (gestión de verificadores KYC) y es miembro de los cuatro roles de auditoría (BLACKLIST_AUDITOR_ROLE, FREEZING_AUDITOR_ROLE, WHITELIST_AUDITOR_ROLE y AFL_TOKEN_AUDITOR_HOLDER_ROLE). El compromiso de esa única clave es la pérdida simultánea de emisión, congelación y administración de KYC.
Los roles de auditoría tienen dos problemas propios. Primero, controlan los getters de solo lectura de las listas de cumplimiento, aparentemente para controlar quién puede leerlas, pero en una cadena pública eso es inútil: el almacenamiento subyacente es legible por cualquiera (leer slots de almacenamiento es cómo mapeamos este sistema), por lo que las listas son públicas de cualquier manera, y la restricción muestra que el diseño no tuvo en cuenta estar en una cadena pública. Segundo, concentración: los cuatro roles de auditoría están en las mismas 23 cuentas, y los titulares de los roles de ejecución A, C, D y F están entre ellas, por lo que las mismas claves que acuñan, queman, congelan y bloquean también están en el grupo destinado a revisar esas acciones.
La revocación no es inmediata. En el motor de ceremonia, el rol de un firmante se comprueba una vez y la cuota se decrementa; los firmantes anteriores nunca se vuelven a validar, por lo que revocar un rol después no retira un voto ya contado:
// 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 }
Finalmente, el rastro de auditoría del propio motor no es confiable. En cada aprobación, el evento evtApprove emite address(0) como firmante en lugar del aprobador real; el firmante real solo sobrevive en el remitente de la transacción y en un registro interno, por lo que los registros de eventos no pueden atribuir quién aprobó una solicitud. Y la lista de solicitudes activas usa nonce 0 tanto como id real de solicitud y marcador de vacío, por lo que las herramientas de monitoreo o aprobación que recorren la lista hacia atrás omiten la solicitud en nonce 0. Ninguno es crítico, pero para un sistema regulado que necesita un rastro de auditoría limpio, ambos le restan.
1.3 Los controles de transferencia son inconsistentes entre transfer y transferFrom
Parte de la diferencia entre los dos es esperable. En transferFrom el iniciador, msg.sender, es un gastador aprobado en lugar de la fuente de los fondos, por lo que el código comprueba explícitamente la fuente real from en cada modo; transfer no necesita hacerlo, porque allí msg.sender es la fuente. Esa adaptación es razonable. Otras dos diferencias no se explican por el iniciador, y dejan la misma acción económica gobernada por reglas diferentes.
Primero, las listas blancas de depósito y reembolso se consultan solo en la ruta transferFrom, donde la pertenencia a la lista provoca un retorno anticipado que omite la comprobación isActive (KYC). transfer nunca las consulta. Que un destinatario esté en la lista blanca no tiene nada que ver con quién inició la transferencia, por lo que el mismo destinatario está sujeto a KYC a través de transfer, pero puede omitirlo a través de 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" ) ) ;
Segundo, checkingMode significa cosas diferentes en las dos funciones. En transfer, el modo "Source" comprueba al remitente; en transferFrom, el modo "Source" comprueba al gastador (msg.sender), mientras que from se comprueba en todos los modos. Así, una única configuración aplica dos políticas diferentes dependiendo del punto de entrada.
El efecto es que los controles de transferencia de un token regulado dependen de qué función se use, lo que los hace difíciles de razonar y, según la configuración, evitables.
1.4 Señales de una compilación preproducción
Además de los errores de lógica específicos, varias propiedades del código indican que se desplegó en mainnet una compilación preproducción.
Registro de depuración en producción. Quedan llamadas a hardhat/console.log en todo el código, incluso dentro del fallback del proxy, que se ejecuta en cada transacción de usuario. Debido a que el proxy no es actualizable, esta sobrecarga es permanente:
// 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 }
Un comentario de cabecera del proxy que describe lo contrario del código. El mismo archivo incluye la documentación del TransparentUpgradeableProxy de OpenZeppelin, que establece que el admin nunca puede caer a la implementación. Este contrato deliberadamente hace lo contrario: la protección de la línea 117 está comentada, y el admin sí cae a la implementación. Un revisor que confiara en el comentario modelaría mal el límite de confianza.
Nombres de rol que no coinciden con sus hashes. El rol de "riesgo elevado" se declara con el mismo nombre de constante pero con una cadena keccak diferente en el token y en los módulos, lo que produce dos roles diferentes:
// 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') ;
El despliegue evita problemas solo porque cada módulo tiene su propia copia; un segundo nombre de rol (AFL_TOKEN_HOLDER_AUDITOR_ROLE) está transpuesto de la misma manera.
Otras señales. El paquete de verificación del proxy subió 117 archivos, incluida la suite de pruebas del proyecto, al explorador público, lo que entrega al lector las pruebas internas y los casos límite. La actualización más reciente solo cambió la limpieza de advertencias del compilador, sin una auditoría de terceros en el proceso. Y el optimizador está configurado a cero runs, lo que hace que las rutas calientes de un token muy utilizado sean más caras, no menos.
Ninguna de estas es grave individualmente. En conjunto, indican que el código no pasó por la disciplina de publicación que se espera de un contrato que mantiene valor en mainnet.
1.5 La única actualización del contrato, rastreada
El proxy se ha actualizado una vez. Fue desplegado el 28 de abril de 2026 con la implementación 0x8ff12fe3bed22d9e40afb4b98ef4dee28d94699e, y el 10 de julio de 2026 se actualizó a la actual 0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc. Debido a que una actualización es una acción de gobernanza en la cadena, podemos ver exactamente quién la aprobó.
upgradeTo requiere rol A más rol B. La actualización fue dos transacciones en bloques adyacentes, con unos doce segundos de diferencia:
- la solicitud, rol A, desde
0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2, en el bloque 25500519 (tx0x742372…85136); - la aprobación, rol B, desde
0x3795300b31429f9d37b0dc805528d9390ce87c50, en el bloque 25500520 (tx0xa7a400…630c), que alcanzó el quórum y realizó la actualización en la misma transacción.
Dos cosas destacan. Primero, toda la ceremonia de dos firmas se completó dentro de un intervalo de un bloque. De la solicitud a la ejecución hubo un bloque; no hubo ventana en la que el segundo firmante pudiera revisar de forma independiente antes de que el cambio entrara en vigor.
Segundo, ningún firmante es identificable desde el registro de roles actual. Los roles se han rotado desde entonces: el solicitante 0xa9315a… ya no tiene el rol A (hoy tiene el rol E), y el segundo firmante 0x3795300b… no tiene ningún rol ahora. Leer el registro tal como está no le diría quién autorizó la actualización; solo lo dice el historial de transacciones. Esta es la propiedad de "revocación no inmediata" vista desde el otro lado: los roles se mueven, por lo que una instantánea de quién tiene qué no es un registro de quién hizo qué.
Y la actualización no corrigió ninguno de los defectos de esta revisión. La implementación que instaló, 0xe42d38b0…, es la que describe toda la Parte 1. No podemos saber desde la cadena si se realizó una auditoría de terceros antes de la actualización; lo que podemos decir es que, si se hizo, los defectos de la Parte 1 la sobrevivieron.
Parte 2: ¿Coincide con el marco de stablecoins de Hong Kong?
La Ordenanza de Stablecoins de Hong Kong entró en vigor el 1 de agosto de 2025, y los emisores licenciados son supervisados bajo la Guía de la HKMA sobre Supervisión de Emisores de Stablecoins Licenciados. Comparamos solo las cláusulas que un contrato inteligente puede cumplir por sí mismo; el respaldo de reservas, la custodia y las ceremonias de claves fuera de la cadena están fuera del alcance de una revisión on-chain. Para cada cláusula a continuación exponemos qué exige la guía, qué hace el contrato y dónde divergen. La comparación se resume aquí y se detalla en las secciones siguientes.
| Cláusula de la HKMA | Qué exige | Dónde diverge el contrato | Veredicto |
|---|---|---|---|
| 6.5.3 | Las operaciones de alto riesgo no deben ser unilaterales (multifirma, y mitigantes como límites de velocidad o timelocks) | mint, burn, pause y freeze se ejecutan cada una con una clave; no hay timelock (la ejecución es atómica con la firma final) | Diverge |
| 6.5.4 | Segregar funciones entre personas autorizadas; revocar la autoridad inmediatamente | una cuenta tiene seis roles, por lo que la ejecución y la auditoría se solapan; una aprobación contada sobrevive a una revocación posterior | Diverge |
| 6.5.5 | Auditoría de terceros para cada cambio de código; correcto, consistente, libre de vulnerabilidades | los defectos de la Parte 1 están vivos en la implementación desplegada; isActive y la revocación de proveedores no hacen lo que su nombre indica |
Diverge |
| Efectividad de los controles de cumplimiento | los controles de blacklist, freeze, whitelist y KYC deben ser efectivos | la restricción KYC y la revocación de proveedores no son funcionales en el código desplegado | Diverge |
| 2.2.3 | Las monedas congeladas o destruidas permanecen totalmente respaldadas y reconciliables | destroy emite Transfer a address(this) en lugar de address(0) y deja intactos los contadores de emisión neta; el suministro reconstruido a partir de eventos se desvía |
Preocupación |
Párrafo 6.5.3: las operaciones de alto riesgo no deben ser unilaterales
Qué exige. Las operaciones de alto riesgo deberían diseñarse de modo que ninguna parte pueda realizarlas unilateralmente, por ejemplo mediante un protocolo de multifirma, y la guía enumera mitigantes adicionales como límites de velocidad y controles con retardo temporal (timelock).
Qué hace el contrato. Leído desde la authorizationMatrix del contrato de gobernanza (la matriz completa está en la tabla de 1.2), las operaciones de suministro y emergencia requieren cada una un solo rol, con cuota de uno:
| Operación | Firma requerida |
|---|---|
mintToDeposit |
ROLE_C x1 |
burnFrom |
ROLE_C x1 |
freeze |
ROLE_C x1 |
pause |
ROLE_D x1 |
destroyBlackFunds |
ROLE_D x1 |
Dónde diverge. Acuñar, quemar, pausar y congelar pueden ejecutarse cada una con una sola clave. Esto no cumple el requisito de que "ninguna parte actúe unilateralmente". Tampoco hay timelock: como se muestra en 1.2, una vez alcanzado el quórum, la acción se ejecuta en la misma transacción, por lo que también está ausente un retardo temporal, uno de los mitigantes que la guía menciona. El contrato sí implementa un límite de velocidad de suministro (whenWithinRiskThresholds), por lo que ese mitigante está presente, pero no sustituye al control multifirma sobre las propias operaciones.
Párrafo 6.5.4: segregación de funciones y revocación inmediata
Qué exige. Diferentes operaciones deberían estar segregadas entre diferentes personas autorizadas, y la autoridad de una persona autorizada debería ser revocable de inmediato.
Qué hace el contrato. Una cuenta de propiedad externa (EOA) tiene seis roles (emisión, congelación, administración de KYC y los cuatro roles de auditoría), por lo que los roles de ejecución y auditoría se superponen. Los cambios de roles en sí mismos requieren solo el rol A más el rol B (ver 1.2), por lo que el mismo grupo pequeño decide quién está autorizado y puede ejecutar y actualizar. Y una firma ya contada no se vuelve a validar si el rol del firmante se revoca después.
Dónde diverge. Las funciones están concentradas en lugar de segregadas, y la revocación no es inmediata: la aprobación anterior de un firmante revocado todavía cuenta para una ejecución posterior.
Párrafo 6.5.5: auditar cada cambio de código; correcto, consistente, sin vulnerabilidades
Qué exige. Un tercero calificado debería auditar los contratos inteligentes en cada cambio de código y confirmar que están (i) implementados correctamente, (ii) consistentes con la funcionalidad prevista y (iii) libres de vulnerabilidades con un alto nivel de confianza.
Qué hace el contrato. La implementación actual es la instalada por la actualización del 10 de julio (rastreada en 1.5), y los defectos de la Parte 1 están vivos en ella.
Dónde diverge. No podemos ver fuera de la cadena si se realizó una auditoría, pero el resultado no cumple el estándar de cualquier manera. Debido a que isActive y la revocación de proveedores no hacen lo que su nombre indica, no se cumplen las condiciones (i) y (ii); y con los defectos de la Parte 1 presentes en el código desplegado, tampoco se cumple (iii).
Efectividad de los controles de cumplimiento
Qué exige. El modelo de ciclo de vida de la guía (blacklist, freeze, whitelist, KYC) presupone que esos controles son efectivos.
Qué hace el contrato. Como se muestra en 1.1, la restricción KYC y la revocación de proveedores no son funcionales en el código desplegado.
Dónde diverge. Un control que se requiere que funcione no funciona. Esta es una brecha sustantiva, no una formalidad.
Párrafo 2.2.3: las monedas congeladas o destruidas permanecen totalmente respaldadas y reconciliables
Qué exige. Las stablecoins congeladas o destruidas por una acción de cumplimiento deberían permanecer totalmente respaldadas, de modo que el suministro y las reservas puedan conciliarse.
Qué hace el contrato. El flujo habitual de remediación para una stablecoin regulada es quemar los fondos en una dirección mala y luego reemitir una cantidad igual a la víctima como un mint separado (así es como funcionan destroyBlackFunds y issue de USDT). El destroy de HKDAP reduce _totalSupply, lo cual es una quema, pero nunca acredita balances[address(this)], emite un Transfer a address(this) en lugar de address(0), y deja intactos los contadores de emisión neta que usa el límite de mint:
// contracts/hce/ControllableAHKD.sol
628 function destroyBlackFunds(address _blackListedUser) external override whenNotPaused onlySupplyDestroyer() {
636 uint dirtyFunds = balanceOf(_blackListedUser);
637 balances[_blackListedUser] = 0;
638 _totalSupply = _totalSupply - dirtyFunds ;
639 emit DestroyedBlackFunds(_blackListedUser, dirtyFunds);
640 emit Transfer(_blackListedUser, address(this), dirtyFunds);
641 }
Dónde diverge. El cambio de estado es una quema, pero el evento dice que los tokens se movieron al contrato, y nada se mantiene allí (balances[address(this)] sigue en cero). Un indexador acreditaría a address(this) tokens que no posee, y el suministro total reconstruido a partir de eventos no coincidiría con la cadena. También confunde la remediación en sí: debido a que los tokens se queman en lugar de estacionarse, una reemisión a la víctima debe ser un mint nuevo, pero el engañoso Transfer(..., address(this), ...) sugiere que el contrato ahora los custodia y podría reenviarlos, lo cual no puede. Una quema limpia a address(0) más una reemisión separada sería a la vez correcta y reconciliable. Tal como está escrito, la contabilidad on-chain de la que depende la conciliación de reservas se desvía del estado real de la cadena. Esto es una preocupación, no un pase limpio.
Limitamos estos hallazgos a lo que muestra la cadena. Si las reservas están totalmente respaldadas, si las claves están en un HSM o en un entorno aislado, y si las transacciones se simulan fuera de la cadena antes de firmar no son visibles desde el contrato, y no hacemos ninguna afirmación al respecto.
Conclusión
El panorama es consistente en ambos ejes de la revisión. Como software, HKDAP contiene defectos funcionales, incluidos controles de cumplimiento que no se ejecutan como están escritos, y muestra varias señales de una compilación preproducción. Como stablecoin regulada, varias de sus propiedades on-chain entran en conflicto con cláusulas específicas de la guía de la HKMA. Según la evidencia en la cadena, y dejando de lado los asuntos fuera de la cadena que no podemos ver, el contrato desplegado aún no cumple el estándar que una stablecoin comercial debería cumplir.
Se siguen dos observaciones, y vale la pena decirlas claramente.
Primero, emitir en una cadena pública cambia dónde se decide el cumplimiento. Un requisito como "ninguna parte debería poder actuar unilateralmente" se cumple o no según las comprobaciones de roles en el código desplegado, y ese código es público. La implementación, no la licencia ni la documentación, es donde tal requisito se cumple o se incumple, y cualquiera puede verificarlo.
Segundo, la etiqueta "Beta Access" no cambia el perfil de riesgo del código desplegado. El contrato está vivo en Ethereum mainnet, administrado por claves reales, y representa un derecho sobre dólares de Hong Kong. Por lo tanto, debe cumplir los estándares de producción independientemente de cómo se etiquete.
Un hilo común subyace a los hallazgos específicos. La arquitectura reinventa, en forma hecha a medida, primitivas que el ecosistema ya proporciona y ha auditado a escala: un motor de aprobación y capa de roles desde cero donde servirían un multisig Safe con AccessManager y TimelockController de OpenZeppelin; una colección de listas enlazadas escrita a mano en lugar de EnumerableSet; un proxy modificado en lugar del TransparentUpgradeableProxy estándar; y un ERC-20 escrito a mano en lugar del de OpenZeppelin. La mayoría de los defectos de esta revisión viven en esa maquinaria personalizada, no en las partes que reutilizan componentes estándar. Se lee como un sistema abstraído de la manera en que se abstrae el software de propósito general, en lugar de estar compuesto por los pequeños bloques de construcción auditados que favorece el desarrollo en cadena, donde cada capa de abstracción personalizada es también gas, superficie de ataque y riesgo de actualización. Un diseño construido con esos componentes estándar sería más pequeño, más seguro y más fácil de revisar, y vendría con las cosas de las que este sistema actualmente carece: una ventana de deliberación de un timelock y roles con nombre y legibles.
Los problemas aquí descritos son abordables. Restaurar la multifirma en las operaciones de alto riesgo, separar la ejecución de la auditoría, corregir la lógica de revocación de KYC, unificar las comprobaciones de transferencia, eliminar el código de depuración y exigir una auditoría de terceros antes de cada actualización resolverían la mayoría de ellos. Desplegar en mainnet con código fuente verificado también es lo que hizo posible esta revisión, y es el valor predeterminado correcto. La revisión continua de seguridad y cumplimiento on-chain de este tipo es lo que hacemos en BlockSec, y estamos encantados de ayudar.



