Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
42 changes: 42 additions & 0 deletions prisma/migrations/20260728064714_add_deposit_account/migration.sql
Original file line number Diff line number Diff line change
@@ -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;

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], onDelete: Restrict)
}

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
Loading