diff --git a/migrations/0001_create_api_keys_and_vaults.up.sql b/migrations/0001_create_api_keys_and_vaults.up.sql index 65271b56..a2b92b0e 100644 --- a/migrations/0001_create_api_keys_and_vaults.up.sql +++ b/migrations/0001_create_api_keys_and_vaults.up.sql @@ -18,10 +18,10 @@ CREATE INDEX idx_api_keys_user_prefix ON api_keys (user_id, prefix); CREATE TABLE vaults ( id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, + user_id TEXT NOT NULL, stellar_vault_contract_id TEXT NOT NULL, network VARCHAR(32) NOT NULL, - balance_snapshot NUMERIC(20, 7) NOT NULL DEFAULT 0, + balance_snapshot BIGINT NOT NULL DEFAULT 0, last_synced_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -30,3 +30,4 @@ CREATE TABLE vaults ( ); CREATE INDEX idx_vaults_user_network ON vaults (user_id, network); + diff --git a/src/app.ts b/src/app.ts index 47eb6658..a6a6ba18 100644 --- a/src/app.ts +++ b/src/app.ts @@ -61,6 +61,7 @@ import { createQuotasRouter } from "./routes/quotas.js"; import { parsePagination, paginatedResponse } from "./lib/pagination.js"; import { InMemoryVaultRepository, + PgVaultRepository, type VaultRepository, } from "./repositories/vaultRepository.js"; import { DepositController } from "./controllers/depositController.js"; @@ -180,7 +181,10 @@ export const createApp = (dependencies?: Partial) => { const usageEventsRepository = dependencies?.usageEventsRepository ?? new InMemoryUsageEventsRepository(); const vaultRepository = - dependencies?.vaultRepository ?? new InMemoryVaultRepository(); + dependencies?.vaultRepository ?? + (process.env.DATABASE_URL + ? new PgVaultRepository(pool) + : new InMemoryVaultRepository()); const lookupDeveloper = dependencies?.findDeveloperByUserId ?? findByUserId; const persistApi = dependencies?.createApiWithEndpoints ?? createApi; const reportExporterService = new ReportExporterService( diff --git a/src/repositories/vaultRepository.test.ts b/src/repositories/vaultRepository.test.ts index 8e8d6ac4..7aecbaa3 100644 --- a/src/repositories/vaultRepository.test.ts +++ b/src/repositories/vaultRepository.test.ts @@ -159,3 +159,111 @@ test('updateBalanceSnapshot rejects negative balances', async () => { /non-negative integer/ ); }); + +describe('PgVaultRepository (pg-mem)', () => { + async function createPgRepo(): Promise { + const { newDb } = await import('pg-mem'); + const { PgVaultRepository } = await import('./vaultRepository.js'); + const db = newDb(); + db.public.none(` + CREATE TABLE vaults ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + stellar_vault_contract_id TEXT NOT NULL, + network VARCHAR(32) NOT NULL, + balance_snapshot BIGINT NOT NULL DEFAULT 0, + last_synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT vaults_user_network_unique UNIQUE (user_id, network), + CONSTRAINT vaults_balance_snapshot_non_negative CHECK (balance_snapshot >= 0) + ); + `); + const { Pool } = db.adapters.createPg(); + const pool = new Pool(); + return new PgVaultRepository(pool); + } + + test('create stores a vault with default snapshot values', async () => { + const repository = await createPgRepo(); + const vault = await repository.create('user-1', 'contract-1', 'testnet'); + + assert.equal(vault.userId, 'user-1'); + assert.equal(vault.contractId, 'contract-1'); + assert.equal(vault.network, 'testnet'); + assert.equal(vault.balanceSnapshot, 0n); + assert.equal(vault.lastSyncedAt, null); + assert.ok(vault.createdAt instanceof Date); + assert.ok(vault.updatedAt instanceof Date); + }); + + test('create enforces one vault per user and network via unique constraint', async () => { + const repository = await createPgRepo(); + await repository.create('user-1', 'contract-1', 'testnet'); + + await assert.rejects( + repository.create('user-1', 'contract-2', 'testnet'), + DuplicateVaultError, + ); + }); + + test('create allows multiple vaults for same user on different networks', async () => { + const repository = await createPgRepo(); + const testnetVault = await repository.create('user-1', 'contract-1', 'testnet'); + const mainnetVault = await repository.create('user-1', 'contract-2', 'mainnet'); + + assert.notEqual(testnetVault.id, mainnetVault.id); + const foundMainnet = await repository.findByUserId('user-1', 'mainnet'); + assert.ok(foundMainnet); + assert.equal(foundMainnet.contractId, 'contract-2'); + }); + + test('findByUserId returns null when user has no vault for a network', async () => { + const repository = await createPgRepo(); + await repository.create('user-1', 'contract-1', 'testnet'); + + const result = await repository.findByUserId('user-1', 'mainnet'); + assert.equal(result, null); + }); + + test('updateBalanceSnapshot updates balance and lastSyncedAt and persists across reads', async () => { + const repository = await createPgRepo(); + const vault = await repository.create('user-1', 'contract-1', 'testnet'); + const syncedAt = new Date('2026-02-25T10:00:00.000Z'); + + const updated = await repository.updateBalanceSnapshot(vault.id, 15000000n, syncedAt); + assert.equal(updated.balanceSnapshot, 15000000n); + assert.equal(updated.lastSyncedAt?.toISOString(), '2026-02-25T10:00:00.000Z'); + + const stored = await repository.findByUserId('user-1', 'testnet'); + assert.ok(stored); + assert.equal(stored.balanceSnapshot, 15000000n); + assert.equal(stored.lastSyncedAt?.toISOString(), '2026-02-25T10:00:00.000Z'); + }); + + test('updateBalanceSnapshot throws VaultNotFoundError for unknown or non-numeric vault id', async () => { + const repository = await createPgRepo(); + const syncedAt = new Date('2026-02-25T10:00:00.000Z'); + + await assert.rejects( + repository.updateBalanceSnapshot('999999', 100n, syncedAt), + VaultNotFoundError, + ); + await assert.rejects( + repository.updateBalanceSnapshot('does-not-exist', 100n, syncedAt), + VaultNotFoundError, + ); + }); + + test('updateBalanceSnapshot rejects negative balances', async () => { + const repository = await createPgRepo(); + const vault = await repository.create('user-1', 'contract-1', 'testnet'); + const syncedAt = new Date('2026-02-25T10:00:00.000Z'); + + await assert.rejects( + repository.updateBalanceSnapshot(vault.id, -1n, syncedAt), + /non-negative integer/, + ); + }); +}); + diff --git a/src/repositories/vaultRepository.ts b/src/repositories/vaultRepository.ts index 3fedafa4..135de608 100644 --- a/src/repositories/vaultRepository.ts +++ b/src/repositories/vaultRepository.ts @@ -116,3 +116,113 @@ export class InMemoryVaultRepository implements VaultRepository { return cloneVault(updatedVault); } } + +export interface VaultQueryResultRow { + id: string | number; + user_id: string | number; + stellar_vault_contract_id: string; + network: string; + balance_snapshot: string | number | bigint; + last_synced_at: Date | string | null; + created_at: Date | string; + updated_at: Date | string; +} + +export interface VaultQueryable { + query( + text: string, + values?: unknown[], + ): Promise<{ rows: R[]; rowCount?: number | null }>; +} + +const toDate = (val: Date | string): Date => + val instanceof Date ? new Date(val.getTime()) : new Date(val); + +const mapVaultRow = (row: VaultQueryResultRow): Vault => ({ + id: String(row.id), + userId: String(row.user_id), + contractId: row.stellar_vault_contract_id, + network: row.network, + balanceSnapshot: + typeof row.balance_snapshot === 'bigint' + ? row.balance_snapshot + : BigInt(Math.trunc(Number(row.balance_snapshot))), + lastSyncedAt: row.last_synced_at ? toDate(row.last_synced_at) : null, + createdAt: toDate(row.created_at), + updatedAt: toDate(row.updated_at), +}); + +const isUniqueViolation = (err: unknown): boolean => { + if (!err || typeof err !== 'object') return false; + const code = (err as { code?: string }).code; + if (code === '23505') return true; + const msg = (err as { message?: string }).message ?? ''; + return /duplicate key|unique constraint/i.test(msg); +}; + +export class PgVaultRepository implements VaultRepository { + constructor(private readonly db: VaultQueryable) {} + + async create(userId: string, contractId: string, network: string): Promise { + try { + const result = await this.db.query( + `INSERT INTO vaults (user_id, stellar_vault_contract_id, network, balance_snapshot, last_synced_at, created_at, updated_at) + VALUES ($1, $2, $3, 0, NULL, NOW(), NOW()) + RETURNING id, user_id, stellar_vault_contract_id, network, balance_snapshot, last_synced_at, created_at, updated_at`, + [userId, contractId, network], + ); + return mapVaultRow(result.rows[0]); + } catch (err) { + if (isUniqueViolation(err)) { + throw new DuplicateVaultError(userId, network); + } + throw err; + } + } + + async findByUserId(userId: string, network: string): Promise { + const result = await this.db.query( + `SELECT id, user_id, stellar_vault_contract_id, network, balance_snapshot, last_synced_at, created_at, updated_at + FROM vaults + WHERE user_id = $1 AND network = $2 + LIMIT 1`, + [userId, network], + ); + if (result.rows.length === 0) { + return null; + } + return mapVaultRow(result.rows[0]); + } + + async updateBalanceSnapshot( + id: VaultId, + balance: bigint, + lastSyncedAt: Date, + ): Promise { + if (balance < 0n) { + throw new Error('balanceSnapshot must be a non-negative integer in smallest units.'); + } + + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + throw new VaultNotFoundError(id); + } + + const result = await this.db.query( + `UPDATE vaults + SET balance_snapshot = $2, + last_synced_at = $3, + updated_at = NOW() + WHERE id = $1 + RETURNING id, user_id, stellar_vault_contract_id, network, balance_snapshot, last_synced_at, created_at, updated_at`, + [numericId, balance.toString(), new Date(lastSyncedAt.getTime())], + ); + + if (result.rows.length === 0) { + throw new VaultNotFoundError(id); + } + + return mapVaultRow(result.rows[0]); + } +} +