diff --git a/.env.example b/.env.example index 6a2abdf..58b1469 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,8 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_SECURE=false + +# Stellar & Deposit Accounts +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org +DEPOSIT_ACCOUNT_ENCRYPTION_KEY= + diff --git a/eslint.config.cjs b/eslint.config.cjs index edcafd9..0d3b837 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -27,6 +27,7 @@ module.exports = [ 'dist/**', 'build/**', 'coverage/**', + 'tests/**', '**/*.d.ts', 'eslint.config.cjs', ], diff --git a/prisma/migrations/20260728064714_add_deposit_account/migration.sql b/prisma/migrations/20260728064714_add_deposit_account/migration.sql new file mode 100644 index 0000000..2e3104a --- /dev/null +++ b/prisma/migrations/20260728064714_add_deposit_account/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "PaymentConfirmation" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "payerAddress" TEXT NOT NULL, + "txHash" TEXT, + "idempotencyKey" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PaymentConfirmation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DepositAccount" ( + "id" TEXT NOT NULL, + "address" TEXT NOT NULL, + "encryptedSecret" TEXT NOT NULL, + "invoiceId" TEXT, + "inUse" BOOLEAN NOT NULL DEFAULT false, + "lastUsedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DepositAccount_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PaymentConfirmation_idempotencyKey_key" ON "PaymentConfirmation"("idempotencyKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "DepositAccount_address_key" ON "DepositAccount"("address"); + +-- CreateIndex +CREATE UNIQUE INDEX "DepositAccount_invoiceId_key" ON "DepositAccount"("invoiceId"); + +-- AddForeignKey +ALTER TABLE "PaymentConfirmation" ADD CONSTRAINT "PaymentConfirmation_invoiceId_merchantId_fkey" FOREIGN KEY ("invoiceId", "merchantId") REFERENCES "Invoice"("id", "merchantId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DepositAccount" ADD CONSTRAINT "DepositAccount_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7f95ef2..cad118c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -128,6 +128,7 @@ model Invoice { bridgePayments BridgePayment[] paymentConfirmations PaymentConfirmation[] + depositAccount DepositAccount? // Enables the composite FK on BridgePayment that enforces invoiceId+merchantId consistency. @@unique([id, merchantId]) @@ -244,3 +245,17 @@ model PaymentConfirmation { idempotencyKey String @unique createdAt DateTime @default(now()) } + +model DepositAccount { + id String @id @default(uuid()) + address String @unique + encryptedSecret String + invoiceId String? @unique + inUse Boolean @default(false) + lastUsedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: Restrict) +} + diff --git a/src/config/environment.ts b/src/config/environment.ts index acf7d77..e9cf075 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -25,6 +25,8 @@ export const environment = { nodeEnv: process.env.NODE_ENV || 'development', port: parseInt(process.env.PORT || '3000', 10), jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', + stellarHorizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org', + depositAccountEncryptionKey: process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY || '', db: { host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432', 10), diff --git a/src/services/deposit-account.service.ts b/src/services/deposit-account.service.ts new file mode 100644 index 0000000..e4d0509 --- /dev/null +++ b/src/services/deposit-account.service.ts @@ -0,0 +1,142 @@ +import { Horizon, Keypair } from '@stellar/stellar-sdk'; +import type { DepositAccount } from '@prisma/client'; +import prisma from '../config/prisma.js'; +import { encrypt } from '../utils/encryption.js'; +import { AppError } from '../utils/errors.js'; + +export interface DepositAccountSummary { + id: string; + address: string; + invoiceId: string | null; + inUse: boolean; + lastUsedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export function sanitizeDepositAccount(account: DepositAccount): DepositAccountSummary { + return { + id: account.id, + address: account.address, + invoiceId: account.invoiceId, + inUse: account.inUse, + lastUsedAt: account.lastUsedAt, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + }; +} + +export class DepositAccountService { + constructor(private horizon: Horizon.Server) {} + + async createAccount(): Promise { + const keypair = Keypair.random(); + const encryptedSecret = encrypt(keypair.secret()); + + const account = await prisma.depositAccount.create({ + data: { + address: keypair.publicKey(), + encryptedSecret, + inUse: false, + invoiceId: null, + }, + }); + + return sanitizeDepositAccount(account); + } + + async getAllAccounts(): Promise { + const accounts = await prisma.depositAccount.findMany({ + orderBy: { createdAt: 'desc' }, + }); + return accounts.map(sanitizeDepositAccount); + } + + async getAccountBalance( + address: string, + ): Promise>['balances']> { + try { + const account = await this.horizon.loadAccount(address); + return account.balances; + } catch (error: unknown) { + const err = error as { response?: { status?: number }; status?: number; name?: string }; + if (err?.response?.status === 404 || err?.status === 404 || err?.name === 'NotFoundError') { + return []; + } + throw error; + } + } + + async getAvailableAccounts(): Promise { + const accounts = await prisma.depositAccount.findMany({ + where: { inUse: false }, + orderBy: { createdAt: 'asc' }, + }); + return accounts.map(sanitizeDepositAccount); + } + + async assignAccount(invoiceId: string): Promise { + while (true) { + const candidates = await prisma.depositAccount.findMany({ + where: { inUse: false }, + orderBy: { createdAt: 'asc' }, + }); + + if (candidates.length === 0) { + throw new AppError(404, 'No available deposit accounts'); + } + + const now = new Date(); + + for (const candidate of candidates) { + try { + const result = await prisma.depositAccount.updateMany({ + where: { + id: candidate.id, + inUse: false, + }, + data: { + inUse: true, + invoiceId, + lastUsedAt: now, + }, + }); + + if (result.count === 1) { + const updated = await prisma.depositAccount.findUnique({ + where: { id: candidate.id }, + }); + return sanitizeDepositAccount(updated!); + } + } catch (error: unknown) { + const err = error as { code?: string }; + if (err?.code === 'P2002') { + throw new AppError(409, 'Invoice already has an assigned deposit account'); + } + throw error; + } + } + } + } + + async releaseAccount(accountId: string): Promise { + const account = await prisma.depositAccount.findUnique({ + where: { id: accountId }, + }); + + if (!account) { + throw new AppError(404, 'Deposit account not found'); + } + + const updated = await prisma.depositAccount.update({ + where: { id: accountId }, + data: { + invoiceId: null, + inUse: false, + lastUsedAt: new Date(), + }, + }); + + return sanitizeDepositAccount(updated); + } +} diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts new file mode 100644 index 0000000..24df432 --- /dev/null +++ b/src/utils/encryption.ts @@ -0,0 +1,47 @@ +import crypto from 'node:crypto'; +import { environment } from '../config/environment.js'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; + +export function getEncryptionKey(): Buffer { + const keyHex = + process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY || environment.depositAccountEncryptionKey; + if ( + !keyHex || + typeof keyHex !== 'string' || + keyHex.length !== 64 || + !/^[0-9a-fA-F]{64}$/.test(keyHex) + ) { + throw new Error('DEPOSIT_ACCOUNT_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'); + } + return Buffer.from(keyHex, 'hex'); +} + +export function encrypt(plaintext: string): string { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + + return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`; +} + +export function decrypt(ciphertext: string): string { + const key = getEncryptionKey(); + const parts = ciphertext.split(':'); + if (parts.length !== 3) { + throw new Error('Invalid ciphertext format'); + } + const [ivHex, tagHex, encryptedHex] = parts; + const iv = Buffer.from(ivHex, 'hex'); + const tag = Buffer.from(tagHex, 'hex'); + const encrypted = Buffer.from(encryptedHex, 'hex'); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); +} diff --git a/tests/unit/deposit-account.service.test.ts b/tests/unit/deposit-account.service.test.ts new file mode 100644 index 0000000..d92100c --- /dev/null +++ b/tests/unit/deposit-account.service.test.ts @@ -0,0 +1,290 @@ +import { jest } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import crypto from 'node:crypto'; + +const validKeyHex = crypto.randomBytes(32).toString('hex'); +process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY = validKeyHex; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { decrypt } = await import('../../src/utils/encryption.js'); +const { DepositAccountService, sanitizeDepositAccount } = await import( + '../../src/services/deposit-account.service.js' +); + +describe('DepositAccountService', () => { + let mockHorizonServer: any; + let service: InstanceType; + + beforeEach(() => { + mockReset(prismaMock); + mockHorizonServer = { + loadAccount: jest.fn(), + }; + service = new DepositAccountService(mockHorizonServer as any); + }); + + describe('createAccount', () => { + it('generates a new keypair, encrypts secret seed, and persists to DB without returning secrets', async () => { + const mockRecord = { + id: 'acc-uuid-1', + address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + encryptedSecret: 'iv:tag:ciphertext', + invoiceId: null, + inUse: false, + lastUsedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + + prismaMock.depositAccount.create.mockImplementation(async (args: any) => ({ + ...mockRecord, + address: args.data.address, + encryptedSecret: args.data.encryptedSecret, + })); + + const summary = await service.createAccount(); + + expect(prismaMock.depositAccount.create).toHaveBeenCalledTimes(1); + const createArgs = prismaMock.depositAccount.create.mock.calls[0][0]; + + // Address should be a Stellar public key (starts with G and 56 chars long) + expect(createArgs.data.address).toMatch(/^G[A-Z0-9]{55}$/); + expect(createArgs.data.inUse).toBe(false); + expect(createArgs.data.invoiceId).toBeNull(); + + // Encrypted secret must be valid ciphertext decryptable into a Stellar secret key (starts with S) + const decryptedSecret = decrypt(createArgs.data.encryptedSecret); + expect(decryptedSecret).toMatch(/^S[A-Z0-9]{55}$/); + + // Returned summary must never include encrypted or raw secret + expect((summary as any).encryptedSecret).toBeUndefined(); + expect((summary as any).secret).toBeUndefined(); + expect(summary.address).toBe(createArgs.data.address); + expect(summary.inUse).toBe(false); + + // No horizon on-chain transactions submitted + expect(mockHorizonServer.loadAccount).not.toHaveBeenCalled(); + }); + }); + + describe('getAllAccounts', () => { + it('returns all deposit accounts without secrets', async () => { + const mockAccounts = [ + { + id: 'acc-1', + address: 'GAAA1', + encryptedSecret: 'enc-1', + invoiceId: 'inv-1', + inUse: true, + lastUsedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'acc-2', + address: 'GAAA2', + encryptedSecret: 'enc-2', + invoiceId: null, + inUse: false, + lastUsedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + prismaMock.depositAccount.findMany.mockResolvedValue(mockAccounts); + + const result = await service.getAllAccounts(); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual(sanitizeDepositAccount(mockAccounts[0] as any)); + expect(result[1]).toEqual(sanitizeDepositAccount(mockAccounts[1] as any)); + expect((result[0] as any).encryptedSecret).toBeUndefined(); + expect((result[1] as any).encryptedSecret).toBeUndefined(); + }); + }); + + describe('getAccountBalance', () => { + it('returns balances when the account exists on-chain', async () => { + const mockBalances = [ + { balance: '100.0000000', asset_type: 'native' }, + { balance: '50.0000000', asset_type: 'credit_alphanum4', asset_code: 'USDC' }, + ]; + mockHorizonServer.loadAccount.mockResolvedValue({ balances: mockBalances }); + + const balances = await service.getAccountBalance('GAAA1'); + + expect(balances).toEqual(mockBalances); + expect(mockHorizonServer.loadAccount).toHaveBeenCalledWith('GAAA1'); + }); + + it('returns an empty array when the account is 404 (not yet created on-chain)', async () => { + const notFoundError: any = new Error('Not Found'); + notFoundError.name = 'NotFoundError'; + notFoundError.response = { status: 404 }; + mockHorizonServer.loadAccount.mockRejectedValue(notFoundError); + + const balances = await service.getAccountBalance('GAAA_NEW'); + + expect(balances).toEqual([]); + }); + + it('rethrows non-404 errors', async () => { + const serverError = new Error('Horizon Internal Error'); + mockHorizonServer.loadAccount.mockRejectedValue(serverError); + + await expect(service.getAccountBalance('GAAA1')).rejects.toThrow('Horizon Internal Error'); + }); + }); + + describe('getAvailableAccounts', () => { + it('returns only available (inUse: false) accounts without secrets', async () => { + const availableAccount = { + id: 'acc-2', + address: 'GAAA2', + encryptedSecret: 'enc-2', + invoiceId: null, + inUse: false, + lastUsedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + prismaMock.depositAccount.findMany.mockResolvedValue([availableAccount]); + + const result = await service.getAvailableAccounts(); + + expect(prismaMock.depositAccount.findMany).toHaveBeenCalledWith({ + where: { inUse: false }, + orderBy: { createdAt: 'asc' }, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('acc-2'); + expect((result[0] as any).encryptedSecret).toBeUndefined(); + }); + }); + + describe('assignAccount', () => { + it('assigns an available account using conditional update', async () => { + const candidate = { + id: 'acc-1', + address: 'GAAA1', + encryptedSecret: 'enc-1', + invoiceId: null, + inUse: false, + lastUsedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const updatedAccount = { + ...candidate, + inUse: true, + invoiceId: 'inv-100', + lastUsedAt: new Date(), + }; + + prismaMock.depositAccount.findMany.mockResolvedValue([candidate]); + prismaMock.depositAccount.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.depositAccount.findUnique.mockResolvedValue(updatedAccount); + + const result = await service.assignAccount('inv-100'); + + expect(prismaMock.depositAccount.updateMany).toHaveBeenCalledWith({ + where: { id: 'acc-1', inUse: false }, + data: expect.objectContaining({ + inUse: true, + invoiceId: 'inv-100', + }), + }); + expect(result.id).toBe('acc-1'); + expect(result.inUse).toBe(true); + expect(result.invoiceId).toBe('inv-100'); + }); + + it('handles race conditions by trying the next candidate if lost race', async () => { + const candidate1 = { id: 'acc-1', address: 'GAAA1', inUse: false }; + const candidate2 = { id: 'acc-2', address: 'GAAA2', inUse: false }; + + prismaMock.depositAccount.findMany.mockResolvedValue([candidate1, candidate2]); + + // Lost race for candidate1 (count: 0), won race for candidate2 (count: 1) + prismaMock.depositAccount.updateMany + .mockResolvedValueOnce({ count: 0 }) + .mockResolvedValueOnce({ count: 1 }); + + const updatedCandidate2 = { + ...candidate2, + encryptedSecret: 'enc-2', + invoiceId: 'inv-200', + inUse: true, + lastUsedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + prismaMock.depositAccount.findUnique.mockResolvedValue(updatedCandidate2); + + const result = await service.assignAccount('inv-200'); + + expect(prismaMock.depositAccount.updateMany).toHaveBeenCalledTimes(2); + expect(result.id).toBe('acc-2'); + expect(result.invoiceId).toBe('inv-200'); + }); + + it('throws 404 when no available accounts exist', async () => { + prismaMock.depositAccount.findMany.mockResolvedValue([]); + + await expect(service.assignAccount('inv-300')).rejects.toMatchObject({ + statusCode: 404, + message: 'No available deposit accounts', + }); + }); + }); + + describe('releaseAccount', () => { + it('clears invoiceId and inUse flag, updating lastUsedAt', async () => { + const assignedAccount = { + id: 'acc-1', + address: 'GAAA1', + encryptedSecret: 'enc-1', + invoiceId: 'inv-100', + inUse: true, + lastUsedAt: new Date('2026-01-01'), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const releasedAccount = { + ...assignedAccount, + invoiceId: null, + inUse: false, + lastUsedAt: new Date(), + }; + + prismaMock.depositAccount.findUnique.mockResolvedValue(assignedAccount); + prismaMock.depositAccount.update.mockResolvedValue(releasedAccount); + + const result = await service.releaseAccount('acc-1'); + + expect(prismaMock.depositAccount.update).toHaveBeenCalledWith({ + where: { id: 'acc-1' }, + data: { + invoiceId: null, + inUse: false, + lastUsedAt: expect.any(Date), + }, + }); + expect(result.inUse).toBe(false); + expect(result.invoiceId).toBeNull(); + }); + + it('throws 404 if account does not exist', async () => { + prismaMock.depositAccount.findUnique.mockResolvedValue(null); + + await expect(service.releaseAccount('non-existent-id')).rejects.toMatchObject({ + statusCode: 404, + message: 'Deposit account not found', + }); + }); + }); +}); diff --git a/tests/unit/encryption.test.ts b/tests/unit/encryption.test.ts new file mode 100644 index 0000000..0d2aed1 --- /dev/null +++ b/tests/unit/encryption.test.ts @@ -0,0 +1,75 @@ +import crypto from 'node:crypto'; +import { encrypt, decrypt, getEncryptionKey } from '../../src/utils/encryption.js'; + +describe('Encryption Utility', () => { + const validKeyHex = crypto.randomBytes(32).toString('hex'); + const originalEnvKey = process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY; + + beforeEach(() => { + process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY = validKeyHex; + }); + + afterAll(() => { + if (originalEnvKey !== undefined) { + process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY = originalEnvKey; + } else { + delete process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY; + } + }); + + describe('getEncryptionKey', () => { + it('returns a 32-byte Buffer for a valid 64-char hex key', () => { + const keyBuffer = getEncryptionKey(); + expect(keyBuffer).toBeInstanceOf(Buffer); + expect(keyBuffer.length).toBe(32); + }); + + it('throws an error if key is missing', () => { + delete process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY; + expect(() => getEncryptionKey()).toThrow( + 'DEPOSIT_ACCOUNT_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)' + ); + }); + + it('throws an error if key is less than 64 hex characters', () => { + process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY = '1234567890abcdef'; + expect(() => getEncryptionKey()).toThrow( + 'DEPOSIT_ACCOUNT_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)' + ); + }); + + it('throws an error if key contains non-hex characters', () => { + process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY = 'Z'.repeat(64); + expect(() => getEncryptionKey()).toThrow( + 'DEPOSIT_ACCOUNT_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)' + ); + }); + }); + + describe('encrypt and decrypt', () => { + it('correctly encrypts and decrypts a string round-trip', () => { + const secret = 'SDORW56POGIXY3NZS24EPRP36Y4QUTYJ2E2MVRKVKZ27TXL5N7227W4G'; + const ciphertext = encrypt(secret); + + expect(typeof ciphertext).toBe('string'); + expect(ciphertext.split(':').length).toBe(3); + + const decrypted = decrypt(ciphertext); + expect(decrypted).toBe(secret); + }); + + it('throws an error when decrypting malformed ciphertext', () => { + expect(() => decrypt('invalid-ciphertext-format')).toThrow('Invalid ciphertext format'); + }); + + it('throws an error when ciphertext tag or data is corrupted', () => { + const secret = 'test-secret'; + const ciphertext = encrypt(secret); + const [iv, tag, data] = ciphertext.split(':'); + const corruptedData = data.substring(0, data.length - 2) + (data.endsWith('00') ? 'ff' : '00'); + const corruptedCiphertext = `${iv}:${tag}:${corruptedData}`; + + expect(() => decrypt(corruptedCiphertext)).toThrow(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 45e0975..bb121a6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "moduleResolution": "NodeNext", "target": "ES2023", "strict": true, + "skipLibCheck": true, "isolatedModules": true, "esModuleInterop": true, "types": [