Una revisión de seguridad y cumplimiento de BlockSec sobre HKDAP (Anchorpoint), a fecha del 13 de agosto de 2026.
TL;DR. Revisamos el contrato desplegado de HKDAP, la primera stablecoin regulada emitida en Hong Kong, activa en la red principal de Ethereum, y no está lista para producción. Sus controles de KYC y revocación no funcionan como están escritos, su gobernanza está lo suficientemente concentrada como para que una sola clave pueda acuñar, quemar o congelar, y varias de sus propiedades en cadena entran en conflicto con la propia directriz de la HKMA. Hay un hilo común subyacente: el contrato reinventa desde cero las primitivas que el ecosistema ya proporciona y audita a gran 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 más que en las partes que reutilizan componentes estándar. La etiqueta de "Beta Access" no cierra esa brecha.
El 12 de agosto de 2026, Anchorpoint lanzó la primera fase de HKDAP, una stablecoin del dólar de Hong Kong. Es un lanzamiento significativo. Anchorpoint, una empresa conjunta liderada por Standard Chartered Bank (Hong Kong) junto con HKT y Animoca Brands, posee una de solo dos licencias de emisor de stablecoins que la HKMA ha concedido, de entre 36 solicitantes, y HKDAP es una de 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. Funciona en la red principal de Ethereum, y el código fuente de su contrato está verificado en Etherscan. Esta es una propiedad útil: para una stablecoin emitida de esta manera, las reglas que gobiernan la emisión, la transferencia y la congelación no se describen en un documento, sino que 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ública.
Eso hace posible una revisión concreta, y eso es lo que hicimos. Examinamos el contrato desplegado según dos ejes. Primero, como software: ¿es correcto y de calidad para producción? Segundo, como stablecoin regulada: ¿coincide su comportamiento en cadena con la Directriz sobre Supervisión de Emisores de Stablecoins Licenciados de la HKMA?
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 en cadena entran en conflicto con cláusulas específicas de la directriz de la HKMA. Nuestra evaluación es que, incluso como beta, el contrato no cumple con el estándar de calidad que requiere una stablecoin comercial.
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 observables en cadena. No hacemos afirmaciones sobre asuntos que la cadena de bloques no puede establecer, como el respaldo de reservas o la custodia de claves fuera de la cadena.
Cómo encontramos el contrato
Partimos del emisor, no de una lista de tokens. La presencia corporativa de Anchorpoint enlaza a su sitio en anchorpoint.hk. La página de Beta Access indica el despliegue: red principal de Ethereum, proxy 0x87622385F960fcCB3121d6D0A9513bd1D9Bed6cA, cuyo código fuente está verificado en Etherscan.
A partir de ahí mapeamos todo el sistema en 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 a funciones de vista en la red principal, no inferida solo a partir del código fuente.
Cómo se ve el contrato
Las tres capas de HKDAP: un plano de control de gobernanza administra el token, y el token consulta cinco módulos de cumplimiento en cada transferencia.

