Skip to content
Closed
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: 3 additions & 2 deletions migrations/0001_create_api_keys_and_vaults.up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -30,3 +30,4 @@ CREATE TABLE vaults (
);

CREATE INDEX idx_vaults_user_network ON vaults (user_id, network);

6 changes: 5 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -180,7 +181,10 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
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(
Expand Down
108 changes: 108 additions & 0 deletions src/repositories/vaultRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,111 @@ test('updateBalanceSnapshot rejects negative balances', async () => {
/non-negative integer/
);
});

describe('PgVaultRepository (pg-mem)', () => {
async function createPgRepo(): Promise<import('./vaultRepository.js').PgVaultRepository> {
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/,
);
});
});

110 changes: 110 additions & 0 deletions src/repositories/vaultRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<R = VaultQueryResultRow>(
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<Vault> {
try {
const result = await this.db.query<VaultQueryResultRow>(
`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<Vault | null> {
const result = await this.db.query<VaultQueryResultRow>(
`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<Vault> {
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<VaultQueryResultRow>(
`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]);
}
}