Laboratorio funcional de KYC + wallet no-custodial + Claimable Balance sobre Stellar, implementado con Node.js + TypeScript + Fastify y arquitectura hexagonal.
El objetivo es validar el dominio completo (alta de usuario → KYC → envío a usuario existente o a tag sin cuenta → reclamo → expiración/reverso → reconciliación) en local, sin fondos reales.
┌──────────────────────────────────────────────────────────────────────────────┐
│ CLIENTE │
│ ┌──────────────┐ │
│ │ UI Web/Móvil│ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
┌──────────────────────────────────────────────────────────────────────────────┐
│ API LAYER (interfaces/ - Fastify) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Rutas │──▶│ Zod │──▶│ DTOs │ │
│ │ Fastify │ Validación │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER (application/ - Casos de uso) │
│ KYCUseCases ClaimUseCases TransferUseCases LedgerUseCases │
│ UserUseCases FeeUseCases ExpirationJob ReconciliationJob │
└──────────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER (domain/ - Puertos y entidades) │
│ Puerto DiditClient Puerto ClaimReleaser Puerto UserRepository │
│ Puerto WalletRepo Puerto LedgerRepo KYCVerification │
│ Claim Transfer │
└──────────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER (infrastructure/ - Adaptadores) │
│ DiditHTTPClient DiditMockClient PostgresKYCVerificationRepository │
│ PostgresUserRepo PostgresWalletRepo PostgresLedgerService │
│ StellarHorizonClient PostgresWebhookEventRepository │
└──────────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ EXTERNAL SERVICES │
│ Didit API (verification.didit.me/v3) Stellar Horizon Testnet │
│ Friendbot PostgreSQL 15+ │
└──────────────────────────────────────────────────────────────────────────────┘
Regla de dependencia: interfaces → application → domain, y infrastructure → domain.
El dominio NUNCA importa: pg, fastify, stellar-sdk, didit-sdk.
┌─────────────────────────────────────────────────────────────────────────────┐
│ src/ - Arquitectura Hexagonal │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ interfaces/ │ │ application/ │ │ domain/ │ │ infrastructure│ │
│ │ (Fastify) │ │ (Casos de │ │ (Puertos y │ │ (Adaptadores)│ │
│ │ Rutas, │ │ uso) │ │ entidades) │ │ Postgres, │ │
│ │ Zod, DTOs │ │ │ │ │ │ Didit, Stellar│ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Depende de Depende de No depende de Depende de │
│ application domain ningún framework domain │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Regla de dependencia:
interfaces → application → domain
infrastructure → domain
El dominio NUNCA importa: pg, fastify, stellar-sdk, didit-sdk
Usuario ──▶ POST /kyc/start ──▶ KYCUseCases ──▶ PostgresKYCRepo
│ │
│ (sesión activa?) │
│ │
▼ ▼
DiditHTTPClient Sesión existente
│ │
▼ ▼
POST /v3/session/ ┌──────────────┐
(x-api-key, body) │ {verificationId, url} │
│ │
▼ ▼
Didit API responde Usuario recibe URL
{session_id, url} │
│ ▼
▼ Usuario abre URL en Didit
KYCUseCases guarda │
en BD ▼
│ Didit envía webhook
▼ POST /webhooks/didit
actualizarKycStatus │
(approved/declined) ▼
│ verificarFirma()
▼ │
actualizarDatosKYC ├─▶ Firma válida ──▶ procesarWebhook()
(OCR) │ │
│ │ ▼
▼ │ obtenerPorDiditSessionId()
activarWalletsPendientes │ │
dispararClaimsPendientes │ status=Approved?
│ │ │
▼ │ ┌───┴───┴───┐
Responder 200 {url} │ │ │
│ │ Declined In Review
▼ │ │ │
Usuario recibe URL │ ▼ ▼
│ │ kyc_status='declined'
▼ │ kyc_status='in_review'
Fin │ │
│ ▼
│ actualizarKycStatus()
│ │
└──────────────────┘
Usuario ──▶ POST /transfers/claim ──▶ ClaimUseCases ──▶ Validar KYC = approved
│ │
▼ ▼
Validar destinatario Crear ledger (suma cero)
NO existe como user Crear transfer (pending_claim)
│ │
▼ ▼
Generar claimToken Crear claim (token_hash, amount, asset)
│ │
▼ ▼
Responder 200 {claimToken} Destinatario POST /claims/:token/redeem
│ │
▼ ▼
Fin ClaimUseCases.validar KYC = approved
│
▼
StellarHorizonClient.crearClaimableBalanceSponsored()
│
▼
Horizon.loadAccount(sponsorPublicKey)
│
▼
¿Cuenta existe? ──No──▶ Friendbot fondea
│Sí
▼
¿USDC? ──No──▶ XLM nativo
│Sí
▼
¿Claimant tiene trustline? ──No──▶ crearTrustline()
│Sí
▼
Horizon.submitTransaction(createClaimableBalance)
│
▼
{balanceId, txHash}
│
▼
actualizarClaim(claimed_onchain)
actualizarTransfer(claimed_onchain)
│
▼
Responder 200 {status: completed}
POST /transfers
│
▼
Validar KYC sender = approved
│
▼
┌─────┴─────┐
│ │
Sí No
│ │
▼ ▼
Destinatario Destinatario NO existe como usuario
existe │
│ ▼
▼ Crear Claimable Balance
Transferencia │
directa ▼
a usuario Ledger grupo suma cero
│ │
▼ ▼
Grupo asientos Transfer pending_claim
suma cero │
│ ▼
▼ Generar claimToken
Fin │
▼
Responder claimToken
│
▼
Fin
Nota: Los diagramas anteriores usan formato ASCII para garantizar que se rendericen correctamente en cualquier visor de Markdown, incluido GitHub.
| Capa | Tecnología | Versión |
|---|---|---|
| Lenguaje | TypeScript | 5.5 |
| Runtime | Node.js | 22 |
| HTTP Framework | Fastify | 4 |
| Base de Datos | PostgreSQL | 15+ |
| ORM / Query | pg (node-postgres) | 8 |
| Tests | Vitest | 2 |
| Blockchain SDK | @stellar/stellar-sdk | 17.4.0 |
| Validación | Zod | 3 |
| KYC API | Didit v3 | REST |
- Node.js 22+
- PostgreSQL 15+ (o Docker)
Opción A — Docker:
docker compose up -d # Postgres 15 en puerto 5432Opción B — PostgreSQL local:
sudo -u postgres psql -c "CREATE USER labkyc WITH PASSWORD 'labkyc' SUPERUSER;"
sudo -u postgres createdb -O labkyc labkyccp .env.example .env| Variable | Default local | Para qué sirve |
|---|---|---|
DATABASE_URL |
postgresql://labkyc:labkyc@localhost:5433/labkyc |
Conexión a Postgres |
APP_HOST |
0.0.0.0 |
Host del servidor |
APP_PORT |
8000 |
Puerto del servidor |
APP_ENV |
local |
Entorno de ejecución |
APP_URL |
http://localhost:8000 |
Callback URL para Didit |
SECRET_KEY |
local_dev_secret_key... |
Clave secreta local |
DIDIT_API_KEY |
— | API key de Didit (requerido si MOCK_DIDIT !== "true") |
DIDIT_WORKFLOW_ID |
— | UUID del workflow de Didit |
DIDIT_WEBHOOK_SECRET |
— | Secreto HMAC del webhook de Didit |
DIDIT_API_BASE_URL |
https://verification.didit.me/v3 |
Base URL de Didit API |
TURNKEY_API_BASE_URL |
https://api.turnkey.com |
Base URL de Turnkey |
TURNKEY_API_KEY |
mock | API key de Turnkey |
STELLAR_HORIZON_URL |
https://horizon-testnet.stellar.org |
URL de Horizon Testnet |
STELLAR_NETWORK_PASSPHRASE |
Test SDF Network ; September 2015 |
Passphrase de red Stellar |
STELLAR_SPONSOR_PUBLIC_KEY |
— | Public key del sponsor (Testnet) |
STELLAR_SPONSOR_SECRET_KEY |
— | Secret key del sponsor (no subir al repo) |
STELLAR_SENDER_PUBLIC_KEY |
— | Public key del sender/claimant |
STELLAR_SENDER_SECRET_KEY |
— | Secret key del sender/claimant (no subir al repo) |
STELLAR_USDC_ISSUER |
GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 |
Emisor del asset USDC |
STELLAR_USDC_ASSET_CODE |
USDC |
Código del asset USDC |
MOCK_STELLAR |
true |
Usar cliente simulado de Stellar |
MOCK_DIDIT |
true |
"true" = DiditMockClient. Otro valor = DiditHTTPClient |
RUN_STELLAR_INTEGRATION |
— | true para tests reales contra Testnet |
CLAIM_TTL_DAYS |
30 |
Vigencia del claim |
CLAIM_TOKEN_BYTES |
32 |
Largo del token plano del claim |
PENDING_LIMIT_USD |
200 |
Tope de envíos a tags sin cuenta |
⚠️ El archivo.envNO se sube a GitHub. Contiene secretos.
psql "$DATABASE_URL" -f migrations/001_init.sqlVerificar invariante del ledger sobre BD vacía:
psql "$DATABASE_URL" -c "SELECT count(*) FROM check_ledger_invariants();" # debe dar 0npm install
npm run dev # tsx watch src/interfaces/main.ts
npm test # suite completa
npm run build # tsc -> dist/| Método | Ruta | Descripción |
|---|---|---|
POST |
/users |
Registra usuario (tag único, compliance profile 1:1) |
POST |
/kyc/start |
Crea sesión de verificación en Didit |
POST |
/webhooks/didit |
Webhook de Didit (HMAC-SHA256 X-Signature-V2 + X-Timestamp ≤ 300s) |
POST |
/wallets |
Crea wallet no-custodial |
GET |
/wallets/me?userId= |
Wallet del usuario |
POST |
/ledger/grupo |
Grupo de asientos balanceado (uso interno/debug) |
POST |
/transfers |
Transferencia P2P a usuario existente |
POST |
/transfers/claim |
Transferencia a tag sin usuario (Claimable Balance) |
GET |
/claims/:token |
Vista pública del claim |
POST |
/claims/:token/redeem |
Registra/vincula usuario y arranca KYC |
POST |
/fees/calculate |
Calcula fee según fee_configs |
POST |
/jobs/expiracion |
Dispara job de expiración y reverso |
POST |
/jobs/reconciliacion |
Dispara job de reconciliación on-chain |
GET |
/health |
Health check |
El proyecto soporta dos modos de operación:
Usa DiditMockClient. Ideal para desarrollo local y tests sin credenciales de Didit.
Usa DiditHTTPClient contra la API real de Didit.
Variables requeridas en .env:
DIDIT_API_KEY— API key de Didit (headerx-api-key)DIDIT_WORKFLOW_ID— UUID del workflow de verificaciónDIDIT_WEBHOOK_SECRET— Shared secret para verificar firmas HMAC-SHA256DIDIT_API_BASE_URL—https://verification.didit.me/v3
Flujo KYC:
POST /kyc/startcrea sesión en Didit y retornaurl- Usuario completa verificación en Didit (hosted session)
- Didit envía
POST /webhooks/diditconX-Signature-V2yX-Timestamp - Si
status: "Approved", se actualizakyc_status = 'approved', se extraen datos OCR y se activan wallets - Se disparan claims pendientes asociados al usuario
npm run test -- tests/stellar.integration.test.tsUsa StellarMockClient. Valida:
- Crear Claimable Balance patrocinado (mock)
- Reclamar Claimable Balance (mock)
- Obtener estado de transacción (mock)
- Verificar existencia de Claimable Balance (mock)
- Generar keypairs válidos
Resultado: 10 tests pasando.
RUN_STELLAR_INTEGRATION=true npm run test -- tests/stellar.integration.test.tsTests contra Horizon Testnet real con cuentas configuradas en .env.
Resultado: 12 tests pasando (cuando el sponsor tiene fondos USDC suficientes).
npm testEjecuta todos los archivos de test con Vitest.
| Archivo | Tests | Descripción |
|---|---|---|
tests/invariants.test.ts |
10 | Invariantes del ledger (suma cero) |
tests/stellar.integration.test.ts |
12 | Stellar mock + integración real Testnet |
tests/claimRedemption.test.ts |
7 | Redención de claims |
tests/e2e.test.ts |
2 | Flujo end-to-end completo |
tests/kyc.test.ts |
6 | Flujo de KYC con Didit |
tests/ledger.test.ts |
4 | Operaciones del ledger |
tests/claims.test.ts |
3 | Creación de claims |
tests/transfers.test.ts |
4 | Transferencias P2P |
tests/fees.test.ts |
7 | Cálculo de fees |
tests/expiration.test.ts |
2 | Jobs de expiración |
tests/reconciliation.test.ts |
2 | Jobs de reconciliación |
tests/users.test.ts |
5 | Gestión de usuarios |
Resultado esperado: 64/64 tests pasando.
| Prueba | Tests | Estado | Notas |
|---|---|---|---|
| Opción 1 — Mock/Unitario | 10/10 | ✅ Pasando | Sin red, sin .env |
| Opción 2 — Integración REAL XLM | 2/2 | ✅ Pasando | Claimable Balance creado y reclamado en Testnet |
| Opción 2 — Integración REAL USDC | 1/1 | ✅ Pasando | Requiere sponsor con saldo USDC |
| Opción 3 — Suite completa | 64/64 | ✅ Pasando | Todas las pruebas verdes |
| KYC / Didit (mock) | 6/6 | ✅ Pasando | Tests unitarios de KYC |
| Didit API real (prueba directa) | 1/1 | ✅ Pasando | Sesión creada correctamente |
Todos los tests de integración REAL se ejecutaron contra Stellar Testnet y son verificables en el explorador público:
| Cuenta | Link StellarExpert | Rol |
|---|---|---|
| Sponsor | https://stellar.expert/explorer/testnet/account/GC2M6KWINT6OPRVKXB5K3JRTJ5IPAEYDODAQPF6VST7VCFFBJ3SZWYFA | Paga fees y crea Claimable Balances |
| Sender/Claimant | https://stellar.expert/explorer/testnet/account/GBA7WPRVUR3Q2QMSHWU47AE47X74QKVV6WFTXW4SQUULENII2EFYED63 | Reclama Claimable Balances |
| Transacción | Link StellarExpert | Descripción |
|---|---|---|
| Crear Claimable Balance | https://stellar.expert/explorer/testnet/tx/267ca42404975c9ba2227ea954b62c32523ec18205278de3a8a13ff9bf047f91 | createClaimableBalance de 5 USDC patrocinado |
| Reclamar Claimable Balance | https://stellar.expert/explorer/testnet/tx/94c96e7718786e478874d3a3c2dc87f7b31e411896004fd113ff9bf683c53f94 | claimClaimableBalance por el claimant |
| balanceId | Link StellarExpert | Estado |
|---|---|---|
0000000017031d7a444e95e0a6065193be13a91ffc3cee7402840c5042940493bb1cea2a |
https://stellar.expert/explorer/testnet/claimable-balance/0000000017031d7a444e95e0a6065193be13a91ffc3cee7402840c5042940493bb1cea2a | Reclamado ✅ |
- Claimant USDC: 40.0000000 → 45.0000000 (+5 USDC recibidos)
- Claimant XLM: 9999.9999700 (sin cambios — no pagó gas de red)
- Claimable Balance: reclamado y ya no disponible en la cuenta del sponsor
✅ Sesión creada exitosamente en Didit:
sessionId: 5e608140-e20f-4885-9c4b-389cfdde777a
url: https://verify.didit.me/session/_UhrZPdnbNx2
El método crearClaimableBalanceSponsored tenía declaraciones duplicadas:
- Línea 36:
const sponsorKp = Keypair.fromSecret(sponsorSecretKey); - Línea 54:
const sponsorKp = Keypair.fromSecret(sponsorSecretKey);← duplicado - Línea 55:
const sponsorAcc = await this.horizon.loadAccount(sponsorKp.publicKey()); - Línea 64:
const sponsorAcc = await this.horizon.loadAccount(...);← duplicado
Se eliminaron las líneas duplicadas 54 y 64. Ahora sponsorKp y sponsorAcc se declaran una sola vez.
Archivo modificado: src/infrastructure/stellarHorizonClient.ts
El método establecerTrustline usaba claimantAddress (variable inexistente). Ahora usa correctamente claimantKp.publicKey().
Las cuentas se configuran exclusivamente en .env (local, no se sube al repositorio):
| Variable | Uso |
|---|---|
STELLAR_SPONSOR_PUBLIC_KEY |
Public key del sponsor |
STELLAR_SPONSOR_SECRET_KEY |
Secret key del sponsor (no subir al repo) |
STELLAR_SENDER_PUBLIC_KEY |
Public key del sender/claimant |
STELLAR_SENDER_SECRET_KEY |
Secret key del sender/claimant (no subir al repo) |
STELLAR_USDC_ISSUER |
Emisor del asset USDC |
STELLAR_USDC_ASSET_CODE |
Código del asset USDC |
El sponsor debe tener:
- ✅ Trustline USDC establecida con el issuer configurado
- ✅ Saldo USDC suficiente (≥ 5 USDC por test)
Si el sponsor se queda sin USDC, el test falla con op_underfunded.
| Error | Causa | Solución |
|---|---|---|
op_no_trust |
El sponsor no tiene trustline USDC | Establecer trustline hacia el issuer USDC |
op_underfunded |
El sponsor no tiene saldo USDC | Fondear la cuenta con más USDC |
401 Unauthorized (Didit) |
Credenciales de Didit inválidas | Obtener nuevas credenciales del MCP o Didit Console |
Cuenta no apareció |
Friendbot no respondió | Los tests actuales usan cuentas existentes, saltan Friendbot |
| Errores de TypeScript preexistentes | predicate, sponsorSecretKey, claimantAddress |
Son preexistentes, no relacionados con el fix |
- No-custodia siempre —
wallets.custody_typees constantenon_custodial - El destinatario no existe hasta el reclamo — enviar a un tag sin cuenta no crea fila en
users - KYC es la puerta del claim — sin
kyc_status = 'approved'no se ejecutaclaimClaimableBalance - Doble asiento con suma cero — cada operación genera ≥2 asientos; si
credit - debit != 0, se revierte todo el grupo - Idempotencia obligatoria —
webhook_events (provider, event_id)ytransfers.idempotency_key - Firma antes que lógica — el webhook se persiste siempre (auditoría) pero con firma inválida no se ejecuta lógica
- Token plano del claim una sola vez — en DB solo
claims.claim_token_hash(SHA-256) - Estados de transfer —
pending → pending_claim → claimed_onchain → completed(ofailed/reversed)
| Fecha | Cambio | Archivo |
|---|---|---|
| 2026-11-09 | Fix: eliminar declaraciones duplicadas de sponsorKp y sponsorAcc |
src/infrastructure/stellarHorizonClient.ts |
| 2026-11-09 | Fix: corregir claimantAddress → claimantKp.publicKey() en establecerTrustline |
src/infrastructure/stellarHorizonClient.ts |
| 2026-11-09 | Adaptar test de integración para usar variables de entorno (sin Friendbot) | tests/stellar.integration.test.ts |
| 2026-11-09 | Ajustar expectativas de test USDC | tests/stellar.integration.test.ts |
| 2026-11-09 | Integrar DiditHTTPClient para KYC real con verificación X-Timestamp |
src/interfaces/routes/kyc.ts, src/application/kyc.ts |
| 2026-11-09 | Documentar 3 opciones de prueba, cuentas de Testnet y evidencia StellarExpert | README.md |
| 2026-11-09 | Reestructurar README con diagramas de arquitectura y últimos hashes de prueba | README.md |
Integración real con Pollar, on/off-ramp reales, frontend, KYC tiers dinámicos, multi-chain, notificaciones transaccionales y panel de administración.
- 64/64 tests pasando
- Stellar Testnet: Claimable Balance USDC y XLM creado y reclamado
- Didit API: Sesión creada correctamente contra
verification.didit.me/v3 - README sin secretos expuestos
- Evidencia en StellarExpert con links verificables
- Diagramas de arquitectura por sección
-
.envno se sube al repositorio