From 01f755976d49786593252597a6951ac83ef020e9 Mon Sep 17 00:00:00 2001 From: bosunUbuntu Date: Fri, 26 Sep 2025 15:16:01 +0100 Subject: [PATCH 1/4] feat: register kyc data in soroban contract --- services/stellar-wallet/src/config/envs.ts | 2 + services/stellar-wallet/src/index.ts | 2 + .../tests/routes/kyc-verify.test.ts | 288 ++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 services/stellar-wallet/tests/routes/kyc-verify.test.ts diff --git a/services/stellar-wallet/src/config/envs.ts b/services/stellar-wallet/src/config/envs.ts index 322273c..2ce4349 100644 --- a/services/stellar-wallet/src/config/envs.ts +++ b/services/stellar-wallet/src/config/envs.ts @@ -9,6 +9,8 @@ const envSchema = z.object({ PORT: z.coerce.number().default(3000), // Parses PORT to number and defaults to 3000 HORIZON_URL: z.url().default('https://horizon-testnet.stellar.org'), // Must be a valid URL SOROBAN_RPC_URL: z.url().default('https://soroban-testnet.stellar.org'), // Must be a valid URL + STELLAR_SECRET_KEY: z.string().min(1, 'STELLAR_SECRET_KEY is required'), // Deployer account secret key + SOROBAN_CONTRACT_ID: z.string().min(1, 'SOROBAN_CONTRACT_ID is required'), // Contract address from deployment }) // Validate and parse environment variables diff --git a/services/stellar-wallet/src/index.ts b/services/stellar-wallet/src/index.ts index 211b9ce..e895926 100644 --- a/services/stellar-wallet/src/index.ts +++ b/services/stellar-wallet/src/index.ts @@ -3,6 +3,7 @@ import express, { type NextFunction, type Request, type Response } from 'express import envs from './config/envs' import { authLimiter, kycLimiter, walletLimiter } from './middlewares/rate-limit' import { kycRouter } from './routes/kyc' +import { kycVerifyRouter } from './routes/kyc-verify' import { walletRouter } from './routes/wallet' export const app = express() @@ -21,6 +22,7 @@ app.post('/auth', authLimiter, (_req: Request, res: Response) => { }) app.use('/kyc', kycLimiter, kycRouter) +app.use('/kyc', kycLimiter, kycVerifyRouter) app.use('/wallet', walletLimiter, walletRouter) diff --git a/services/stellar-wallet/tests/routes/kyc-verify.test.ts b/services/stellar-wallet/tests/routes/kyc-verify.test.ts new file mode 100644 index 0000000..e70a5b0 --- /dev/null +++ b/services/stellar-wallet/tests/routes/kyc-verify.test.ts @@ -0,0 +1,288 @@ +import type sqlite3 from 'sqlite3' +import request from 'supertest' +import { closeDB, connectDB, initializeKycTable, run } from '../../src/db/kyc' + +// Create a clean app instance for testing without rate limiting +import express from 'express' +import { kycVerifyRouter } from '../../src/routes/kyc-verify' + +const testApp = express() +testApp.use(express.json()) +testApp.use('/kyc', kycVerifyRouter) + +// Mock the Soroban client module +const mockSorobanServer = { + getAccount: jest.fn(), + sendTransaction: jest.fn(), + getTransaction: jest.fn(), +} + +jest.mock('../../src/soroban/client', () => ({ + connectSoroban: jest.fn(() => mockSorobanServer), +})) + +// Mock Stellar SDK +const mockKeypair = { + publicKey: jest.fn().mockReturnValue('GCTEST...'), + sign: jest.fn(), +} + +const mockTransaction = { + sign: jest.fn(), +} + +const mockTransactionBuilder = { + addOperation: jest.fn().mockReturnThis(), + setTimeout: jest.fn().mockReturnThis(), + build: jest.fn(() => mockTransaction), +} + +const mockContract = { + call: jest.fn().mockReturnValue({}), +} + +jest.mock('@stellar/stellar-sdk', () => ({ + Keypair: { + fromSecret: jest.fn(() => mockKeypair), + }, + Contract: jest.fn(() => mockContract), + TransactionBuilder: jest.fn(() => mockTransactionBuilder), + nativeToScVal: jest.fn().mockReturnValue({}), + BASE_FEE: '100', + Networks: { TESTNET: 'Test SDF Network ; September 2015' }, +})) + +// Mock environment variables +jest.mock('../../src/config/envs', () => ({ + default: { + PORT: 3000, + STELLAR_SECRET_KEY: 'STEST...', + SOROBAN_CONTRACT_ID: 'CTEST...', + }, +})) + +// Use an in-memory DB for tests +jest.mock('../../src/db/kyc', () => { + const sqlite3Req: typeof import('sqlite3') = require('sqlite3') + const { promisify } = require('node:util') + + let db: sqlite3.Database | null = null + + const getDb = async (): Promise => { + if (!db) { + sqlite3Req.verbose() + db = new sqlite3Req.Database(':memory:') + const runAsync = promisify(db.run.bind(db)) as ( + sql: string, + params?: unknown[], + ) => Promise + await runAsync('PRAGMA foreign_keys = ON;') + } + return db + } + + const run = (database: sqlite3.Database, sql: string, params: unknown[] = []): Promise => { + const runAsync = promisify(database.run.bind(database)) as ( + sql: string, + params?: unknown[], + ) => Promise + return runAsync(sql, params) + } + + const all = ( + database: sqlite3.Database, + sql: string, + params: unknown[] = [], + ): Promise => { + const allAsync = promisify(database.all.bind(database)) as ( + sql: string, + params?: unknown[], + ) => Promise + return allAsync(sql, params) + } + + return { + connectDB: async (): Promise => getDb(), + + closeDB: async (): Promise => { + const instance = db + if (!instance) return + await new Promise((resolve, reject) => { + instance.close((err: Error | null) => (err ? reject(err) : resolve())) + }) + db = null + }, + + initializeKycTable: async (conn?: sqlite3.Database): Promise => { + const database = conn ?? (await getDb()) + const runAsync = promisify(database.run.bind(database)) as ( + sql: string, + params?: unknown[], + ) => Promise + + await runAsync(` + CREATE TABLE IF NOT EXISTS kyc ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + document TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + ); + `) + await runAsync('CREATE UNIQUE INDEX IF NOT EXISTS idx_kyc_document ON kyc (document);') + }, + + findKycById: async (database: sqlite3.Database, id: number) => { + const rows = await all(database, 'SELECT * FROM kyc WHERE id = ? LIMIT 1;', [id]) + return rows.length ? rows[0] : null + }, + + run, + all, + } +}) + +beforeAll(async () => { + const db = await connectDB() + await initializeKycTable(db) +}) + +afterAll(async () => { + await closeDB() +}) + +describe('POST /kyc/verify', () => { + beforeEach(async () => { + jest.clearAllMocks() + + // Reset all mocks to clean state + mockSorobanServer.getAccount.mockReset() + mockSorobanServer.sendTransaction.mockReset() + mockSorobanServer.getTransaction.mockReset() + + // Setup successful Soroban mocks + mockSorobanServer.getAccount.mockResolvedValue({}) + mockSorobanServer.sendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'txhash123', + }) + mockSorobanServer.getTransaction + .mockResolvedValueOnce({ status: 'NOT_FOUND' }) + .mockResolvedValueOnce({ status: 'SUCCESS' }) + + // Insert test KYC record + const db = await connectDB() + await run(db, 'DELETE FROM kyc WHERE id = 1') // Clean up + await run(db, 'INSERT INTO kyc (id, name, document, status) VALUES (?, ?, ?, ?)', [ + 1, + 'John Doe', + 'ABC12345', + 'pending', + ]) + }) + + it('should successfully register KYC data and return 201', async () => { + const requestBody = { + kyc_id: '1', + name: 'John Doe', + document: 'ABC12345', + } + + const response = await request(testApp).post('/kyc/verify').send(requestBody).expect(201) + + expect(response.body).toHaveProperty('kyc_id', '1') + expect(response.body).toHaveProperty('data_hash') + expect(response.body).toHaveProperty('status', 'approved') + expect(response.body.data_hash).toMatch(/^[a-f0-9]{64}$/) // SHA-256 hash format + + // Verify contract was called + expect(mockContract.call).toHaveBeenCalledWith( + 'register_kyc', + {}, // mocked nativeToScVal return + {}, // mocked nativeToScVal return + {}, // mocked nativeToScVal return + ) + + // Verify database was updated + const db = await connectDB() + const updatedRecord = await run(db, 'SELECT status FROM kyc WHERE id = 1') + // Note: run doesn't return data, so we'd need to use all() to verify + }) + + it('should return 400 for missing kyc_id', async () => { + const requestBody = { + name: 'John Doe', + document: 'ABC12345', + } + + const response = await request(testApp).post('/kyc/verify').send(requestBody).expect(400) + + expect(response.body).toEqual({ + error: 'kyc_id is required and must be a string', + }) + }) + + it('should return 400 for invalid KYC data', async () => { + const requestBody = { + kyc_id: '1', + name: 'A', // Too short + document: '123', // Too short + } + + const response = await request(testApp).post('/kyc/verify').send(requestBody).expect(400) + + expect(response.body.error).toContain('name must be at least 2 characters') + }) + + it('should return 400 for invalid kyc_id', async () => { + const requestBody = { + kyc_id: '999', + name: 'John Doe', + document: 'ABC12345', + } + + const response = await request(testApp).post('/kyc/verify').send(requestBody).expect(400) + + expect(response.body).toEqual({ error: 'Invalid kyc_id' }) + }) + + it('should return 500 for contract call failure', async () => { + // Override to simulate contract call failure + mockSorobanServer.sendTransaction.mockResolvedValue({ + status: 'ERROR', + errorResult: 'Contract error', + }) + + const requestBody = { + kyc_id: '1', + name: 'John Doe', + document: 'ABC12345', + } + + const response = await request(testApp).post('/kyc/verify').send(requestBody).expect(500) + + expect(response.body).toEqual({ error: 'Failed to register KYC' }) + }) + + it('should return 500 for transaction failure', async () => { + // Override mocks to simulate transaction failure scenario + mockSorobanServer.sendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'txhash123', + }) + // Clear the chained mocks and set up failure + mockSorobanServer.getTransaction.mockReset() + mockSorobanServer.getTransaction.mockResolvedValue({ + status: 'FAILED', + }) + + const requestBody = { + kyc_id: '1', + name: 'John Doe', + document: 'ABC12345', + } + + const response = await request(testApp).post('/kyc/verify').send(requestBody).expect(500) + + expect(response.body).toEqual({ error: 'Failed to register KYC' }) + }) +}) From 7a9cc21955a0719026f83f3d20fddf65cb2db408 Mon Sep 17 00:00:00 2001 From: bosunUbuntu Date: Fri, 26 Sep 2025 15:26:18 +0100 Subject: [PATCH 2/4] feat: register kyc data in soroban contract --- .../stellar-wallet/src/routes/kyc-verify.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 services/stellar-wallet/src/routes/kyc-verify.ts diff --git a/services/stellar-wallet/src/routes/kyc-verify.ts b/services/stellar-wallet/src/routes/kyc-verify.ts new file mode 100644 index 0000000..8e5ce43 --- /dev/null +++ b/services/stellar-wallet/src/routes/kyc-verify.ts @@ -0,0 +1,109 @@ +import { createHash } from 'node:crypto' +import { Router, type Request, type Response } from 'express' +import * as StellarSdk from '@stellar/stellar-sdk' +import { connectDB, findKycById, run } from '../db/kyc' +import { validateKycData } from '../kyc/validate' +import { connectSoroban } from '../soroban/client' +import envs from '../config/envs' + +export const kycVerifyRouter = Router() + +interface VerifyKycRequest { + kyc_id: string + name: string + document: string +} + +interface VerifyKycResponse { + kyc_id: string + data_hash: string + status: string +} + +/** + * POST /kyc/verify - Register KYC data in Soroban contract and update database + */ +kycVerifyRouter.post('/verify', async (req: Request, res: Response) => { + try { + const { kyc_id, name, document } = req.body as VerifyKycRequest + + // Validate required fields + if (!kyc_id || typeof kyc_id !== 'string') { + return res.status(400).json({ error: 'kyc_id is required and must be a string' }) + } + + // Validate KYC data using existing validator + const validation = validateKycData({ name, document }) + if (!validation.isValid) { + return res.status(400).json({ error: validation.errors.join(', ') }) + } + + // Connect to database and verify kyc_id exists + const db = await connectDB() + const kycRecord = await findKycById(db, parseInt(kyc_id)) + if (!kycRecord) { + return res.status(400).json({ error: 'Invalid kyc_id' }) + } + + // Generate hash of KYC data + const kycDataString = JSON.stringify({ name: validation.data!.name, document: validation.data!.document }) + const dataHash = createHash('sha256').update(kycDataString).digest('hex') + + // Connect to Soroban and prepare contract call + const sorobanServer = connectSoroban() + const sourceKeypair = StellarSdk.Keypair.fromSecret(envs.STELLAR_SECRET_KEY) + const sourceAccount = await sorobanServer.getAccount(sourceKeypair.publicKey()) + + // Build contract call transaction + const contract = new StellarSdk.Contract(envs.SOROBAN_CONTRACT_ID) + const operation = contract.call( + 'register_kyc', + StellarSdk.nativeToScVal(kyc_id, { type: 'string' }), + StellarSdk.nativeToScVal(dataHash, { type: 'string' }), + StellarSdk.nativeToScVal('approved', { type: 'string' }) + ) + + const transaction = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation(operation) + .setTimeout(30) + .build() + + // Sign and submit transaction + transaction.sign(sourceKeypair) + const response = await sorobanServer.sendTransaction(transaction) + + if (response.status === 'ERROR') { + throw new Error(`Contract call failed: ${response.errorResult}`) + } + + // Wait for transaction confirmation + let txResponse = await sorobanServer.getTransaction(response.hash) + while (txResponse.status === 'NOT_FOUND') { + await new Promise(resolve => setTimeout(resolve, 1000)) + txResponse = await sorobanServer.getTransaction(response.hash) + } + + if (txResponse.status !== 'SUCCESS') { + throw new Error(`Transaction failed: ${txResponse.status}`) + } + + // Update database status to approved + await run(db, 'UPDATE kyc SET status = ? WHERE id = ?', ['approved', parseInt(kyc_id)]) + + // Return success response + const verifyResponse: VerifyKycResponse = { + kyc_id, + data_hash: dataHash, + status: 'approved' + } + + return res.status(201).json(verifyResponse) + + } catch (error) { + console.error('KYC verification error:', error) + return res.status(500).json({ error: 'Failed to register KYC' }) + } +}) \ No newline at end of file From f8c4ed5538f61365c55f37c17571e4e7336a562e Mon Sep 17 00:00:00 2001 From: bosunUbuntu Date: Fri, 26 Sep 2025 15:39:27 +0100 Subject: [PATCH 3/4] update .env.example --- services/stellar-wallet/.env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/stellar-wallet/.env.example b/services/stellar-wallet/.env.example index 1a057e6..ce2ba26 100644 --- a/services/stellar-wallet/.env.example +++ b/services/stellar-wallet/.env.example @@ -10,3 +10,6 @@ ENCRYPTION_KEY= # Rate limiting settings RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX=100 + +STELLAR_SECRET_KEY=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +SOROBAN_CONTRACT_ID=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \ No newline at end of file From b113d60fc708c3736e0c81db67340848a9fd6dda Mon Sep 17 00:00:00 2001 From: bosunUbuntu Date: Sun, 28 Sep 2025 01:54:55 +0100 Subject: [PATCH 4/4] fix workflow errors --- services/stellar-wallet/src/config/envs.ts | 10 ++++++++-- services/stellar-wallet/src/routes/kyc-verify.ts | 14 ++++++++------ .../stellar-wallet/tests/routes/kyc-verify.test.ts | 11 ++++++----- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/services/stellar-wallet/src/config/envs.ts b/services/stellar-wallet/src/config/envs.ts index 2ce4349..86c5dbf 100644 --- a/services/stellar-wallet/src/config/envs.ts +++ b/services/stellar-wallet/src/config/envs.ts @@ -9,8 +9,14 @@ const envSchema = z.object({ PORT: z.coerce.number().default(3000), // Parses PORT to number and defaults to 3000 HORIZON_URL: z.url().default('https://horizon-testnet.stellar.org'), // Must be a valid URL SOROBAN_RPC_URL: z.url().default('https://soroban-testnet.stellar.org'), // Must be a valid URL - STELLAR_SECRET_KEY: z.string().min(1, 'STELLAR_SECRET_KEY is required'), // Deployer account secret key - SOROBAN_CONTRACT_ID: z.string().min(1, 'SOROBAN_CONTRACT_ID is required'), // Contract address from deployment + STELLAR_SECRET_KEY: + process.env.NODE_ENV === 'test' + ? z.string().default('STEST_MOCK_KEY_FOR_TESTING') + : z.string().min(1, 'STELLAR_SECRET_KEY is required'), + SOROBAN_CONTRACT_ID: + process.env.NODE_ENV === 'test' + ? z.string().default('CTEST_MOCK_CONTRACT_FOR_TESTING') + : z.string().min(1, 'SOROBAN_CONTRACT_ID is required'), }) // Validate and parse environment variables diff --git a/services/stellar-wallet/src/routes/kyc-verify.ts b/services/stellar-wallet/src/routes/kyc-verify.ts index 8e5ce43..51ca69c 100644 --- a/services/stellar-wallet/src/routes/kyc-verify.ts +++ b/services/stellar-wallet/src/routes/kyc-verify.ts @@ -46,7 +46,10 @@ kycVerifyRouter.post('/verify', async (req: Request, res: Response) => { } // Generate hash of KYC data - const kycDataString = JSON.stringify({ name: validation.data!.name, document: validation.data!.document }) + const kycDataString = JSON.stringify({ + name: validation.data!.name, + document: validation.data!.document, + }) const dataHash = createHash('sha256').update(kycDataString).digest('hex') // Connect to Soroban and prepare contract call @@ -60,7 +63,7 @@ kycVerifyRouter.post('/verify', async (req: Request, res: Response) => { 'register_kyc', StellarSdk.nativeToScVal(kyc_id, { type: 'string' }), StellarSdk.nativeToScVal(dataHash, { type: 'string' }), - StellarSdk.nativeToScVal('approved', { type: 'string' }) + StellarSdk.nativeToScVal('approved', { type: 'string' }), ) const transaction = new StellarSdk.TransactionBuilder(sourceAccount, { @@ -82,7 +85,7 @@ kycVerifyRouter.post('/verify', async (req: Request, res: Response) => { // Wait for transaction confirmation let txResponse = await sorobanServer.getTransaction(response.hash) while (txResponse.status === 'NOT_FOUND') { - await new Promise(resolve => setTimeout(resolve, 1000)) + await new Promise((resolve) => setTimeout(resolve, 1000)) txResponse = await sorobanServer.getTransaction(response.hash) } @@ -97,13 +100,12 @@ kycVerifyRouter.post('/verify', async (req: Request, res: Response) => { const verifyResponse: VerifyKycResponse = { kyc_id, data_hash: dataHash, - status: 'approved' + status: 'approved', } return res.status(201).json(verifyResponse) - } catch (error) { console.error('KYC verification error:', error) return res.status(500).json({ error: 'Failed to register KYC' }) } -}) \ No newline at end of file +}) diff --git a/services/stellar-wallet/tests/routes/kyc-verify.test.ts b/services/stellar-wallet/tests/routes/kyc-verify.test.ts index e70a5b0..732a323 100644 --- a/services/stellar-wallet/tests/routes/kyc-verify.test.ts +++ b/services/stellar-wallet/tests/routes/kyc-verify.test.ts @@ -1,6 +1,6 @@ import type sqlite3 from 'sqlite3' import request from 'supertest' -import { closeDB, connectDB, initializeKycTable, run } from '../../src/db/kyc' +import { closeDB, connectDB, initializeKycTable, run, all } from '../../src/db/kyc' // Create a clean app instance for testing without rate limiting import express from 'express' @@ -194,7 +194,7 @@ describe('POST /kyc/verify', () => { expect(response.body).toHaveProperty('status', 'approved') expect(response.body.data_hash).toMatch(/^[a-f0-9]{64}$/) // SHA-256 hash format - // Verify contract was called + // Verify contract was called with correct parameters expect(mockContract.call).toHaveBeenCalledWith( 'register_kyc', {}, // mocked nativeToScVal return @@ -202,10 +202,11 @@ describe('POST /kyc/verify', () => { {}, // mocked nativeToScVal return ) - // Verify database was updated + // Verify database status was actually updated const db = await connectDB() - const updatedRecord = await run(db, 'SELECT status FROM kyc WHERE id = 1') - // Note: run doesn't return data, so we'd need to use all() to verify + const rows = await all(db, 'SELECT status FROM kyc WHERE id = 1') + expect(rows).toHaveLength(1) + expect(rows[0]).toEqual({ status: 'approved' }) }) it('should return 400 for missing kyc_id', async () => {