El token de HKDAP no usa la implementación estándar de referencia de ERC-20 de OpenZeppelin; su lógica ERC-20 está escrita desde cero (aquí es de donde 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 acuñación, quema, pausa y destrucción forzada, más comprobaciones de cumplimiento integradas en cada transferencia. - Un motor de gobernanza M-de-N desarrollado internamente. Cada acción privilegiada (actualización, acuñación, quema, pausa, lista negra, congelación, cambio de un módulo de cumplimiento) pasa por una ceremonia de solicitud, aprobación y ejecución en lugar de un multisig sencillo.
- Cinco módulos de cumplimiento. Lista negra, congelación, un servicio de activación de KYC, y listas blancas de depósito y redención. Cada módulo es en sí mismo 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 en el sistema converge en última instancia 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 | Ubicación | 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 del proveedor (el bucle nunca se ejecuta, y usa == en lugar de =); falla de forma abierta. |
| La baja del verificador 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 de KYC nunca se valida en cadena | TokenHolderActivationServer.sol:575-578 |
La prueba se pasa como argumento pero se descarta; cualquier prueba, incluida una cadena vacía, pasa. | |
| El límite de transferencia libre se puede evadir dividiendo | ControllableAHKD.sol:337-535 |
freeTransferLimit se comprueba por llamada, no de forma acumulativa; si se usa como tope de actividad sin KYC, se puede evadir 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 acuñación 0xa7e53c…b33d7 |
acuñar / quemar / congelar / desactivar KYC (rol C), pausar / destruir (rol D), lista negra / descongelar (rol F) se ejecutan cada una con una sola clave. |
| Un par de claves (A + B) actualiza todo | authorizationMatrix |
upgradeTo para el token y los cinco módulos requiere 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 posee solo el contrato; DEFAULT_ADMIN_ROLE = address(0)); ninguna clave externa puede cambiar los roles directamente. |
|
| Una dirección posee seis roles | registro de roles 0xa728… |
0x2f7f00… posee el rol C más ADMIN_TOKEN_HOLDER y los cuatro roles de auditor; ejecución y auditoría se superponen. |
|
| La revocación no es inmediata; no hay timelock | HybridControlEngine.sol:158-227 |
Una firma contabilizada no se vuelve a validar tras revocarse un rol; la ejecución es atómica con la firma final, sin ningún retraso. | |
| El registro 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 el nonce 0 tanto como id real como marcador vacío, por lo que el recorrido inverso pasa por alto la primera solicitud. |
|
| 1.3 Comprobaciones de transferencia inconsistentes | transfer y transferFrom aplican reglas diferentes |
ControllableAHKD.sol:313-502 |
El retorno anticipado por lista blanca evita el KYC solo a través de transferFrom; checkingMode comprueba partes diferentes; la misma transferencia se gobierna de forma distinta. |
| 1.4 Señales de una compilación previa a producción | registros de depuración, comentario erróneo, discrepancia nombre/hash, 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; discrepancia entre nombre y hash de rol; 117 archivos subidos, incl. pruebas; optimizer runs = 0. |
| 1.5 La única actualización dejó defectos intactos | rastreo de la única actualización | tx 0x742372…85136, 0xa7a400…630c |
Despliegue el 28 de abril → actualización el 10 de julio; las dos transacciones de la ceremonia A + B estuvieron separadas por un bloque (~12s); todos los defectos de la Parte 1 están en la implementación instalada. |
1.1 Los controles de KYC y revocación no funcionan como están escritos
Bajo la regulación, los usuarios del lado B (institucionales) a los que sirve HKDAP están obligados a pasar KYC. En el contrato, eso significa que debe hacer al menos dos cosas: aplicar el KYC en las transferencias, de modo que una cartera que no ha pasado el KYC quede bloqueada, y revocar el acceso cuando se elimina un proveedor de identidad o un titular. En HKDAP, esa vía está rota en tres lugares independientes.
La revocación de KYC es código muerto. El token filtra las transferencias llamando a isActive(address) en el módulo de KYC. isActive está pensado para hacer 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 algún proveedor de identidad que avaló 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 todo un proveedor de modo que cada cartera que incorporó caiga con él (el bucle). 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 tiene efecto: active se convierte en la comparación 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; y aunque se ejecutara, la línea 231 usa ==, una comparación cuyo resultado se descarta, donde debería asignar con =. Por lo tanto, isActive devuelve solo la comparación del contador e ignora por completo la revocación del proveedor. Esto falla de forma abierta: dar de baja a un proveedor de KYC comprometido no impide que las carteras que incorporó sigan operando. Y como unregisterVerifier no toca esos contadores, las carteras de un proveedor revocado mantienen un recuento positivo y permanecen activas.
La baja del proveedor también está rota del otro lado. unregisterVerifier solo mueve un nodo de la 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 }
Como el estado permanece ACTIVE, un verificador "dado de baja" todavía puede registrar y desactivar titulares, la rama pensada para reactivar a un proveedor es inalcanzable, y una segunda llamada a unregisterVerifier produce un desbordamiento por debajo y revierte.
La prueba de KYC nunca se valida en cadena. Cuando un verificador autorizado registra o renueva una cartera a través de registerOrRenew (con acceso restringido de modo que solo un verificador actualmente activo puede llamarlo), el flujo llega a _checkKYCProof, que está pensada para validar la prueba enviada frente al 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 el kycProof enviado a través de _registerOrRenew y lo pasa como segundo argumento. Pero en la línea 575 ese argumento cae en un parámetro sin nombre alguno, 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 por parte de un externo, ya que solo un verificador activo puede llegar a ella, pero en cadena el contrato no realiza ninguna validación de la evidencia de KYC, por lo que su integridad depende enteramente del verificador fuera de la cadena. Un verificador comprometido o descuidado puede activar cualquier cartera con cualquier prueba, incluida una cadena vacía.
En conjunto, estos tres puntos significan que una propiedad de cumplimiento central, la capacidad de restringir y revocar el acceso, no funciona como está escrita.
Más allá de estos tres, hay una debilidad relacionada: el límite de exención de transferencia libre se puede evadir dividiendo. El token tiene un freeTransferLimit, y una transferencia por debajo de él omite la comprobación de isActive (KYC). Pero el límite se compara solo con el importe de la transferencia actual; el contrato no mantiene ningún total acumulado por dirección o periodo. Así que, si el límite se usa como tope de 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 que el tope sea inefectivo.
1.2 La gobernanza está sobreconcentrada, y las operaciones de alto riesgo son de firma única
Enumeramos cada rol y cada titular de rol en cadena. Dos cosas destacan antes de entrar en 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 de ellos coincide con una constante de rol con nombre en el código fuente verificado (los roles con nombre, como SUPPLY_CONTROLLER_ROLE, los poseen los propios contratos, no los firmantes). Etiquetamos los seis roles de firmante de A a 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 los roles tuvieran nombre.
Segundo, el número de titulares es pequeño. La siguiente tabla se lee del registro de roles en cadena; las direcciones están abreviadas.
| Rol (nuestra etiqueta) | Hash en cadena | Titular(es) | Qué autoriza |
|---|---|---|---|
| A | 0x37f0f656… |
0x747356d525bf47f495af7c6e42f3ab9b8ba87a4b |
primera firma para upgradeTo / changeAdmin / setControlAuthority |
| B | 0x95c27f81… |
0x8117a657df7a1c399231bfe26a096eb8f8ad5973, 0x5d9d9b28e83382e694916b0feeaf7a9badbb659c, 0xebb73f78e253539acceb4e6cc287095788eee5d4 |
la segunda firma en casi todas las acciones de dos firmas |
| C | 0xfa2fe896… |
0x2f7f00cc5334fe2861e485ff610f74890a0316ed |
mintToDeposit, burnFrom, freeze, desactivación de KYC (única) |
| D | 0x3dce3265… |
0x5092af62a1625fa57404557d3ad417474f3f494c |
pause, destroyBlackFunds, registrar/desregistrar direcciones de depósito y redención, registerVerifier (única) |
| E | 0xfd21a76d… |
0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2 |
cambiar los módulos de cumplimiento (setBlacklistServer, etc.) |
| F | 0x510ac1ff… |
0x7daabe5092d3feea5cc19ddbb5380a4eac38f89c |
addBlackList, unfreeze (única) |
De la tabla se derivan varios puntos.
Operaciones de alto riesgo con un solo firmante. La mayoría de las operaciones de alto riesgo requieren un solo rol con un cupo de uno. La siguiente tabla se lee en cadena a partir del 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 de KYC | C x1 |
pause, destroyBlackFunds |
token | D x1 |
register, unregister (depósito / redención) |
módulos de directorio | D x1 |
registerVerifier, unregisterVerifier |
módulo de KYC | D x1 |
addBlackList, batchBlackList |
módulo de lista negra | F x1 |
unfreeze |
módulo de congelación | F x1 |
removeBlackList |
módulo de lista negra | F x1 + B x1 |
setBlacklistServer, setFreezingServer, setCheckingMode |
token | E x1 + B x1 |
| establecedores 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 |
La emisión, la congelación y la desactivación de KYC son de firma única (todas bajo el rol C); la pausa, la destrucción, los cambios de directorio y el registro de verificadores son de firma única bajo el rol D; la inclusión en lista negra y la descongelación son de firma única bajo el rol F. Solo las actualizaciones y los cambios de configuración requieren una segunda firma. Nótese la asimetría: freeze y addBlackList necesitan una firma mientras que removeBlackList necesita dos, así que restringir una cuenta es más fácil que liberarla.
Esto no es solo una lectura de la matriz; es observable en una emisión en vivo. La emisión más reciente en el momento de escribir esto, tx 0xa7e53c…b33d7, es una única transacción enviada por la única cuenta del rol C (0x2f7f00cc5334fe2861e485ff610f74890a0316ed) al contrato de gobernanza del token. Llama a request, y esa misma transacción alcanza el quórum y emite la Transfer de acuñación desde address(0), sin una transacción de aprobación separada y sin un segundo firmante. Como la emisión se liquida dentro de la propia transacción del solicitante, una sola clave solicitó y ejecutó la acuñación.
Tampoco hay ningún timelock en ninguna parte del motor. En el momento en que llega la última firma requerida, la acción se ejecuta en esa misma transacción, sin ningún retraso en el que pudiera revisarse, cancelarse o contestarse; el motor registra una marca de tiempo executedAt pero nunca la comprueba. Así que incluso las operaciones de dos firmas se liquidan de forma instantánea en cuanto 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. Como 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 se pueden conceder y revocar en el registro (HybridControlledAuthority, 0xa7287701f7ab8f38ef57cdb2a2a69a40128ec89c), a través de su propia ceremonia; ninguna clave externa puede cambiarlos directamente. Y su authorizationMatrix, leído en 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 barrera de dos firmas es mejor que las operaciones de firma única mencionadas antes, pero sigue siendo un umbral 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 posee el ADMIN_TOKEN_HOLDER_ROLE con nombre propio (gestión de verificadores de KYC) y es miembro de los cuatro roles de auditor (BLACKLIST_AUDITOR_ROLE, FREEZING_AUDITOR_ROLE, WHITELIST_AUDITOR_ROLE, y AFL_TOKEN_AUDITOR_HOLDER_ROLE). El compromiso de esa única clave supone la pérdida simultánea de la administración de emisión, congelación y KYC.
Los roles de auditor tienen dos problemas propios. Primero, restringen los getters de solo lectura de las listas de cumplimiento, aparentemente para controlar quién puede leerlas, pero en una cadena pública eso no tiene sentido: el almacenamiento subyacente es legible por cualquiera (leer slots de almacenamiento es cómo mapeamos este sistema), así que las listas son públicas de todos modos, y la restricción demuestra que el diseño no tuvo en cuenta que se ejecutaría en una cadena pública. Segundo, la concentración: los cuatro roles de auditor residen en las mismas 23 cuentas, y los titulares de los roles de ejecución A, C, D y F están entre ellas, así que las mismas claves que acuñan, queman, congelan y ponen en lista negra también forman parte del grupo pensado para revisar esas acciones.
La revocación no es inmediata. En el motor de ceremonias, el rol de un firmante se comprueba una vez y el cupo se decrementa; los firmantes anteriores nunca se vuelven a validar, por lo que revocar un rol posteriormente no retira un voto ya contabilizado:
// 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 propio registro de auditoría del 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 el nonce 0 tanto como id real de solicitud como su marcador vacío, por lo que las herramientas de monitorización o aprobación que recorren la lista hacia atrás pasan por alto la solicitud en el nonce 0. Ninguno de los dos es crítico, pero para un sistema regulado que necesita un rastro de auditoría limpio, ambos restan de él.
1.3 Los controles de transferencia son inconsistentes entre transfer y transferFrom
Parte de la diferencia entre ambos es esperable. En transferFrom el iniciador, msg.sender, es un gastador aprobado en lugar del origen de los fondos, así que el código comprueba explícitamente el origen real from en cada modo; transfer no necesita hacerlo, porque ahí msg.sender es el origen. 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 redención se consultan solo en la ruta de transferFrom, donde la pertenencia desencadena un retorno anticipado que evita la comprobación de isActive (KYC). transfer nunca las consulta. Si un destinatario está en la lista blanca no tiene nada que ver con quién inició la transferencia, así 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í que un único ajuste de configuración aplica dos políticas diferentes según el 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, dependiendo de la configuración, evitables.
1.4 Señales de una compilación previa a producción
Más allá de los errores lógicos específicos, varias propiedades de la base de código indican que se desplegó una compilación previa a producción en la red principal.
Registro de depuración en producción. Persisten llamadas a hardhat/console.log en todo el código, incluidas dentro del fallback del proxy, que se ejecuta en cada transacción de usuario. Como el propio 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 lleva la documentación de TransparentUpgradeableProxy de OpenZeppelin, que establece que el admin nunca puede caer en la implementación. Este contrato hace deliberadamente lo contrario: la protección en la línea 117 está comentada, y el admin sí cae en la implementación. Un revisor que confíe 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 una cadena keccak diferente en el token y en los módulos, lo que produce dos roles distintos:
// 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 a un lector las pruebas internas y los casos límite. La actualización más reciente cambió solo la limpieza de advertencias del compilador, sin ninguna auditoría de terceros involucrada. Y el optimizador está configurado con cero ejecuciones, lo que hace que las rutas críticas de un token de uso intensivo sean más caras en lugar de más baratas.
Ninguno de estos puntos es grave por sí solo. En conjunto, indican que el código no pasó por la disciplina de lanzamiento esperada para un contrato que sostiene valor en la red principal.
1.5 La única actualización del contrato, rastreada
El proxy se ha actualizado una vez. Se desplegó el 28 de abril de 2026 con la implementación 0x8ff12fe3bed22d9e40afb4b98ef4dee28d94699e, y el 10 de julio de 2026 se actualizó a la actual 0xe42d38b05d7ff702193ac5ccedb411b7298ffbfc. Como una actualización es una acción de gobernanza en cadena, podemos ver exactamente quién la aprobó.
upgradeTo requiere el rol A más el rol B. La actualización fueron dos transacciones en bloques adyacentes, con unos doce segundos de diferencia:
- la solicitud, rol A, de
0xa9315aadc89ba681f1fe5df3375ab1a87d37eca2, en el bloque 25500519 (tx0x742372…85136); - la aprobación, rol B, de
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 único intervalo de bloque. De la solicitud a la ejecución hubo un solo bloque; no hubo ninguna ventana en la que el segundo firmante pudiera revisar de forma independiente antes de que el cambio entrara en vigor.
Segundo, ninguno de los dos firmantes es identificable a partir del registro de roles actual. Los roles se han rotado desde entonces: el solicitante 0xa9315a… ya no posee el rol A (hoy posee el rol E), y el segundo firmante 0x3795300b… ya no posee ningún rol. Leer el registro tal como está hoy no diría quién autorizó la actualización; solo lo hace el historial de transacciones. Esta es la propiedad de "la revocación no es inmediata" vista desde el otro lado: los roles se mueven, así que una instantánea de quién posee 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 a partir de la cadena si se realizó una auditoría de terceros antes de la actualización; lo que podemos decir es que, si se realizó, los defectos de la Parte 1 sobrevivieron a ella.
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 están supervisados bajo la Directriz de la HKMA sobre Supervisión de Emisores de Stablecoins Licenciados. Comparamos únicamente las cláusulas que un contrato inteligente puede satisfacer 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 en cadena. Para cada cláusula a continuación indicamos qué requiere la directriz, qué hace el contrato, y dónde difieren. La comparación se resume aquí y se detalla en las secciones siguientes.
| Cláusula de la HKMA | Qué requiere | Dónde el contrato diverge | Veredicto |
|---|---|---|---|
| 6.5.3 | Las operaciones de alto riesgo no deben ser unilaterales (multi-firma, y mitigantes como límites de velocidad o timelocks) | acuñar, quemar, pausar y congelar se ejecutan cada una con una sola clave; sin timelock (la ejecución es atómica con la firma final) | Diverge |
| 6.5.4 | Segregar funciones entre personas autorizadas; revocar la autoridad de inmediato | una cuenta posee seis roles, por lo que ejecución y auditoría se superponen; una aprobación contabilizada 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 activos en la implementación desplegada; isActive y la revocación del proveedor no hacen lo que sus nombres indican |
Diverge |
| Efectividad de los controles de cumplimiento | los controles de lista negra, congelación, lista blanca y KYC deben ser efectivos | el filtrado de KYC y la revocación del proveedor no son funcionales en el código desplegado | Diverge |
| 2.2.3 | Las monedas congeladas o destruidas siguen totalmente respaldadas y son reconciliables | la destrucción 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é requiere. Las operaciones de alto riesgo deben diseñarse de modo que ninguna parte pueda realizarlas de forma unilateral, por ejemplo mediante un protocolo de multi-firma, y la directriz enumera mitigantes adicionales como límites de velocidad y controles con retraso temporal (timelock).
Qué hace el contrato. Leído del authorizationMatrix del contrato de gobernanza (la matriz completa está en la tabla de la sección 1.2), las operaciones de suministro y de emergencia requieren cada una un solo rol, con un cupo 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 "ninguna parte unilateralmente". Tampoco hay un timelock: como se muestra en 1.2, una vez alcanzado el quórum, la acción se ejecuta en la misma transacción, así que un retraso temporal, uno de los mitigantes que nombra la directriz, también está ausente. El contrato sí implementa un límite de velocidad de suministro (whenWithinRiskThresholds), así que ese mitigante está presente, pero no sustituye al control multi-firma sobre las propias operaciones.
Párrafo 6.5.4: segregación de funciones y revocación inmediata
Qué requiere. Las diferentes operaciones deben segregarse entre distintas personas autorizadas, y la autoridad de una persona autorizada debe poder revocarse de inmediato.
Qué hace el contrato. Una sola cuenta de propiedad externa posee seis roles (emisión, congelación, administración de KYC, y los cuatro roles de auditor), por lo que los roles de ejecución y auditoría se superponen. Los propios cambios de rol también requieren solo el rol A más el rol B (ver 1.2), así que el mismo grupo pequeño decide quién está autorizado y puede ejecutar y actualizar. Y una firma ya contabilizada no se vuelve a validar si el rol del firmante se revoca posteriormente.
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 sigue contando para una ejecución posterior.
Párrafo 6.5.5: auditar cada cambio de código; correcto, consistente, sin vulnerabilidades
Qué requiere. Un tercero cualificado debe auditar los contratos inteligentes en cada cambio de código, y confirmar que están (i) implementados correctamente, (ii) son consistentes con la funcionalidad pretendida, y (iii) están 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 activos 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 todos modos. Como isActive y la revocación del proveedor no hacen lo que sus nombres indican, las condiciones (i) y (ii) no se cumplen; y con los defectos de la Parte 1 presentes en el código desplegado, la (iii) tampoco se cumple.
Efectividad de los controles de cumplimiento
Qué requiere. El modelo de ciclo de vida de la directriz (lista negra, congelación, lista blanca, KYC) presupone que esos controles son efectivos.
Qué hace el contrato. Como se muestra en 1.1, el filtrado de KYC y la revocación del proveedor 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 siguen totalmente respaldadas y son reconciliables
Qué requiere. Las stablecoins congeladas o destruidas por acción de cumplimiento deben seguir totalmente respaldadas, de modo que el suministro y las reservas puedan reconciliarse.
Qué hace el contrato. El flujo de remediación habitual para una stablecoin regulada es quemar los fondos en una dirección incorrecta y más adelante volver a emitir un importe equivalente a la víctima como una emisión separada (así funciona destroyBlackFunds más issue de USDT). La destrucción de HKDAP reduce _totalSupply, lo cual es una quema, pero nunca acredita balances[address(this)], emite una Transfer a address(this) en lugar de a address(0), y deja intactos los contadores de emisión neta que usa el límite de acuñación:
// 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 nunca se mantienen ahí (balances[address(this)] permanece 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 genera confusión en la propia remediación: como los tokens se queman en lugar de aparcarse, una nueva emisión a la víctima debe ser una acuñación nueva, pero la engañosa 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 nueva emisión separada sería tanto correcta como reconciliable. Tal como está escrito, la contabilidad en cadena de la que depende una reconciliación de reservas se desvía del verdadero estado de la cadena. Esto es una preocupación más que un pase limpio.
Limitamos estos hallazgos a lo que muestra la cadena. Si las reservas están totalmente respaldadas, si las claves residen en un HSM o en un entorno aislado (air-gapped), y si las transacciones se simulan fuera de la cadena antes de firmarse, no son cosas 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 previa a producción. Como stablecoin regulada, varias de sus propiedades en cadena entran en conflicto con cláusulas específicas de la directriz de la HKMA. Sobre la base de la evidencia en cadena, y dejando de lado los asuntos fuera de la cadena que no podemos ver, el contrato desplegado todavía no cumple el estándar que debería cumplir una stablecoin comercial.
De ello se derivan dos observaciones, y merece la pena plantearlas con claridad.
Primero, emitir en una cadena pública cambia dónde se decide el cumplimiento. Un requisito como "ninguna parte debe poder actuar unilateralmente" se satisface o no según las comprobaciones de rol en el código desplegado, y ese código es público. La implementación, más que la licencia o la documentación, es donde tal requisito realmente se cumple o se incumple, y cualquiera puede verificar cuál de las dos cosas ocurre.
Segundo, la etiqueta "Beta Access" no cambia el perfil de riesgo del código desplegado. El contrato está activo en la red principal de Ethereum, administrado por claves reales, y representa un derecho sobre dólares de Hong Kong. Por lo tanto, debería someterse a estándares de producción independientemente de cómo se etiquete.
Un hilo común subyace bajo los hallazgos específicos. La arquitectura reinventa, de forma a medida, primitivas que el ecosistema ya proporciona y ha auditado a gran escala: un motor de aprobación y una capa de roles construidos desde cero donde bastaría un multisig de Safe con AccessManager y TimelockController de OpenZeppelin; una colección de lista enlazada escrita a mano en lugar de EnumerableSet; un proxy modificado en lugar del estándar TransparentUpgradeableProxy; y un ERC-20 escrito a mano en lugar del de OpenZeppelin. La mayoría de los defectos de esta revisión residen en esa maquinaria personalizada, no en las partes que reutilizan componentes estándar. Se lee como un sistema abstraído a la manera del software de propósito general, en lugar de compuesto a partir de los bloques de construcción pequeños y auditados que favorece el desarrollo en cadena, donde cada capa de abstracción personalizada también es gas, superficie de ataque y riesgo de actualización. Un diseño construido a partir de 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 que a este sistema le faltan actualmente: una ventana de deliberación de un timelock, y roles legibles con nombre.
Los problemas descritos aquí son abordables. Restaurar la multi-firma 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 la red principal con el código fuente verificado es también lo que hizo posible esta revisión, y es la opción correcta por defecto. La revisión continua de seguridad y cumplimiento en cadena de este tipo es lo que hacemos en BlockSec, y estaremos encantados de ayudar.



