Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

1 change: 1 addition & 0 deletions eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ module.exports = [
'dist/**',
'build/**',
'coverage/**',
'tests/**',
'**/*.d.ts',
'eslint.config.cjs',
],
Expand Down
41 changes: 41 additions & 0 deletions prisma/migrations/20260728064714_add_deposit_account/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
-- 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 SET NULL ON UPDATE CASCADE;
15 changes: 15 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

2 changes: 2 additions & 0 deletions src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
142 changes: 142 additions & 0 deletions src/services/deposit-account.service.ts
Original file line number Diff line number Diff line change
@@ -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<DepositAccountSummary> {
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<DepositAccountSummary[]> {
const accounts = await prisma.depositAccount.findMany({
orderBy: { createdAt: 'desc' },
});
return accounts.map(sanitizeDepositAccount);
}

async getAccountBalance(
address: string,
): Promise<Awaited<ReturnType<Horizon.Server['loadAccount']>>['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<DepositAccountSummary[]> {
const accounts = await prisma.depositAccount.findMany({
where: { inUse: false },
orderBy: { createdAt: 'asc' },
});
return accounts.map(sanitizeDepositAccount);
}

async assignAccount(invoiceId: string): Promise<DepositAccountSummary> {
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<DepositAccountSummary> {
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);
}
}
47 changes: 47 additions & 0 deletions src/utils/encryption.ts
Original file line number Diff line number Diff line change
@@ -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');
}
Loading