diff --git a/apps/api/package.json b/apps/api/package.json index 2042e8a..c63c324 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "dependencies": { "@crypto-wallet/cache": "workspace:*", "@crypto-wallet/config": "workspace:*", + "@crypto-wallet/ledger-core": "workspace:*", "@crypto-wallet/shared-types": "workspace:*", "@crypto-wallet/storage": "workspace:*", "@opentelemetry/api": "^1.9.1", diff --git a/apps/api/src/__tests__/exchange-account-repository.test.ts b/apps/api/src/__tests__/exchange-account-repository.test.ts new file mode 100644 index 0000000..9761343 --- /dev/null +++ b/apps/api/src/__tests__/exchange-account-repository.test.ts @@ -0,0 +1,137 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { PostgresStorage } from "@crypto-wallet/storage"; + +import { ExchangeAccountRepository } from "../exchange/exchange-account-repository.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for exchange account repository tests"); +} + +describe("ExchangeAccountRepository", () => { + it("creates and finds a customer exchange account", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeAccountRepository(storage); + + const ownerId = randomUUID(); + + try { + await storage.connect(); + + const created = await repository.create({ + ownerType: "customer", + ownerId, + kind: "customer", + }); + + expect(created).toMatchObject({ + ownerType: "customer", + ownerId, + kind: "customer", + status: "active", + }); + + expect(created.id).toBeTruthy(); + expect(created.createdAt).toBeInstanceOf(Date); + + const found = await repository.findById(created.id); + + expect(found).toEqual(created); + } finally { + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE owner_id = $1 + `, + [ownerId], + ); + + await storage.disconnect(); + } + }); + + it("finds an account by owner and kind", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeAccountRepository(storage); + + const ownerId = randomUUID(); + + try { + await storage.connect(); + + const created = await repository.create({ + ownerType: "platform", + ownerId, + kind: "treasury", + }); + + const found = await repository.findByOwnerAndKind("platform", ownerId, "treasury"); + + expect(found).toEqual(created); + } finally { + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE owner_id = $1 + `, + [ownerId], + ); + + await storage.disconnect(); + } + }); + + it("returns null for an unknown account", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeAccountRepository(storage); + + try { + await storage.connect(); + + const found = await repository.findById(randomUUID()); + + expect(found).toBeNull(); + } finally { + await storage.disconnect(); + } + }); + + it("enforces the database uniqueness boundary", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeAccountRepository(storage); + + const ownerId = randomUUID(); + + try { + await storage.connect(); + + await repository.create({ + ownerType: "customer", + ownerId, + kind: "customer", + }); + + await expect( + repository.create({ + ownerType: "customer", + ownerId, + kind: "customer", + }), + ).rejects.toThrow(); + } finally { + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE owner_id = $1 + `, + [ownerId], + ); + + await storage.disconnect(); + } + }); +}); diff --git a/apps/api/src/__tests__/exchange-asset-account-repository.test.ts b/apps/api/src/__tests__/exchange-asset-account-repository.test.ts new file mode 100644 index 0000000..2cd5345 --- /dev/null +++ b/apps/api/src/__tests__/exchange-asset-account-repository.test.ts @@ -0,0 +1,409 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { PostgresStorage } from "@crypto-wallet/storage"; + +import { ExchangeAssetAccountRepository } from "../exchange/exchange-asset-account-repository.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for exchange asset account repository tests"); +} + +describe("ExchangeAssetAccountRepository", () => { + async function createFixtures(storage: PostgresStorage) { + const exchangeAccountId = randomUUID(); + const networkId = randomUUID(); + const assetId = randomUUID(); + + await storage.query( + ` + INSERT INTO exchange_accounts ( + id, + owner_type, + owner_id, + kind + ) + VALUES ($1, 'customer', $2, 'customer') + `, + [exchangeAccountId, randomUUID()], + ); + + await storage.query( + ` + INSERT INTO networks ( + id, + key, + name, + chain, + environment + ) + VALUES ($1, $2, $3, $4, $5) + `, + [networkId, `test-${networkId}`, "Test Network", "test-chain", "test"], + ); + + await storage.query( + ` + INSERT INTO assets ( + id, + network_id, + symbol, + name, + decimals, + asset_type + ) + VALUES ($1, $2, $3, $4, $5, $6) + `, + [assetId, networkId, `T${assetId.slice(0, 6)}`, "Test Asset", 18, "native"], + ); + + return { + exchangeAccountId, + networkId, + assetId, + }; + } + + async function cleanupFixtures( + storage: PostgresStorage, + exchangeAccountId: string, + networkId: string, + assetId: string, + ) { + await storage.query( + ` + DELETE FROM exchange_asset_accounts + WHERE exchange_account_id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM assets + WHERE id = $1 + `, + [assetId], + ); + + await storage.query( + ` + DELETE FROM networks + WHERE id = $1 + `, + [networkId], + ); + } + + it("creates and finds an exchange asset account", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeAssetAccountRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const created = await repository.create({ + exchangeAccountId: fixtures.exchangeAccountId, + assetId: fixtures.assetId, + networkId: fixtures.networkId, + }); + + expect(created).toMatchObject({ + exchangeAccountId: fixtures.exchangeAccountId, + assetId: fixtures.assetId, + networkId: fixtures.networkId, + }); + + expect(created.createdAt).toBeInstanceOf(Date); + + const found = await repository.findById(created.id); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); + + it("finds an asset account by exchange account and asset", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeAssetAccountRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const created = await repository.create({ + exchangeAccountId: fixtures.exchangeAccountId, + assetId: fixtures.assetId, + networkId: fixtures.networkId, + }); + + const found = await repository.findByAccountAndAsset( + fixtures.exchangeAccountId, + fixtures.assetId, + ); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); + + it("lists asset accounts for an exchange account", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeAssetAccountRepository(storage); + + let exchangeAccountId: string | undefined; + let networkId: string | undefined; + let assetId: string | undefined; + let secondAssetId: string | undefined; + + try { + await storage.connect(); + + const fixtures = await createFixtures(storage); + + exchangeAccountId = fixtures.exchangeAccountId; + networkId = fixtures.networkId; + assetId = fixtures.assetId; + secondAssetId = randomUUID(); + + await storage.query( + ` + INSERT INTO assets ( + id, + network_id, + symbol, + name, + decimals, + asset_type + ) + VALUES ($1, $2, $3, $4, $5, $6) + `, + [ + secondAssetId, + networkId, + `U${secondAssetId.slice(0, 6)}`, + "Second Test Asset", + 18, + "native", + ], + ); + + const first = await repository.create({ + exchangeAccountId, + assetId, + networkId, + }); + + const second = await repository.create({ + exchangeAccountId, + assetId: secondAssetId, + networkId, + }); + + const listed = await repository.listByExchangeAccount(exchangeAccountId); + + expect(listed).toEqual([first, second]); + } finally { + if (exchangeAccountId) { + await storage.query( + ` + DELETE FROM exchange_asset_accounts + WHERE exchange_account_id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE id = $1 + `, + [exchangeAccountId], + ); + } + + if (assetId) { + await storage.query(`DELETE FROM assets WHERE id = $1`, [assetId]); + } + + if (secondAssetId) { + await storage.query(`DELETE FROM assets WHERE id = $1`, [secondAssetId]); + } + + if (networkId) { + await storage.query(`DELETE FROM networks WHERE id = $1`, [networkId]); + } + + await storage.disconnect(); + } + }); + + it("rejects an asset paired with the wrong network", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeAssetAccountRepository(storage); + + let exchangeAccountId: string | undefined; + let networkId: string | undefined; + let assetId: string | undefined; + let wrongNetworkId: string | undefined; + + try { + await storage.connect(); + + const fixtures = await createFixtures(storage); + + exchangeAccountId = fixtures.exchangeAccountId; + networkId = fixtures.networkId; + assetId = fixtures.assetId; + wrongNetworkId = randomUUID(); + + await storage.query( + ` + INSERT INTO networks ( + id, + key, + name, + chain, + environment + ) + VALUES ($1, $2, $3, $4, $5) + `, + [wrongNetworkId, `wrong-${wrongNetworkId}`, "Wrong Network", "wrong-chain", "test"], + ); + + await expect( + repository.create({ + exchangeAccountId, + assetId, + networkId: wrongNetworkId, + }), + ).rejects.toThrow(); + } finally { + if (exchangeAccountId) { + await storage.query( + ` + DELETE FROM exchange_asset_accounts + WHERE exchange_account_id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE id = $1 + `, + [exchangeAccountId], + ); + } + + if (assetId) { + await storage.query(`DELETE FROM assets WHERE id = $1`, [assetId]); + } + + if (wrongNetworkId) { + await storage.query(`DELETE FROM networks WHERE id = $1`, [wrongNetworkId]); + } + + if (networkId) { + await storage.query(`DELETE FROM networks WHERE id = $1`, [networkId]); + } + + await storage.disconnect(); + } + }); + + it("enforces one asset account per exchange account and asset", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeAssetAccountRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + await repository.create({ + exchangeAccountId: fixtures.exchangeAccountId, + assetId: fixtures.assetId, + networkId: fixtures.networkId, + }); + + await expect( + repository.create({ + exchangeAccountId: fixtures.exchangeAccountId, + assetId: fixtures.assetId, + networkId: fixtures.networkId, + }), + ).rejects.toThrow(); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); +}); diff --git a/apps/api/src/__tests__/exchange-deposit-repository.test.ts b/apps/api/src/__tests__/exchange-deposit-repository.test.ts new file mode 100644 index 0000000..2171b27 --- /dev/null +++ b/apps/api/src/__tests__/exchange-deposit-repository.test.ts @@ -0,0 +1,348 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { PostgresStorage } from "@crypto-wallet/storage"; + +import { ExchangeDepositRepository } from "../exchange/exchange-deposit-repository.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for exchange deposit repository tests"); +} + +describe("ExchangeDepositRepository", () => { + async function createFixtures(storage: PostgresStorage) { + const exchangeAccountId = randomUUID(); + const networkId = randomUUID(); + const assetId = randomUUID(); + const exchangeAssetAccountId = randomUUID(); + + await storage.query( + ` + INSERT INTO exchange_accounts ( + id, + owner_type, + owner_id, + kind + ) + VALUES ($1, 'customer', $2, 'customer') + `, + [exchangeAccountId, randomUUID()], + ); + + await storage.query( + ` + INSERT INTO networks ( + id, + key, + name, + chain, + environment + ) + VALUES ($1, $2, $3, $4, $5) + `, + [ + networkId, + `deposit-test-${networkId}`, + "Deposit Test Network", + "deposit-test-chain", + "test", + ], + ); + + await storage.query( + ` + INSERT INTO assets ( + id, + network_id, + symbol, + name, + decimals, + asset_type + ) + VALUES ($1, $2, $3, $4, $5, $6) + `, + [assetId, networkId, `D${assetId.slice(0, 6)}`, "Deposit Test Asset", 18, "native"], + ); + + await storage.query( + ` + INSERT INTO exchange_asset_accounts ( + id, + exchange_account_id, + asset_id, + network_id + ) + VALUES ($1, $2, $3, $4) + `, + [exchangeAssetAccountId, exchangeAccountId, assetId, networkId], + ); + + return { + exchangeAccountId, + networkId, + assetId, + exchangeAssetAccountId, + }; + } + + async function cleanupFixtures( + storage: PostgresStorage, + exchangeAccountId: string, + networkId: string, + assetId: string, + ) { + await storage.query( + ` + DELETE FROM exchange_deposits + WHERE exchange_asset_account_id IN ( + SELECT id + FROM exchange_asset_accounts + WHERE exchange_account_id = $1 + ) + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_asset_accounts + WHERE exchange_account_id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM assets + WHERE id = $1 + `, + [assetId], + ); + + await storage.query( + ` + DELETE FROM networks + WHERE id = $1 + `, + [networkId], + ); + } + + it("creates and finds a deposit", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeDepositRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + exchangeAssetAccountId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const created = await repository.create({ + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + transactionHash: `tx-${randomUUID()}`, + amount: "1250000", + reference: `deposit-${randomUUID()}`, + }); + + expect(created).toMatchObject({ + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + amount: "1250000", + status: "detected", + }); + + expect(created.createdAt).toBeInstanceOf(Date); + + const found = await repository.findById(created.id); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); + + it("finds a deposit by reference", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeDepositRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + exchangeAssetAccountId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const reference = `deposit-${randomUUID()}`; + + const created = await repository.create({ + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + transactionHash: `tx-${randomUUID()}`, + amount: "2500000", + reference, + }); + + const found = await repository.findByReference(reference); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); + + it("finds a deposit by network and transaction hash", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeDepositRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + exchangeAssetAccountId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const transactionHash = `tx-${randomUUID()}`; + + const created = await repository.create({ + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + transactionHash, + amount: "5000000", + reference: `deposit-${randomUUID()}`, + }); + + const found = await repository.findByTransaction(fixtures.networkId, transactionHash); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); + + it("returns null for an unknown deposit", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeDepositRepository(storage); + + try { + await storage.connect(); + + const found = await repository.findById(randomUUID()); + + expect(found).toBeNull(); + } finally { + await storage.disconnect(); + } + }); + + it("enforces unique deposit references", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeDepositRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + exchangeAssetAccountId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const reference = `duplicate-deposit-${randomUUID()}`; + + const input = { + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + transactionHash: `tx-${randomUUID()}`, + amount: "1000000", + reference, + }; + + await repository.create(input); + + await expect( + repository.create({ + ...input, + transactionHash: `tx-${randomUUID()}`, + }), + ).rejects.toThrow(); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); +}); diff --git a/apps/api/src/__tests__/exchange-fee-repository.test.ts b/apps/api/src/__tests__/exchange-fee-repository.test.ts new file mode 100644 index 0000000..21832d4 --- /dev/null +++ b/apps/api/src/__tests__/exchange-fee-repository.test.ts @@ -0,0 +1,420 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { PostgresStorage } from "@crypto-wallet/storage"; + +import { ExchangeFeeRepository } from "../exchange/exchange-fee-repository.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for exchange fee repository tests"); +} + +describe("ExchangeFeeRepository", () => { + async function createFixtures(storage: PostgresStorage) { + const buyerExchangeAccountId = randomUUID(); + const sellerExchangeAccountId = randomUUID(); + const feeExchangeAccountId = randomUUID(); + + const networkId = randomUUID(); + const baseAssetId = randomUUID(); + const quoteAssetId = randomUUID(); + + const buyerBaseExchangeAssetAccountId = randomUUID(); + const buyerQuoteExchangeAssetAccountId = randomUUID(); + const sellerBaseExchangeAssetAccountId = randomUUID(); + const sellerQuoteExchangeAssetAccountId = randomUUID(); + const feeExchangeAssetAccountId = randomUUID(); + + const tradeId = randomUUID(); + + await storage.query( + ` + INSERT INTO exchange_accounts ( + id, + owner_type, + owner_id, + kind + ) + VALUES + ($1, 'customer', $4, 'customer'), + ($2, 'customer', $5, 'customer'), + ($3, 'platform', $6, 'fee') + `, + [ + buyerExchangeAccountId, + sellerExchangeAccountId, + feeExchangeAccountId, + randomUUID(), + randomUUID(), + randomUUID(), + ], + ); + + await storage.query( + ` + INSERT INTO networks ( + id, + key, + name, + chain, + environment + ) + VALUES ($1, $2, $3, $4, $5) + `, + [networkId, `fee-test-${networkId}`, "Fee Test Network", "fee-test-chain", "test"], + ); + + await storage.query( + ` + INSERT INTO assets ( + id, + network_id, + symbol, + name, + decimals, + asset_type + ) + VALUES + ($1, $3, $5, 'Fee Base Asset', 18, 'native'), + ($2, $4, $6, 'Fee Quote Asset', 6, 'native') + `, + [ + baseAssetId, + quoteAssetId, + networkId, + networkId, + `B${baseAssetId.slice(0, 6)}`, + `Q${quoteAssetId.slice(0, 6)}`, + ], + ); + + await storage.query( + ` + INSERT INTO exchange_asset_accounts ( + id, + exchange_account_id, + asset_id, + network_id + ) + VALUES + ($1, $5, $9, $13), + ($2, $6, $10, $13), + ($3, $7, $11, $13), + ($4, $8, $12, $13), + ($14, $15, $9, $13) + `, + [ + buyerBaseExchangeAssetAccountId, + buyerQuoteExchangeAssetAccountId, + sellerBaseExchangeAssetAccountId, + sellerQuoteExchangeAssetAccountId, + buyerExchangeAccountId, + buyerExchangeAccountId, + sellerExchangeAccountId, + sellerExchangeAccountId, + baseAssetId, + quoteAssetId, + baseAssetId, + quoteAssetId, + networkId, + feeExchangeAssetAccountId, + feeExchangeAccountId, + ], + ); + + await storage.query( + ` + INSERT INTO exchange_trades ( + id, + buyer_base_exchange_asset_account_id, + buyer_quote_exchange_asset_account_id, + seller_base_exchange_asset_account_id, + seller_quote_exchange_asset_account_id, + base_asset_id, + quote_asset_id, + base_amount, + quote_amount, + price_numerator, + price_denominator, + status, + reference, + executed_at + ) + VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + 1000000, + 2000000, + 2, + 1, + 'executed', + $8, + NOW() + ) + `, + [ + tradeId, + buyerBaseExchangeAssetAccountId, + buyerQuoteExchangeAssetAccountId, + sellerBaseExchangeAssetAccountId, + sellerQuoteExchangeAssetAccountId, + baseAssetId, + quoteAssetId, + `trade-${randomUUID()}`, + ], + ); + + return { + buyerExchangeAccountId, + sellerExchangeAccountId, + feeExchangeAccountId, + networkId, + baseAssetId, + quoteAssetId, + buyerBaseExchangeAssetAccountId, + buyerQuoteExchangeAssetAccountId, + sellerBaseExchangeAssetAccountId, + sellerQuoteExchangeAssetAccountId, + feeExchangeAssetAccountId, + tradeId, + }; + } + + async function cleanupFixtures( + storage: PostgresStorage, + fixtures: { + buyerExchangeAccountId: string; + sellerExchangeAccountId: string; + feeExchangeAccountId: string; + networkId: string; + baseAssetId: string; + quoteAssetId: string; + tradeId: string; + }, + ) { + await storage.query( + ` + DELETE FROM exchange_fees + WHERE trade_id = $1 + `, + [fixtures.tradeId], + ); + + await storage.query( + ` + DELETE FROM exchange_trades + WHERE id = $1 + `, + [fixtures.tradeId], + ); + + await storage.query( + ` + DELETE FROM exchange_asset_accounts + WHERE exchange_account_id IN ($1, $2, $3) + `, + [ + fixtures.buyerExchangeAccountId, + fixtures.sellerExchangeAccountId, + fixtures.feeExchangeAccountId, + ], + ); + + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE id IN ($1, $2, $3) + `, + [ + fixtures.buyerExchangeAccountId, + fixtures.sellerExchangeAccountId, + fixtures.feeExchangeAccountId, + ], + ); + + await storage.query( + ` + DELETE FROM assets + WHERE id IN ($1, $2) + `, + [fixtures.baseAssetId, fixtures.quoteAssetId], + ); + + await storage.query( + ` + DELETE FROM networks + WHERE id = $1 + `, + [fixtures.networkId], + ); + } + + it("creates and finds a fee", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeFeeRepository(storage); + + let fixtures: Awaited> | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const created = await repository.create({ + tradeId: fixtures.tradeId, + sourceExchangeAssetAccountId: fixtures.buyerBaseExchangeAssetAccountId, + feeExchangeAssetAccountId: fixtures.feeExchangeAssetAccountId, + assetId: fixtures.baseAssetId, + amount: "2500", + reference: `fee-${randomUUID()}`, + }); + + expect(created).toMatchObject({ + tradeId: fixtures.tradeId, + sourceExchangeAssetAccountId: fixtures.buyerBaseExchangeAssetAccountId, + feeExchangeAssetAccountId: fixtures.feeExchangeAssetAccountId, + assetId: fixtures.baseAssetId, + amount: "2500", + status: "charged", + }); + + expect(created.createdAt).toBeInstanceOf(Date); + + const found = await repository.findById(created.id); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures(storage, fixtures); + } + + await storage.disconnect(); + } + }); + + it("finds a fee by reference", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeFeeRepository(storage); + + let fixtures: Awaited> | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const reference = `fee-${randomUUID()}`; + + const created = await repository.create({ + tradeId: fixtures.tradeId, + sourceExchangeAssetAccountId: fixtures.buyerBaseExchangeAssetAccountId, + feeExchangeAssetAccountId: fixtures.feeExchangeAssetAccountId, + assetId: fixtures.baseAssetId, + amount: "5000", + reference, + }); + + const found = await repository.findByReference(reference); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures(storage, fixtures); + } + + await storage.disconnect(); + } + }); + + it("preserves an explicitly supplied reversed status", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeFeeRepository(storage); + + let fixtures: Awaited> | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const created = await repository.create({ + tradeId: fixtures.tradeId, + sourceExchangeAssetAccountId: fixtures.buyerBaseExchangeAssetAccountId, + feeExchangeAssetAccountId: fixtures.feeExchangeAssetAccountId, + assetId: fixtures.baseAssetId, + amount: "1000", + status: "reversed", + reference: `fee-${randomUUID()}`, + }); + + expect(created.status).toBe("reversed"); + } finally { + if (fixtures) { + await cleanupFixtures(storage, fixtures); + } + + await storage.disconnect(); + } + }); + + it("returns null for an unknown fee", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeFeeRepository(storage); + + try { + await storage.connect(); + + const found = await repository.findById(randomUUID()); + + expect(found).toBeNull(); + } finally { + await storage.disconnect(); + } + }); + + it("enforces unique fee references", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeFeeRepository(storage); + + let fixtures: Awaited> | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const reference = `duplicate-fee-${randomUUID()}`; + + const input = { + tradeId: fixtures.tradeId, + sourceExchangeAssetAccountId: fixtures.buyerBaseExchangeAssetAccountId, + feeExchangeAssetAccountId: fixtures.feeExchangeAssetAccountId, + assetId: fixtures.baseAssetId, + amount: "1000", + reference, + }; + + await repository.create(input); + + await expect( + repository.create({ + ...input, + }), + ).rejects.toThrow(); + } finally { + if (fixtures) { + await cleanupFixtures(storage, fixtures); + } + + await storage.disconnect(); + } + }); +}); diff --git a/apps/api/src/__tests__/exchange-subaccount-repository.test.ts b/apps/api/src/__tests__/exchange-subaccount-repository.test.ts new file mode 100644 index 0000000..b533f6a --- /dev/null +++ b/apps/api/src/__tests__/exchange-subaccount-repository.test.ts @@ -0,0 +1,199 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { PostgresStorage } from "@crypto-wallet/storage"; + +import { ExchangeSubaccountRepository } from "../exchange/exchange-subaccount-repository.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for exchange subaccount repository tests"); +} + +describe("ExchangeSubaccountRepository", () => { + it("creates and finds a subaccount", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeSubaccountRepository(storage); + + const exchangeAccountId = randomUUID(); + + try { + await storage.connect(); + + await storage.query( + ` + INSERT INTO exchange_accounts ( + id, + owner_type, + owner_id, + kind + ) + VALUES ($1, 'customer', $2, 'customer') + `, + [exchangeAccountId, randomUUID()], + ); + + const created = await repository.create({ + exchangeAccountId, + name: "spot", + }); + + expect(created).toMatchObject({ + exchangeAccountId, + name: "spot", + }); + + expect(created.id).toBeTruthy(); + expect(created.createdAt).toBeInstanceOf(Date); + + const found = await repository.findById(created.id); + + expect(found).toEqual(created); + } finally { + await storage.query( + ` + DELETE FROM exchange_subaccounts + WHERE exchange_account_id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE id = $1 + `, + [exchangeAccountId], + ); + + await storage.disconnect(); + } + }); + + it("lists subaccounts for an exchange account", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeSubaccountRepository(storage); + + const exchangeAccountId = randomUUID(); + + try { + await storage.connect(); + + await storage.query( + ` + INSERT INTO exchange_accounts ( + id, + owner_type, + owner_id, + kind + ) + VALUES ($1, 'customer', $2, 'customer') + `, + [exchangeAccountId, randomUUID()], + ); + + const first = await repository.create({ + exchangeAccountId, + name: "spot", + }); + + const second = await repository.create({ + exchangeAccountId, + name: "margin", + }); + + const listed = await repository.listByExchangeAccount(exchangeAccountId); + + expect(listed).toEqual([first, second]); + } finally { + await storage.query( + ` + DELETE FROM exchange_subaccounts + WHERE exchange_account_id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE id = $1 + `, + [exchangeAccountId], + ); + + await storage.disconnect(); + } + }); + + it("returns null for an unknown subaccount", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeSubaccountRepository(storage); + + try { + await storage.connect(); + + const found = await repository.findById(randomUUID()); + + expect(found).toBeNull(); + } finally { + await storage.disconnect(); + } + }); + + it("enforces unique subaccount names per exchange account", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeSubaccountRepository(storage); + + const exchangeAccountId = randomUUID(); + + try { + await storage.connect(); + + await storage.query( + ` + INSERT INTO exchange_accounts ( + id, + owner_type, + owner_id, + kind + ) + VALUES ($1, 'customer', $2, 'customer') + `, + [exchangeAccountId, randomUUID()], + ); + + await repository.create({ + exchangeAccountId, + name: "spot", + }); + + await expect( + repository.create({ + exchangeAccountId, + name: "spot", + }), + ).rejects.toThrow(); + } finally { + await storage.query( + ` + DELETE FROM exchange_subaccounts + WHERE exchange_account_id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE id = $1 + `, + [exchangeAccountId], + ); + + await storage.disconnect(); + } + }); +}); diff --git a/apps/api/src/__tests__/exchange-trade-repository.test.ts b/apps/api/src/__tests__/exchange-trade-repository.test.ts new file mode 100644 index 0000000..342ad5f --- /dev/null +++ b/apps/api/src/__tests__/exchange-trade-repository.test.ts @@ -0,0 +1,382 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { PostgresStorage } from "@crypto-wallet/storage"; + +import { ExchangeTradeRepository } from "../exchange/exchange-trade-repository.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for exchange trade repository tests"); +} + +describe("ExchangeTradeRepository", () => { + async function createFixtures(storage: PostgresStorage) { + const buyerExchangeAccountId = randomUUID(); + const sellerExchangeAccountId = randomUUID(); + + const networkId = randomUUID(); + const baseAssetId = randomUUID(); + const quoteAssetId = randomUUID(); + + const buyerBaseAccountId = randomUUID(); + const buyerQuoteAccountId = randomUUID(); + const sellerBaseAccountId = randomUUID(); + const sellerQuoteAccountId = randomUUID(); + + await storage.query( + ` + INSERT INTO exchange_accounts ( + id, + owner_type, + owner_id, + kind + ) + VALUES + ($1, 'customer', $3, 'customer'), + ($2, 'customer', $4, 'customer') + `, + [buyerExchangeAccountId, sellerExchangeAccountId, randomUUID(), randomUUID()], + ); + + await storage.query( + ` + INSERT INTO networks ( + id, + key, + name, + chain, + environment + ) + VALUES ($1, $2, $3, $4, $5) + `, + [networkId, `trade-test-${networkId}`, "Trade Test Network", "trade-test-chain", "test"], + ); + + await storage.query( + ` + INSERT INTO assets ( + id, + network_id, + symbol, + name, + decimals, + asset_type + ) + VALUES + ($1, $3, $5, 'Trade Base Asset', 18, 'native'), + ($2, $4, $6, 'Trade Quote Asset', 6, 'native') + `, + [ + baseAssetId, + quoteAssetId, + networkId, + networkId, + `B${baseAssetId.slice(0, 6)}`, + `Q${quoteAssetId.slice(0, 6)}`, + ], + ); + + await storage.query( + ` + INSERT INTO exchange_asset_accounts ( + id, + exchange_account_id, + asset_id, + network_id + ) + VALUES + ($1, $5, $9, $13), + ($2, $6, $10, $13), + ($3, $7, $11, $13), + ($4, $8, $12, $13) + `, + [ + buyerBaseAccountId, + buyerQuoteAccountId, + sellerBaseAccountId, + sellerQuoteAccountId, + buyerExchangeAccountId, + buyerExchangeAccountId, + sellerExchangeAccountId, + sellerExchangeAccountId, + baseAssetId, + quoteAssetId, + baseAssetId, + quoteAssetId, + networkId, + ], + ); + + return { + buyerExchangeAccountId, + sellerExchangeAccountId, + networkId, + baseAssetId, + quoteAssetId, + buyerBaseAccountId, + buyerQuoteAccountId, + sellerBaseAccountId, + sellerQuoteAccountId, + }; + } + + async function cleanupFixtures( + storage: PostgresStorage, + fixtures: { + buyerExchangeAccountId: string; + sellerExchangeAccountId: string; + networkId: string; + baseAssetId: string; + quoteAssetId: string; + }, + ) { + await storage.query( + ` + DELETE FROM exchange_trades + WHERE buyer_base_exchange_asset_account_id IN ( + SELECT id + FROM exchange_asset_accounts + WHERE exchange_account_id IN ($1, $2) + ) + OR buyer_quote_exchange_asset_account_id IN ( + SELECT id + FROM exchange_asset_accounts + WHERE exchange_account_id IN ($1, $2) + ) + OR seller_base_exchange_asset_account_id IN ( + SELECT id + FROM exchange_asset_accounts + WHERE exchange_account_id IN ($1, $2) + ) + OR seller_quote_exchange_asset_account_id IN ( + SELECT id + FROM exchange_asset_accounts + WHERE exchange_account_id IN ($1, $2) + ) + `, + [fixtures.buyerExchangeAccountId, fixtures.sellerExchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_asset_accounts + WHERE exchange_account_id IN ($1, $2) + `, + [fixtures.buyerExchangeAccountId, fixtures.sellerExchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE id IN ($1, $2) + `, + [fixtures.buyerExchangeAccountId, fixtures.sellerExchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM assets + WHERE id IN ($1, $2) + `, + [fixtures.baseAssetId, fixtures.quoteAssetId], + ); + + await storage.query( + ` + DELETE FROM networks + WHERE id = $1 + `, + [fixtures.networkId], + ); + } + + it("creates and finds a trade", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeTradeRepository(storage); + + let fixtures: Awaited> | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const created = await repository.create({ + buyerBaseExchangeAssetAccountId: fixtures.buyerBaseAccountId, + buyerQuoteExchangeAssetAccountId: fixtures.buyerQuoteAccountId, + sellerBaseExchangeAssetAccountId: fixtures.sellerBaseAccountId, + sellerQuoteExchangeAssetAccountId: fixtures.sellerQuoteAccountId, + baseAssetId: fixtures.baseAssetId, + quoteAssetId: fixtures.quoteAssetId, + baseAmount: "1250000", + quoteAmount: "2500000", + priceNumerator: "2", + priceDenominator: "1", + reference: `trade-${randomUUID()}`, + }); + + expect(created).toMatchObject({ + buyerBaseExchangeAssetAccountId: fixtures.buyerBaseAccountId, + buyerQuoteExchangeAssetAccountId: fixtures.buyerQuoteAccountId, + sellerBaseExchangeAssetAccountId: fixtures.sellerBaseAccountId, + sellerQuoteExchangeAssetAccountId: fixtures.sellerQuoteAccountId, + baseAssetId: fixtures.baseAssetId, + quoteAssetId: fixtures.quoteAssetId, + baseAmount: "1250000", + quoteAmount: "2500000", + priceNumerator: "2", + priceDenominator: "1", + status: "executed", + }); + + expect(created.executedAt).toBeInstanceOf(Date); + + const found = await repository.findById(created.id); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures(storage, fixtures); + } + + await storage.disconnect(); + } + }); + + it("finds a trade by reference", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeTradeRepository(storage); + + let fixtures: Awaited> | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const reference = `trade-${randomUUID()}`; + + const created = await repository.create({ + buyerBaseExchangeAssetAccountId: fixtures.buyerBaseAccountId, + buyerQuoteExchangeAssetAccountId: fixtures.buyerQuoteAccountId, + sellerBaseExchangeAssetAccountId: fixtures.sellerBaseAccountId, + sellerQuoteExchangeAssetAccountId: fixtures.sellerQuoteAccountId, + baseAssetId: fixtures.baseAssetId, + quoteAssetId: fixtures.quoteAssetId, + baseAmount: "1000000", + quoteAmount: "3000000", + priceNumerator: "3", + priceDenominator: "1", + reference, + }); + + const found = await repository.findByReference(reference); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures(storage, fixtures); + } + + await storage.disconnect(); + } + }); + + it("preserves an explicitly supplied reversed status", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeTradeRepository(storage); + + let fixtures: Awaited> | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const created = await repository.create({ + buyerBaseExchangeAssetAccountId: fixtures.buyerBaseAccountId, + buyerQuoteExchangeAssetAccountId: fixtures.buyerQuoteAccountId, + sellerBaseExchangeAssetAccountId: fixtures.sellerBaseAccountId, + sellerQuoteExchangeAssetAccountId: fixtures.sellerQuoteAccountId, + baseAssetId: fixtures.baseAssetId, + quoteAssetId: fixtures.quoteAssetId, + baseAmount: "1000000", + quoteAmount: "2000000", + priceNumerator: "2", + priceDenominator: "1", + status: "reversed", + reference: `trade-${randomUUID()}`, + }); + + expect(created.status).toBe("reversed"); + } finally { + if (fixtures) { + await cleanupFixtures(storage, fixtures); + } + + await storage.disconnect(); + } + }); + + it("returns null for an unknown trade", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeTradeRepository(storage); + + try { + await storage.connect(); + + const found = await repository.findById(randomUUID()); + + expect(found).toBeNull(); + } finally { + await storage.disconnect(); + } + }); + + it("enforces unique trade references", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeTradeRepository(storage); + + let fixtures: Awaited> | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const reference = `duplicate-trade-${randomUUID()}`; + + const input = { + buyerBaseExchangeAssetAccountId: fixtures.buyerBaseAccountId, + buyerQuoteExchangeAssetAccountId: fixtures.buyerQuoteAccountId, + sellerBaseExchangeAssetAccountId: fixtures.sellerBaseAccountId, + sellerQuoteExchangeAssetAccountId: fixtures.sellerQuoteAccountId, + baseAssetId: fixtures.baseAssetId, + quoteAssetId: fixtures.quoteAssetId, + baseAmount: "1000000", + quoteAmount: "2000000", + priceNumerator: "2", + priceDenominator: "1", + reference, + }; + + await repository.create(input); + + await expect( + repository.create({ + ...input, + reference, + }), + ).rejects.toThrow(); + } finally { + if (fixtures) { + await cleanupFixtures(storage, fixtures); + } + + await storage.disconnect(); + } + }); +}); diff --git a/apps/api/src/__tests__/exchange-withdrawal-repository.test.ts b/apps/api/src/__tests__/exchange-withdrawal-repository.test.ts new file mode 100644 index 0000000..892b01f --- /dev/null +++ b/apps/api/src/__tests__/exchange-withdrawal-repository.test.ts @@ -0,0 +1,404 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { PostgresStorage } from "@crypto-wallet/storage"; + +import { ExchangeWithdrawalRepository } from "../exchange/exchange-withdrawal-repository.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for exchange withdrawal repository tests"); +} + +describe("ExchangeWithdrawalRepository", () => { + async function createFixtures(storage: PostgresStorage) { + const exchangeAccountId = randomUUID(); + const networkId = randomUUID(); + const assetId = randomUUID(); + const exchangeAssetAccountId = randomUUID(); + + await storage.query( + ` + INSERT INTO exchange_accounts ( + id, + owner_type, + owner_id, + kind + ) + VALUES ($1, 'customer', $2, 'customer') + `, + [exchangeAccountId, randomUUID()], + ); + + await storage.query( + ` + INSERT INTO networks ( + id, + key, + name, + chain, + environment + ) + VALUES ($1, $2, $3, $4, $5) + `, + [ + networkId, + `withdrawal-test-${networkId}`, + "Withdrawal Test Network", + "withdrawal-test-chain", + "test", + ], + ); + + await storage.query( + ` + INSERT INTO assets ( + id, + network_id, + symbol, + name, + decimals, + asset_type + ) + VALUES ($1, $2, $3, $4, $5, $6) + `, + [assetId, networkId, `W${assetId.slice(0, 6)}`, "Withdrawal Test Asset", 18, "native"], + ); + + await storage.query( + ` + INSERT INTO exchange_asset_accounts ( + id, + exchange_account_id, + asset_id, + network_id + ) + VALUES ($1, $2, $3, $4) + `, + [exchangeAssetAccountId, exchangeAccountId, assetId, networkId], + ); + + return { + exchangeAccountId, + networkId, + assetId, + exchangeAssetAccountId, + }; + } + + async function cleanupFixtures( + storage: PostgresStorage, + exchangeAccountId: string, + networkId: string, + assetId: string, + ) { + await storage.query( + ` + DELETE FROM exchange_withdrawals + WHERE exchange_asset_account_id IN ( + SELECT id + FROM exchange_asset_accounts + WHERE exchange_account_id = $1 + ) + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_asset_accounts + WHERE exchange_account_id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM exchange_accounts + WHERE id = $1 + `, + [exchangeAccountId], + ); + + await storage.query( + ` + DELETE FROM assets + WHERE id = $1 + `, + [assetId], + ); + + await storage.query( + ` + DELETE FROM networks + WHERE id = $1 + `, + [networkId], + ); + } + + it("creates and finds a withdrawal", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeWithdrawalRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + exchangeAssetAccountId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const created = await repository.create({ + requestedBy: randomUUID(), + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + destination: `destination-${randomUUID()}`, + amount: "1250000", + reference: `withdrawal-${randomUUID()}`, + }); + + expect(created).toMatchObject({ + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + amount: "1250000", + status: "requested", + }); + + expect(created.createdAt).toBeInstanceOf(Date); + + const found = await repository.findById(created.id); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); + + it("finds a withdrawal by reference", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeWithdrawalRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + exchangeAssetAccountId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const reference = `withdrawal-${randomUUID()}`; + + const created = await repository.create({ + requestedBy: randomUUID(), + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + destination: `destination-${randomUUID()}`, + amount: "2500000", + reference, + }); + + const found = await repository.findByReference(reference); + + expect(found).toEqual(created); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); + + it("preserves an explicitly supplied withdrawal status", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeWithdrawalRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + exchangeAssetAccountId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const requestedBy = randomUUID(); + const approvedBy = randomUUID(); + + const created = await repository.create({ + requestedBy, + approvedBy, + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + destination: `destination-${randomUUID()}`, + amount: "3000000", + status: "approved", + reference: `withdrawal-${randomUUID()}`, + }); + + expect(created.requestedBy).toBe(requestedBy); + expect(created.approvedBy).toBe(approvedBy); + expect(created.status).toBe("approved"); + + const found = await repository.findById(created.id); + + expect(found?.status).toBe("approved"); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); + + it("returns null for an unknown withdrawal", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeWithdrawalRepository(storage); + + try { + await storage.connect(); + + const found = await repository.findById(randomUUID()); + + expect(found).toBeNull(); + } finally { + await storage.disconnect(); + } + }); + + it("enforces unique withdrawal references", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeWithdrawalRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + exchangeAssetAccountId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const reference = `duplicate-withdrawal-${randomUUID()}`; + + const input = { + requestedBy: randomUUID(), + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + destination: `destination-${randomUUID()}`, + amount: "1000000", + reference, + }; + + await repository.create(input); + + await expect( + repository.create({ + ...input, + destination: `destination-${randomUUID()}`, + }), + ).rejects.toThrow(); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); + it("rejects an approved withdrawal when requester and approver are the same actor", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new ExchangeWithdrawalRepository(storage); + + let fixtures: + | { + exchangeAccountId: string; + networkId: string; + assetId: string; + exchangeAssetAccountId: string; + } + | undefined; + + try { + await storage.connect(); + + fixtures = await createFixtures(storage); + + const actorId = randomUUID(); + + await expect( + repository.create({ + requestedBy: actorId, + approvedBy: actorId, + exchangeAssetAccountId: fixtures.exchangeAssetAccountId, + networkId: fixtures.networkId, + destination: `destination-${randomUUID()}`, + amount: "4000000", + status: "approved", + reference: `withdrawal-${randomUUID()}`, + }), + ).rejects.toThrow(); + } finally { + if (fixtures) { + await cleanupFixtures( + storage, + fixtures.exchangeAccountId, + fixtures.networkId, + fixtures.assetId, + ); + } + + await storage.disconnect(); + } + }); +}); diff --git a/apps/api/src/__tests__/reconciliation-repository.test.ts b/apps/api/src/__tests__/reconciliation-repository.test.ts new file mode 100644 index 0000000..c3f731f --- /dev/null +++ b/apps/api/src/__tests__/reconciliation-repository.test.ts @@ -0,0 +1,212 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { PostgresStorage } from "@crypto-wallet/storage"; + +import { PostgresReconciliationRepository } from "../reconciliation/reconciliation-repository.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for reconciliation repository tests"); +} + +describe("PostgresReconciliationRepository", () => { + async function createFixtures(storage: PostgresStorage) { + const assetId = randomUUID(); + const networkId = randomUUID(); + const accountId = randomUUID(); + + await storage.query( + ` + INSERT INTO networks ( + id, + key, + name, + chain, + environment + ) + VALUES ($1, $2, $3, $4, $5) + `, + [ + networkId, + `reconciliation-test-${networkId.slice(0, 6)}`, + "Reconciliation Test Network", + "test", + "test", + ], + ); + + await storage.query( + ` + INSERT INTO assets ( + id, + network_id, + symbol, + name, + decimals, + asset_type + ) + VALUES ($1, $2, $3, $4, $5, $6) + `, + [assetId, networkId, `REC${assetId.slice(0, 4)}`, "Reconciliation Test Asset", 18, "native"], + ); + + return { + assetId, + networkId, + accountId, + }; + } + + async function cleanupFixtures(storage: PostgresStorage, assetId: string, networkId: string) { + await storage.query( + ` + DELETE FROM reconciliation_comparisons + WHERE asset_id = $1 + `, + [assetId], + ); + + await storage.query( + ` + DELETE FROM assets + WHERE id = $1 + `, + [assetId], + ); + + await storage.query( + ` + DELETE FROM networks + WHERE id = $1 + `, + [networkId], + ); + } + + it("saves and retrieves the latest reconciliation comparison", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new PostgresReconciliationRepository(storage); + + await storage.connect(); + + const { assetId, networkId, accountId } = await createFixtures(storage); + + const comparison = { + scope: { + assetId, + networkId, + accountId, + }, + expected: { + source: "ledger" as const, + scope: { + assetId, + networkId, + accountId, + }, + status: "available" as const, + amount: "1000000", + observedAt: "2026-09-12T00:00:00.000Z", + reference: "ledger-test-reference", + }, + actual: { + source: "custody" as const, + scope: { + assetId, + networkId, + accountId, + }, + status: "available" as const, + amount: "999500", + observedAt: "2026-09-12T00:00:01.000Z", + reference: "custody-test-reference", + }, + difference: "-500", + status: "mismatched" as const, + }; + + try { + await repository.save(comparison); + + const result = await repository.findLatest(assetId, networkId, accountId); + + expect(result).not.toBeNull(); + expect(result).toEqual(comparison); + } finally { + await cleanupFixtures(storage, assetId, networkId); + await storage.disconnect(); + } + }); + + it("returns null for an unknown reconciliation scope", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new PostgresReconciliationRepository(storage); + + await storage.connect(); + + try { + const result = await repository.findLatest(randomUUID(), randomUUID(), randomUUID()); + + expect(result).toBeNull(); + } finally { + await storage.disconnect(); + } + }); + it("persists an unavailable observation without inventing a zero amount", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new PostgresReconciliationRepository(storage); + + await storage.connect(); + + const { assetId, networkId, accountId } = await createFixtures(storage); + + const comparison = { + scope: { + assetId, + networkId, + accountId, + }, + expected: { + source: "ledger" as const, + scope: { + assetId, + networkId, + accountId, + }, + status: "available" as const, + amount: "1000000", + observedAt: "2026-09-12T00:00:00.000Z", + }, + actual: { + source: "custody" as const, + scope: { + assetId, + networkId, + accountId, + }, + status: "unavailable" as const, + amount: null, + observedAt: "2026-09-12T00:00:01.000Z", + }, + difference: null, + status: "unavailable" as const, + }; + + try { + await repository.save(comparison); + + const result = await repository.findLatest(assetId, networkId, accountId); + + expect(result).toEqual(comparison); + expect(result?.actual.status).toBe("unavailable"); + expect(result?.actual.amount).toBeNull(); + expect(result?.difference).toBeNull(); + } finally { + await cleanupFixtures(storage, assetId, networkId); + await storage.disconnect(); + } + }); +}); diff --git a/apps/api/src/__tests__/reconciliation-service.test.ts b/apps/api/src/__tests__/reconciliation-service.test.ts new file mode 100644 index 0000000..538306c --- /dev/null +++ b/apps/api/src/__tests__/reconciliation-service.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import type { + ReconciliationComparison, + ReconciliationRepository, +} from "@crypto-wallet/ledger-core"; + +import { ReconciliationService } from "../reconciliation/reconciliation-service.js"; + +class InMemoryReconciliationRepository implements ReconciliationRepository { + private latest: ReconciliationComparison | null = null; + + async save(comparison: ReconciliationComparison): Promise { + this.latest = comparison; + } + + async findLatest( + assetId: string, + networkId?: string, + accountId?: string, + ): Promise { + if (this.latest === null) { + return null; + } + + const scope = this.latest.scope; + + if ( + scope.assetId !== assetId || + scope.networkId !== networkId || + scope.accountId !== accountId + ) { + return null; + } + + return this.latest; + } +} + +describe("ReconciliationService", () => { + it("compares observations and persists the result", async () => { + const repository = new InMemoryReconciliationRepository(); + const service = new ReconciliationService(repository); + + const scope = { + assetId: "usdc", + networkId: "ethereum", + accountId: "exchange-asset-account-1", + }; + + const expected = { + source: "ledger" as const, + scope, + status: "available" as const, + amount: "1000000", + observedAt: "2026-09-12T00:00:00.000Z", + }; + + const actual = { + source: "custody" as const, + scope, + status: "available" as const, + amount: "999500", + observedAt: "2026-09-12T00:00:01.000Z", + }; + + const result = await service.reconcile(expected, actual); + + expect(result.status).toBe("mismatched"); + expect(result.difference).toBe("-500"); + + await expect( + service.findLatest(scope.assetId, scope.networkId, scope.accountId), + ).resolves.toEqual(result); + }); + + it("returns null when no reconciliation exists", async () => { + const repository = new InMemoryReconciliationRepository(); + const service = new ReconciliationService(repository); + + await expect( + service.findLatest("missing-asset", "missing-network", "missing-account"), + ).resolves.toBeNull(); + }); +}); diff --git a/apps/api/src/exchange/exchange-account-repository.ts b/apps/api/src/exchange/exchange-account-repository.ts new file mode 100644 index 0000000..f581722 --- /dev/null +++ b/apps/api/src/exchange/exchange-account-repository.ts @@ -0,0 +1,140 @@ +import { randomUUID } from "node:crypto"; + +import type { Storage } from "@crypto-wallet/storage"; + +export type ExchangeAccountOwnerType = "customer" | "platform"; + +export type ExchangeAccountKind = "customer" | "treasury" | "fee" | "operational"; + +export type ExchangeAccountStatus = "active" | "blocked"; + +export interface ExchangeAccountRecord { + id: string; + ownerType: ExchangeAccountOwnerType; + ownerId: string; + kind: ExchangeAccountKind; + status: ExchangeAccountStatus; + createdAt: Date; +} + +export interface CreateExchangeAccountInput { + ownerType: ExchangeAccountOwnerType; + ownerId: string; + kind: ExchangeAccountKind; + status?: ExchangeAccountStatus; +} + +interface ExchangeAccountRow { + id: string; + owner_type: ExchangeAccountOwnerType; + owner_id: string; + kind: ExchangeAccountKind; + status: ExchangeAccountStatus; + created_at: Date; +} + +export class ExchangeAccountRepository { + constructor(private readonly storage: Storage) {} + + async create(input: CreateExchangeAccountInput): Promise { + const id = randomUUID(); + + const result = await this.storage.query( + ` + INSERT INTO exchange_accounts ( + id, + owner_type, + owner_id, + kind, + status + ) + VALUES ($1, $2, $3, $4, COALESCE($5, 'active')) + RETURNING + id, + owner_type, + owner_id, + kind, + status, + created_at + `, + [id, input.ownerType, input.ownerId, input.kind, input.status ?? null], + ); + + const row = result.rows[0]; + + if (!row) { + throw new Error("Failed to create exchange account"); + } + + return mapExchangeAccount(row); + } + + async findById(id: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + owner_type, + owner_id, + kind, + status, + created_at + FROM exchange_accounts + WHERE id = $1 + LIMIT 1 + `, + [id], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeAccount(row); + } + + async findByOwnerAndKind( + ownerType: ExchangeAccountOwnerType, + ownerId: string, + kind: ExchangeAccountKind, + ): Promise { + const result = await this.storage.query( + ` + SELECT + id, + owner_type, + owner_id, + kind, + status, + created_at + FROM exchange_accounts + WHERE owner_type = $1 + AND owner_id = $2 + AND kind = $3 + LIMIT 1 + `, + [ownerType, ownerId, kind], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeAccount(row); + } +} + +function mapExchangeAccount(row: ExchangeAccountRow): ExchangeAccountRecord { + return { + id: row.id, + ownerType: row.owner_type, + ownerId: row.owner_id, + kind: row.kind, + status: row.status, + createdAt: row.created_at, + }; +} diff --git a/apps/api/src/exchange/exchange-asset-account-repository.ts b/apps/api/src/exchange/exchange-asset-account-repository.ts new file mode 100644 index 0000000..5a36387 --- /dev/null +++ b/apps/api/src/exchange/exchange-asset-account-repository.ts @@ -0,0 +1,143 @@ +import { randomUUID } from "node:crypto"; + +import type { Storage } from "@crypto-wallet/storage"; + +export interface ExchangeAssetAccountRecord { + id: string; + exchangeAccountId: string; + assetId: string; + networkId: string; + createdAt: Date; +} + +export interface CreateExchangeAssetAccountInput { + exchangeAccountId: string; + assetId: string; + networkId: string; +} + +interface ExchangeAssetAccountRow { + id: string; + exchange_account_id: string; + asset_id: string; + network_id: string; + created_at: Date; +} + +export class ExchangeAssetAccountRepository { + constructor(private readonly storage: Storage) {} + + async create(input: CreateExchangeAssetAccountInput): Promise { + const id = randomUUID(); + + const result = await this.storage.query( + ` + INSERT INTO exchange_asset_accounts ( + id, + exchange_account_id, + asset_id, + network_id + ) + VALUES ($1, $2, $3, $4) + RETURNING + id, + exchange_account_id, + asset_id, + network_id, + created_at + `, + [id, input.exchangeAccountId, input.assetId, input.networkId], + ); + + const row = result.rows[0]; + + if (!row) { + throw new Error("Failed to create exchange asset account"); + } + + return mapExchangeAssetAccount(row); + } + + async findById(id: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + exchange_account_id, + asset_id, + network_id, + created_at + FROM exchange_asset_accounts + WHERE id = $1 + LIMIT 1 + `, + [id], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeAssetAccount(row); + } + + async findByAccountAndAsset( + exchangeAccountId: string, + assetId: string, + ): Promise { + const result = await this.storage.query( + ` + SELECT + id, + exchange_account_id, + asset_id, + network_id, + created_at + FROM exchange_asset_accounts + WHERE exchange_account_id = $1 + AND asset_id = $2 + LIMIT 1 + `, + [exchangeAccountId, assetId], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeAssetAccount(row); + } + + async listByExchangeAccount(exchangeAccountId: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + exchange_account_id, + asset_id, + network_id, + created_at + FROM exchange_asset_accounts + WHERE exchange_account_id = $1 + ORDER BY created_at ASC, id ASC + `, + [exchangeAccountId], + ); + + return result.rows.map(mapExchangeAssetAccount); + } +} + +function mapExchangeAssetAccount(row: ExchangeAssetAccountRow): ExchangeAssetAccountRecord { + return { + id: row.id, + exchangeAccountId: row.exchange_account_id, + assetId: row.asset_id, + networkId: row.network_id, + createdAt: row.created_at, + }; +} diff --git a/apps/api/src/exchange/exchange-deposit-repository.ts b/apps/api/src/exchange/exchange-deposit-repository.ts new file mode 100644 index 0000000..03d59b6 --- /dev/null +++ b/apps/api/src/exchange/exchange-deposit-repository.ts @@ -0,0 +1,186 @@ +import { randomUUID } from "node:crypto"; + +import type { Storage } from "@crypto-wallet/storage"; + +export type ExchangeDepositStatus = "detected" | "confirmed" | "credited" | "reversed"; + +export interface ExchangeDepositRecord { + id: string; + exchangeAssetAccountId: string; + networkId: string; + transactionHash: string; + amount: string; + status: ExchangeDepositStatus; + reference: string; + createdAt: Date; +} + +export interface CreateExchangeDepositInput { + exchangeAssetAccountId: string; + networkId: string; + transactionHash: string; + amount: string; + status?: ExchangeDepositStatus; + reference: string; +} + +interface ExchangeDepositRow { + id: string; + exchange_asset_account_id: string; + network_id: string; + transaction_hash: string; + amount: string; + status: ExchangeDepositStatus; + reference: string; + created_at: Date; +} + +export class ExchangeDepositRepository { + constructor(private readonly storage: Storage) {} + + async create(input: CreateExchangeDepositInput): Promise { + const id = randomUUID(); + + const result = await this.storage.query( + ` + INSERT INTO exchange_deposits ( + id, + exchange_asset_account_id, + network_id, + transaction_hash, + amount, + status, + reference + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING + id, + exchange_asset_account_id, + network_id, + transaction_hash, + amount, + status, + reference, + created_at + `, + [ + id, + input.exchangeAssetAccountId, + input.networkId, + input.transactionHash, + input.amount, + input.status ?? "detected", + input.reference, + ], + ); + + const row = result.rows[0]; + + if (!row) { + throw new Error("Failed to create exchange deposit"); + } + + return mapExchangeDeposit(row); + } + + async findById(id: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + exchange_asset_account_id, + network_id, + transaction_hash, + amount, + status, + reference, + created_at + FROM exchange_deposits + WHERE id = $1 + LIMIT 1 + `, + [id], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeDeposit(row); + } + + async findByReference(reference: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + exchange_asset_account_id, + network_id, + transaction_hash, + amount, + status, + reference, + created_at + FROM exchange_deposits + WHERE reference = $1 + LIMIT 1 + `, + [reference], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeDeposit(row); + } + + async findByTransaction( + networkId: string, + transactionHash: string, + ): Promise { + const result = await this.storage.query( + ` + SELECT + id, + exchange_asset_account_id, + network_id, + transaction_hash, + amount, + status, + reference, + created_at + FROM exchange_deposits + WHERE network_id = $1 + AND transaction_hash = $2 + LIMIT 1 + `, + [networkId, transactionHash], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeDeposit(row); + } +} + +function mapExchangeDeposit(row: ExchangeDepositRow): ExchangeDepositRecord { + return { + id: row.id, + exchangeAssetAccountId: row.exchange_asset_account_id, + networkId: row.network_id, + transactionHash: row.transaction_hash, + amount: row.amount, + status: row.status, + reference: row.reference, + createdAt: row.created_at, + }; +} diff --git a/apps/api/src/exchange/exchange-fee-repository.ts b/apps/api/src/exchange/exchange-fee-repository.ts new file mode 100644 index 0000000..643d872 --- /dev/null +++ b/apps/api/src/exchange/exchange-fee-repository.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "node:crypto"; + +import type { Storage } from "@crypto-wallet/storage"; + +export type ExchangeFeeStatus = "charged" | "reversed"; + +export interface ExchangeFeeRecord { + id: string; + tradeId: string; + sourceExchangeAssetAccountId: string; + feeExchangeAssetAccountId: string; + assetId: string; + amount: string; + status: ExchangeFeeStatus; + reference: string; + createdAt: Date; +} + +export interface CreateExchangeFeeInput { + tradeId: string; + sourceExchangeAssetAccountId: string; + feeExchangeAssetAccountId: string; + assetId: string; + amount: string; + status?: ExchangeFeeStatus; + reference: string; +} + +interface ExchangeFeeRow { + id: string; + trade_id: string; + source_exchange_asset_account_id: string; + fee_exchange_asset_account_id: string; + asset_id: string; + amount: string; + status: ExchangeFeeStatus; + reference: string; + created_at: Date; +} + +export class ExchangeFeeRepository { + constructor(private readonly storage: Storage) {} + + async create(input: CreateExchangeFeeInput): Promise { + const id = randomUUID(); + + const result = await this.storage.query( + ` + INSERT INTO exchange_fees ( + id, + trade_id, + source_exchange_asset_account_id, + fee_exchange_asset_account_id, + asset_id, + amount, + status, + reference + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING + id, + trade_id, + source_exchange_asset_account_id, + fee_exchange_asset_account_id, + asset_id, + amount, + status, + reference, + created_at + `, + [ + id, + input.tradeId, + input.sourceExchangeAssetAccountId, + input.feeExchangeAssetAccountId, + input.assetId, + input.amount, + input.status ?? "charged", + input.reference, + ], + ); + + const row = result.rows[0]; + + if (!row) { + throw new Error("Failed to create exchange fee"); + } + + return mapExchangeFee(row); + } + + async findById(id: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + trade_id, + source_exchange_asset_account_id, + fee_exchange_asset_account_id, + asset_id, + amount, + status, + reference, + created_at + FROM exchange_fees + WHERE id = $1 + LIMIT 1 + `, + [id], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeFee(row); + } + + async findByReference(reference: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + trade_id, + source_exchange_asset_account_id, + fee_exchange_asset_account_id, + asset_id, + amount, + status, + reference, + created_at + FROM exchange_fees + WHERE reference = $1 + LIMIT 1 + `, + [reference], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeFee(row); + } +} + +function mapExchangeFee(row: ExchangeFeeRow): ExchangeFeeRecord { + return { + id: row.id, + tradeId: row.trade_id, + sourceExchangeAssetAccountId: row.source_exchange_asset_account_id, + feeExchangeAssetAccountId: row.fee_exchange_asset_account_id, + assetId: row.asset_id, + amount: row.amount, + status: row.status, + reference: row.reference, + createdAt: row.created_at, + }; +} diff --git a/apps/api/src/exchange/exchange-subaccount-repository.ts b/apps/api/src/exchange/exchange-subaccount-repository.ts new file mode 100644 index 0000000..d28beb1 --- /dev/null +++ b/apps/api/src/exchange/exchange-subaccount-repository.ts @@ -0,0 +1,106 @@ +import { randomUUID } from "node:crypto"; + +import type { Storage } from "@crypto-wallet/storage"; + +export interface ExchangeSubaccountRecord { + id: string; + exchangeAccountId: string; + name: string; + createdAt: Date; +} + +export interface CreateExchangeSubaccountInput { + exchangeAccountId: string; + name: string; +} + +interface ExchangeSubaccountRow { + id: string; + exchange_account_id: string; + name: string; + created_at: Date; +} + +export class ExchangeSubaccountRepository { + constructor(private readonly storage: Storage) {} + + async create(input: CreateExchangeSubaccountInput): Promise { + const id = randomUUID(); + + const result = await this.storage.query( + ` + INSERT INTO exchange_subaccounts ( + id, + exchange_account_id, + name + ) + VALUES ($1, $2, $3) + RETURNING + id, + exchange_account_id, + name, + created_at + `, + [id, input.exchangeAccountId, input.name], + ); + + const row = result.rows[0]; + + if (!row) { + throw new Error("Failed to create exchange subaccount"); + } + + return mapExchangeSubaccount(row); + } + + async findById(id: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + exchange_account_id, + name, + created_at + FROM exchange_subaccounts + WHERE id = $1 + LIMIT 1 + `, + [id], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeSubaccount(row); + } + + async listByExchangeAccount(exchangeAccountId: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + exchange_account_id, + name, + created_at + FROM exchange_subaccounts + WHERE exchange_account_id = $1 + ORDER BY created_at ASC, id ASC + `, + [exchangeAccountId], + ); + + return result.rows.map(mapExchangeSubaccount); + } +} + +function mapExchangeSubaccount(row: ExchangeSubaccountRow): ExchangeSubaccountRecord { + return { + id: row.id, + exchangeAccountId: row.exchange_account_id, + name: row.name, + createdAt: row.created_at, + }; +} diff --git a/apps/api/src/exchange/exchange-trade-repository.ts b/apps/api/src/exchange/exchange-trade-repository.ts new file mode 100644 index 0000000..705f510 --- /dev/null +++ b/apps/api/src/exchange/exchange-trade-repository.ts @@ -0,0 +1,214 @@ +import { randomUUID } from "node:crypto"; + +import type { Storage } from "@crypto-wallet/storage"; + +export type ExchangeTradeStatus = "executed" | "reversed"; + +export interface ExchangeTradeRecord { + id: string; + buyerBaseExchangeAssetAccountId: string; + buyerQuoteExchangeAssetAccountId: string; + sellerBaseExchangeAssetAccountId: string; + sellerQuoteExchangeAssetAccountId: string; + baseAssetId: string; + quoteAssetId: string; + baseAmount: string; + quoteAmount: string; + priceNumerator: string; + priceDenominator: string; + status: ExchangeTradeStatus; + reference: string; + executedAt: Date; +} + +export interface CreateExchangeTradeInput { + buyerBaseExchangeAssetAccountId: string; + buyerQuoteExchangeAssetAccountId: string; + sellerBaseExchangeAssetAccountId: string; + sellerQuoteExchangeAssetAccountId: string; + baseAssetId: string; + quoteAssetId: string; + baseAmount: string; + quoteAmount: string; + priceNumerator: string; + priceDenominator: string; + status?: ExchangeTradeStatus; + reference: string; + executedAt?: Date; +} + +interface ExchangeTradeRow { + id: string; + buyer_base_exchange_asset_account_id: string; + buyer_quote_exchange_asset_account_id: string; + seller_base_exchange_asset_account_id: string; + seller_quote_exchange_asset_account_id: string; + base_asset_id: string; + quote_asset_id: string; + base_amount: string; + quote_amount: string; + price_numerator: string; + price_denominator: string; + status: ExchangeTradeStatus; + reference: string; + executed_at: Date; +} + +export class ExchangeTradeRepository { + constructor(private readonly storage: Storage) {} + + async create(input: CreateExchangeTradeInput): Promise { + const id = randomUUID(); + + const result = await this.storage.query( + ` + INSERT INTO exchange_trades ( + id, + buyer_base_exchange_asset_account_id, + buyer_quote_exchange_asset_account_id, + seller_base_exchange_asset_account_id, + seller_quote_exchange_asset_account_id, + base_asset_id, + quote_asset_id, + base_amount, + quote_amount, + price_numerator, + price_denominator, + status, + reference, + executed_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14 + ) + RETURNING + id, + buyer_base_exchange_asset_account_id, + buyer_quote_exchange_asset_account_id, + seller_base_exchange_asset_account_id, + seller_quote_exchange_asset_account_id, + base_asset_id, + quote_asset_id, + base_amount, + quote_amount, + price_numerator, + price_denominator, + status, + reference, + executed_at + `, + [ + id, + input.buyerBaseExchangeAssetAccountId, + input.buyerQuoteExchangeAssetAccountId, + input.sellerBaseExchangeAssetAccountId, + input.sellerQuoteExchangeAssetAccountId, + input.baseAssetId, + input.quoteAssetId, + input.baseAmount, + input.quoteAmount, + input.priceNumerator, + input.priceDenominator, + input.status ?? "executed", + input.reference, + input.executedAt ?? new Date(), + ], + ); + + const row = result.rows[0]; + + if (!row) { + throw new Error("Failed to create exchange trade"); + } + + return mapExchangeTrade(row); + } + + async findById(id: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + buyer_base_exchange_asset_account_id, + buyer_quote_exchange_asset_account_id, + seller_base_exchange_asset_account_id, + seller_quote_exchange_asset_account_id, + base_asset_id, + quote_asset_id, + base_amount, + quote_amount, + price_numerator, + price_denominator, + status, + reference, + executed_at + FROM exchange_trades + WHERE id = $1 + LIMIT 1 + `, + [id], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeTrade(row); + } + + async findByReference(reference: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + buyer_base_exchange_asset_account_id, + buyer_quote_exchange_asset_account_id, + seller_base_exchange_asset_account_id, + seller_quote_exchange_asset_account_id, + base_asset_id, + quote_asset_id, + base_amount, + quote_amount, + price_numerator, + price_denominator, + status, + reference, + executed_at + FROM exchange_trades + WHERE reference = $1 + LIMIT 1 + `, + [reference], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeTrade(row); + } +} + +function mapExchangeTrade(row: ExchangeTradeRow): ExchangeTradeRecord { + return { + id: row.id, + buyerBaseExchangeAssetAccountId: row.buyer_base_exchange_asset_account_id, + buyerQuoteExchangeAssetAccountId: row.buyer_quote_exchange_asset_account_id, + sellerBaseExchangeAssetAccountId: row.seller_base_exchange_asset_account_id, + sellerQuoteExchangeAssetAccountId: row.seller_quote_exchange_asset_account_id, + baseAssetId: row.base_asset_id, + quoteAssetId: row.quote_asset_id, + baseAmount: row.base_amount, + quoteAmount: row.quote_amount, + priceNumerator: row.price_numerator, + priceDenominator: row.price_denominator, + status: row.status, + reference: row.reference, + executedAt: row.executed_at, + }; +} diff --git a/apps/api/src/exchange/exchange-withdrawal-repository.ts b/apps/api/src/exchange/exchange-withdrawal-repository.ts new file mode 100644 index 0000000..f4e1cd4 --- /dev/null +++ b/apps/api/src/exchange/exchange-withdrawal-repository.ts @@ -0,0 +1,173 @@ +import { randomUUID } from "node:crypto"; + +import type { Storage } from "@crypto-wallet/storage"; + +export type ExchangeWithdrawalStatus = + "requested" | "approved" | "rejected" | "submitted" | "confirmed" | "failed"; + +export interface ExchangeWithdrawalRecord { + id: string; + requestedBy: string; + approvedBy: string | null; + exchangeAssetAccountId: string; + networkId: string; + destination: string; + amount: string; + status: ExchangeWithdrawalStatus; + reference: string; + createdAt: Date; +} + +export interface CreateExchangeWithdrawalInput { + requestedBy: string; + approvedBy?: string; + exchangeAssetAccountId: string; + networkId: string; + destination: string; + amount: string; + status?: ExchangeWithdrawalStatus; + reference: string; +} + +interface ExchangeWithdrawalRow { + id: string; + requested_by: string; + approved_by: string | null; + exchange_asset_account_id: string; + network_id: string; + destination: string; + amount: string; + status: ExchangeWithdrawalStatus; + reference: string; + created_at: Date; +} + +export class ExchangeWithdrawalRepository { + constructor(private readonly storage: Storage) {} + + async create(input: CreateExchangeWithdrawalInput): Promise { + const id = randomUUID(); + + const result = await this.storage.query( + ` + INSERT INTO exchange_withdrawals ( + id, + requested_by, + approved_by, + exchange_asset_account_id, + network_id, + destination, + amount, + status, + reference + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING + id, + requested_by, + approved_by, + exchange_asset_account_id, + network_id, + destination, + amount, + status, + reference, + created_at + `, + [ + id, + input.requestedBy, + input.approvedBy ?? null, + input.exchangeAssetAccountId, + input.networkId, + input.destination, + input.amount, + input.status ?? "requested", + input.reference, + ], + ); + + const row = result.rows[0]; + + if (!row) { + throw new Error("Failed to create exchange withdrawal"); + } + + return mapExchangeWithdrawal(row); + } + + async findById(id: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + requested_by, + approved_by, + exchange_asset_account_id, + network_id, + destination, + amount, + status, + reference, + created_at + FROM exchange_withdrawals + WHERE id = $1 + LIMIT 1 + `, + [id], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeWithdrawal(row); + } + + async findByReference(reference: string): Promise { + const result = await this.storage.query( + ` + SELECT + id, + requested_by, + approved_by, + exchange_asset_account_id, + network_id, + destination, + amount, + status, + reference, + created_at + FROM exchange_withdrawals + WHERE reference = $1 + LIMIT 1 + `, + [reference], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapExchangeWithdrawal(row); + } +} + +function mapExchangeWithdrawal(row: ExchangeWithdrawalRow): ExchangeWithdrawalRecord { + return { + id: row.id, + requestedBy: row.requested_by, + approvedBy: row.approved_by, + exchangeAssetAccountId: row.exchange_asset_account_id, + networkId: row.network_id, + destination: row.destination, + amount: row.amount, + status: row.status, + reference: row.reference, + createdAt: row.created_at, + }; +} diff --git a/apps/api/src/reconciliation/index.ts b/apps/api/src/reconciliation/index.ts new file mode 100644 index 0000000..4f5fdcb --- /dev/null +++ b/apps/api/src/reconciliation/index.ts @@ -0,0 +1,2 @@ +export * from "./reconciliation-repository.js"; +export * from "./reconciliation-service.js"; diff --git a/apps/api/src/reconciliation/reconciliation-repository.ts b/apps/api/src/reconciliation/reconciliation-repository.ts new file mode 100644 index 0000000..b16362a --- /dev/null +++ b/apps/api/src/reconciliation/reconciliation-repository.ts @@ -0,0 +1,168 @@ +import { randomUUID } from "node:crypto"; + +import type { + ReconciliationComparison, + ReconciliationRepository, + ReconciliationSource, + ReconciliationStatus, +} from "@crypto-wallet/ledger-core"; +import type { Storage } from "@crypto-wallet/storage"; + +interface ReconciliationComparisonRow { + id: string; + asset_id: string; + network_id: string | null; + account_id: string | null; + expected_observation_status: "available" | "unavailable"; + actual_observation_status: "available" | "unavailable"; + expected_source: ReconciliationSource; + expected_amount: string | null; + actual_source: ReconciliationSource; + actual_amount: string | null; + difference: string | null; + status: ReconciliationStatus; + expected_observed_at: Date; + actual_observed_at: Date; + expected_reference: string | null; + actual_reference: string | null; + created_at: Date; +} + +export class PostgresReconciliationRepository implements ReconciliationRepository { + constructor(private readonly storage: Storage) {} + + async save(comparison: ReconciliationComparison): Promise { + await this.storage.query( + ` + INSERT INTO reconciliation_comparisons ( + id, + asset_id, + network_id, + account_id, + expected_source, + expected_amount, + actual_source, + actual_amount, + expected_observation_status, + actual_observation_status, + difference, + status, + expected_observed_at, + actual_observed_at, + expected_reference, + actual_reference + ) + VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13, + $14, + $15, + $16 + ) + `, + [ + randomUUID(), + comparison.scope.assetId, + comparison.scope.networkId ?? null, + comparison.scope.accountId ?? null, + comparison.expected.source, + comparison.expected.amount, + comparison.actual.source, + comparison.actual.amount, + comparison.expected.status, + comparison.actual.status, + comparison.difference, + comparison.status, + comparison.expected.observedAt, + comparison.actual.observedAt, + comparison.expected.reference ?? null, + comparison.actual.reference ?? null, + ], + ); + } + + async findLatest( + assetId: string, + networkId?: string, + accountId?: string, + ): Promise { + const result = await this.storage.query( + ` + SELECT + id, + asset_id, + network_id, + account_id, + expected_source, + expected_amount, + actual_source, + actual_amount, + expected_observation_status, + actual_observation_status, + difference, + status, + expected_observed_at, + actual_observed_at, + expected_reference, + actual_reference, + created_at + FROM reconciliation_comparisons + WHERE asset_id = $1 + AND network_id IS NOT DISTINCT FROM $2 + AND account_id IS NOT DISTINCT FROM $3 + ORDER BY created_at DESC + LIMIT 1 + `, + [assetId, networkId ?? null, accountId ?? null], + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + return mapReconciliationComparison(row); + } +} + +function mapReconciliationComparison(row: ReconciliationComparisonRow): ReconciliationComparison { + const scope = { + assetId: row.asset_id, + ...(row.network_id !== null ? { networkId: row.network_id } : {}), + ...(row.account_id !== null ? { accountId: row.account_id } : {}), + }; + + return { + scope, + expected: { + source: row.expected_source, + scope, + amount: row.expected_amount, + status: row.expected_observation_status, + observedAt: row.expected_observed_at.toISOString(), + ...(row.expected_reference !== null ? { reference: row.expected_reference } : {}), + }, + actual: { + source: row.actual_source, + scope, + amount: row.actual_amount, + status: row.actual_observation_status, + observedAt: row.actual_observed_at.toISOString(), + ...(row.actual_reference !== null ? { reference: row.actual_reference } : {}), + }, + difference: row.difference, + status: row.status, + }; +} diff --git a/apps/api/src/reconciliation/reconciliation-service.ts b/apps/api/src/reconciliation/reconciliation-service.ts new file mode 100644 index 0000000..a26e764 --- /dev/null +++ b/apps/api/src/reconciliation/reconciliation-service.ts @@ -0,0 +1,33 @@ +import { + DefaultReconciliationEngine, + type ReconciliationComparison, + type ReconciliationObservation, + type ReconciliationRepository, +} from "@crypto-wallet/ledger-core"; + +export class ReconciliationService { + private readonly engine: DefaultReconciliationEngine; + + constructor(private readonly repository: ReconciliationRepository) { + this.engine = new DefaultReconciliationEngine(); + } + + async reconcile( + expected: ReconciliationObservation, + actual: ReconciliationObservation, + ): Promise { + const comparison = this.engine.compare(expected, actual); + + await this.repository.save(comparison); + + return comparison; + } + + async findLatest( + assetId: string, + networkId?: string, + accountId?: string, + ): Promise { + return this.repository.findLatest(assetId, networkId, accountId); + } +} diff --git a/packages/ledger-core/package.json b/packages/ledger-core/package.json new file mode 100644 index 0000000..032d0c9 --- /dev/null +++ b/packages/ledger-core/package.json @@ -0,0 +1,26 @@ +{ + "name": "@crypto-wallet/ledger-core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@crypto-wallet/shared-types": "workspace:*", + "@crypto-wallet/storage": "workspace:*" + }, + "devDependencies": { + "vitest": "^4.1.11" + } +} diff --git a/packages/ledger-core/src/__tests__/account-registry.test.ts b/packages/ledger-core/src/__tests__/account-registry.test.ts new file mode 100644 index 0000000..cb8b0d1 --- /dev/null +++ b/packages/ledger-core/src/__tests__/account-registry.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { LedgerAccountRegistry } from "../account-registry.js"; +import type { LedgerAccountRecord } from "../ledger-types.js"; + +describe("LedgerAccountRegistry", () => { + const account: LedgerAccountRecord = { + id: "ledger-account-1", + walletId: "wallet-1", + assetId: "btc", + chain: "bitcoin", + kind: "asset", + createdAt: "2026-09-12T00:00:00.000Z", + }; + + it("creates and retrieves a ledger account", () => { + const registry = new LedgerAccountRegistry(); + + const created = registry.create(account); + + expect(created).toEqual(account); + expect(registry.getById(account.id)).toEqual(account); + }); + + it("rejects duplicate ledger account ids", () => { + const registry = new LedgerAccountRegistry(); + + registry.create(account); + + expect(() => registry.create(account)).toThrow( + "Ledger account already exists: ledger-account-1", + ); + }); + + it("rejects duplicate account identities", () => { + const registry = new LedgerAccountRegistry(); + + registry.create(account); + + expect(() => + registry.create({ + ...account, + id: "ledger-account-2", + }), + ).toThrow("Ledger account already exists for wallet, asset, chain and kind"); + }); + + it("returns null for an unknown account", () => { + const registry = new LedgerAccountRegistry(); + + expect(registry.getById("unknown")).toBeNull(); + }); + + it("does not expose mutable internal account state", () => { + const registry = new LedgerAccountRegistry(); + + const created = registry.create(account); + + expect(() => { + created.assetId = "mutated"; + }).toThrow(); + + const stored = registry.getById(account.id); + + expect(stored).toEqual(account); + }); +}); diff --git a/packages/ledger-core/src/__tests__/exchange-account-registry.test.ts b/packages/ledger-core/src/__tests__/exchange-account-registry.test.ts new file mode 100644 index 0000000..3a70f19 --- /dev/null +++ b/packages/ledger-core/src/__tests__/exchange-account-registry.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { ExchangeAccountRegistry, type ExchangeAccountRecord } from "../index.js"; + +function createAccount(overrides: Partial = {}): ExchangeAccountRecord { + return { + id: "exchange-account-1", + ownerType: "customer", + ownerId: "customer-1", + kind: "customer", + status: "active", + createdAt: "2026-09-12T00:00:00.000Z", + ...overrides, + }; +} + +describe("ExchangeAccountRegistry", () => { + it("creates and retrieves an exchange account", () => { + const registry = new ExchangeAccountRegistry(); + const account = createAccount(); + + const created = registry.create(account); + + expect(created).toEqual(account); + expect(registry.getById(account.id)).toEqual(account); + }); + + it("rejects duplicate account IDs", () => { + const registry = new ExchangeAccountRegistry(); + + registry.create(createAccount()); + + expect(() => registry.create(createAccount({ ownerId: "customer-2" }))).toThrow( + "Exchange account already exists", + ); + }); + + it("rejects duplicate owner and account kind", () => { + const registry = new ExchangeAccountRegistry(); + + registry.create(createAccount()); + + expect(() => registry.create(createAccount({ id: "exchange-account-2" }))).toThrow( + "Exchange account already exists for owner and account kind", + ); + }); + + it("returns null for an unknown account", () => { + const registry = new ExchangeAccountRegistry(); + + expect(registry.getById("unknown")).toBeNull(); + }); + + it("returns an immutable account", () => { + const registry = new ExchangeAccountRegistry(); + + const created = registry.create(createAccount()); + + expect(() => { + created.status = "blocked"; + }).toThrow(); + + expect(registry.getById(created.id)?.status).toBe("active"); + }); + + it("rejects an empty owner ID", () => { + const registry = new ExchangeAccountRegistry(); + + expect(() => registry.create(createAccount({ ownerId: " " }))).toThrow( + "Exchange account owner ID must not be empty", + ); + }); + + it("rejects customer-owned non-customer accounts", () => { + const registry = new ExchangeAccountRegistry(); + + expect(() => + registry.create( + createAccount({ + kind: "fee", + }), + ), + ).toThrow("Customer-owned exchange accounts must have customer kind"); + }); + + it("rejects platform-owned customer accounts", () => { + const registry = new ExchangeAccountRegistry(); + + expect(() => + registry.create( + createAccount({ + ownerType: "platform", + kind: "customer", + }), + ), + ).toThrow("Platform-owned exchange accounts cannot have customer kind"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/exchange-asset-account-registry.test.ts b/packages/ledger-core/src/__tests__/exchange-asset-account-registry.test.ts new file mode 100644 index 0000000..cf65347 --- /dev/null +++ b/packages/ledger-core/src/__tests__/exchange-asset-account-registry.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { ExchangeAssetAccountRegistry, type ExchangeAssetAccountRecord } from "../index.js"; + +function createAssetAccount( + overrides: Partial = {}, +): ExchangeAssetAccountRecord { + return { + id: "exchange-asset-account-1", + exchangeAccountId: "exchange-account-1", + assetId: "btc", + networkId: "ethereum", + createdAt: "2026-09-12T00:00:00.000Z", + ...overrides, + }; +} + +describe("ExchangeAssetAccountRegistry", () => { + it("creates and retrieves an exchange asset account", () => { + const registry = new ExchangeAssetAccountRegistry(); + const assetAccount = createAssetAccount(); + + const created = registry.create(assetAccount); + + expect(created).toEqual(assetAccount); + expect(registry.getById(assetAccount.id)).toEqual(assetAccount); + }); + + it("rejects duplicate asset-account IDs", () => { + const registry = new ExchangeAssetAccountRegistry(); + + registry.create(createAssetAccount()); + + expect(() => + registry.create( + createAssetAccount({ + exchangeAccountId: "exchange-account-2", + }), + ), + ).toThrow("Exchange asset account already exists"); + }); + + it("rejects duplicate asset for the same exchange account", () => { + const registry = new ExchangeAssetAccountRegistry(); + + registry.create(createAssetAccount()); + + expect(() => + registry.create( + createAssetAccount({ + id: "exchange-asset-account-2", + }), + ), + ).toThrow("Exchange asset account already exists for exchange account and asset"); + }); + + it("allows the same asset for different exchange accounts", () => { + const registry = new ExchangeAssetAccountRegistry(); + + registry.create(createAssetAccount()); + + const second = registry.create( + createAssetAccount({ + id: "exchange-asset-account-2", + exchangeAccountId: "exchange-account-2", + }), + ); + + expect(second.assetId).toBe("btc"); + expect(second.exchangeAccountId).toBe("exchange-account-2"); + }); + + it("allows different assets for the same exchange account", () => { + const registry = new ExchangeAssetAccountRegistry(); + + registry.create(createAssetAccount()); + + const second = registry.create( + createAssetAccount({ + id: "exchange-asset-account-2", + assetId: "eth", + }), + ); + + expect(second.exchangeAccountId).toBe("exchange-account-1"); + expect(second.assetId).toBe("eth"); + }); + + it("returns null for an unknown asset account", () => { + const registry = new ExchangeAssetAccountRegistry(); + + expect(registry.getById("unknown")).toBeNull(); + }); + + it("returns an immutable asset account", () => { + const registry = new ExchangeAssetAccountRegistry(); + + const created = registry.create(createAssetAccount()); + + expect(() => { + created.assetId = "mutated"; + }).toThrow(); + + expect(registry.getById(created.id)?.assetId).toBe("btc"); + }); + + it("rejects an empty exchange account ID", () => { + const registry = new ExchangeAssetAccountRegistry(); + + expect(() => + registry.create( + createAssetAccount({ + exchangeAccountId: " ", + }), + ), + ).toThrow("Exchange asset account exchange account ID must not be empty"); + }); + + it("rejects an empty asset ID", () => { + const registry = new ExchangeAssetAccountRegistry(); + + expect(() => + registry.create( + createAssetAccount({ + assetId: " ", + }), + ), + ).toThrow("Exchange asset account asset ID must not be empty"); + }); + it("rejects an empty network ID", () => { + const registry = new ExchangeAssetAccountRegistry(); + + expect(() => + registry.create( + createAssetAccount({ + networkId: " ", + }), + ), + ).toThrow("Exchange asset account network ID must not be empty"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/exchange-deposit-registry.test.ts b/packages/ledger-core/src/__tests__/exchange-deposit-registry.test.ts new file mode 100644 index 0000000..b0ea6f2 --- /dev/null +++ b/packages/ledger-core/src/__tests__/exchange-deposit-registry.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { ExchangeDepositRegistry, type ExchangeDepositRecord } from "../index.js"; + +function createDeposit(overrides: Partial = {}): ExchangeDepositRecord { + return { + id: "deposit-1", + exchangeAssetAccountId: "exchange-asset-account-1", + networkId: "ethereum", + transactionHash: "0xabc123", + amount: "1000000", + status: "detected", + reference: "deposit-ref-1", + createdAt: "2026-09-12T00:00:00.000Z", + ...overrides, + }; +} + +describe("ExchangeDepositRegistry", () => { + it("creates and retrieves a deposit", () => { + const registry = new ExchangeDepositRegistry(); + const deposit = createDeposit(); + + const created = registry.create(deposit); + + expect(created).toEqual(deposit); + expect(registry.getById(deposit.id)).toEqual(deposit); + }); + + it("rejects duplicate deposit IDs", () => { + const registry = new ExchangeDepositRegistry(); + + registry.create(createDeposit()); + + expect(() => + registry.create( + createDeposit({ + reference: "deposit-ref-2", + }), + ), + ).toThrow("Exchange deposit already exists"); + }); + + it("rejects duplicate references", () => { + const registry = new ExchangeDepositRegistry(); + + registry.create(createDeposit()); + + expect(() => + registry.create( + createDeposit({ + id: "deposit-2", + }), + ), + ).toThrow("Exchange deposit reference already exists"); + }); + + it("returns null for an unknown deposit", () => { + const registry = new ExchangeDepositRegistry(); + + expect(registry.getById("unknown")).toBeNull(); + }); + + it("returns an immutable deposit", () => { + const registry = new ExchangeDepositRegistry(); + + const created = registry.create(createDeposit()); + + expect(() => { + created.status = "credited"; + }).toThrow(); + + expect(registry.getById(created.id)?.status).toBe("detected"); + }); + + it("rejects an empty asset account ID", () => { + const registry = new ExchangeDepositRegistry(); + + expect(() => + registry.create( + createDeposit({ + exchangeAssetAccountId: " ", + }), + ), + ).toThrow("Exchange deposit asset account ID must not be empty"); + }); + + it("rejects an empty network ID", () => { + const registry = new ExchangeDepositRegistry(); + + expect(() => + registry.create( + createDeposit({ + networkId: " ", + }), + ), + ).toThrow("Exchange deposit network ID must not be empty"); + }); + + it("rejects an empty transaction hash", () => { + const registry = new ExchangeDepositRegistry(); + + expect(() => + registry.create( + createDeposit({ + transactionHash: " ", + }), + ), + ).toThrow("Exchange deposit transaction hash must not be empty"); + }); + + it("rejects a non-positive amount", () => { + const registry = new ExchangeDepositRegistry(); + + expect(() => + registry.create( + createDeposit({ + amount: "0", + }), + ), + ).toThrow("Exchange deposit amount must be positive"); + }); + + it("rejects a non-integer amount", () => { + const registry = new ExchangeDepositRegistry(); + + expect(() => + registry.create( + createDeposit({ + amount: "1.5", + }), + ), + ).toThrow("Exchange deposit amount must be positive"); + }); + + it("rejects an empty reference", () => { + const registry = new ExchangeDepositRegistry(); + + expect(() => + registry.create( + createDeposit({ + reference: " ", + }), + ), + ).toThrow("Exchange deposit reference must not be empty"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/exchange-fee-registry.test.ts b/packages/ledger-core/src/__tests__/exchange-fee-registry.test.ts new file mode 100644 index 0000000..e9907b4 --- /dev/null +++ b/packages/ledger-core/src/__tests__/exchange-fee-registry.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { ExchangeFeeRegistry, type ExchangeFeeRecord } from "../index.js"; + +function createFee(overrides: Partial = {}): ExchangeFeeRecord { + return { + id: "fee-1", + tradeId: "trade-1", + sourceExchangeAssetAccountId: "customer-asset-account", + feeExchangeAssetAccountId: "fee-asset-account", + assetId: "usdc", + amount: "5000000", + status: "charged", + reference: "fee-ref-1", + createdAt: "2026-09-12T00:00:00.000Z", + ...overrides, + }; +} + +describe("ExchangeFeeRegistry", () => { + it("creates and retrieves a fee", () => { + const registry = new ExchangeFeeRegistry(); + const fee = createFee(); + + const created = registry.create(fee); + + expect(created).toEqual(fee); + expect(registry.getById(fee.id)).toEqual(fee); + }); + + it("rejects duplicate fee IDs", () => { + const registry = new ExchangeFeeRegistry(); + + registry.create(createFee()); + + expect(() => + registry.create( + createFee({ + reference: "fee-ref-2", + }), + ), + ).toThrow("Exchange fee already exists"); + }); + + it("rejects duplicate references", () => { + const registry = new ExchangeFeeRegistry(); + + registry.create(createFee()); + + expect(() => + registry.create( + createFee({ + id: "fee-2", + }), + ), + ).toThrow("Exchange fee reference already exists"); + }); + + it("returns null for an unknown fee", () => { + const registry = new ExchangeFeeRegistry(); + + expect(registry.getById("unknown")).toBeNull(); + }); + + it("returns an immutable fee", () => { + const registry = new ExchangeFeeRegistry(); + + const created = registry.create(createFee()); + + expect(() => { + created.status = "reversed"; + }).toThrow(); + + expect(registry.getById(created.id)?.status).toBe("charged"); + }); + + it("rejects an empty trade ID", () => { + const registry = new ExchangeFeeRegistry(); + + expect(() => + registry.create( + createFee({ + tradeId: " ", + }), + ), + ).toThrow("Exchange fee trade ID must not be empty"); + }); + + it("rejects an empty source account ID", () => { + const registry = new ExchangeFeeRegistry(); + + expect(() => + registry.create( + createFee({ + sourceExchangeAssetAccountId: " ", + }), + ), + ).toThrow("Exchange fee source asset account ID must not be empty"); + }); + + it("rejects an empty fee account ID", () => { + const registry = new ExchangeFeeRegistry(); + + expect(() => + registry.create( + createFee({ + feeExchangeAssetAccountId: " ", + }), + ), + ).toThrow("Exchange fee fee account ID must not be empty"); + }); + + it("rejects identical source and fee accounts", () => { + const registry = new ExchangeFeeRegistry(); + + expect(() => + registry.create( + createFee({ + feeExchangeAssetAccountId: "customer-asset-account", + }), + ), + ).toThrow("Exchange fee source and fee accounts must differ"); + }); + + it("rejects an empty asset ID", () => { + const registry = new ExchangeFeeRegistry(); + + expect(() => + registry.create( + createFee({ + assetId: " ", + }), + ), + ).toThrow("Exchange fee asset ID must not be empty"); + }); + + it("rejects a non-positive amount", () => { + const registry = new ExchangeFeeRegistry(); + + expect(() => + registry.create( + createFee({ + amount: "0", + }), + ), + ).toThrow("Exchange fee amount must be positive"); + }); + + it("rejects a non-integer amount", () => { + const registry = new ExchangeFeeRegistry(); + + expect(() => + registry.create( + createFee({ + amount: "1.5", + }), + ), + ).toThrow("Exchange fee amount must be positive"); + }); + + it("rejects an empty reference", () => { + const registry = new ExchangeFeeRegistry(); + + expect(() => + registry.create( + createFee({ + reference: " ", + }), + ), + ).toThrow("Exchange fee reference must not be empty"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/exchange-posting-builders.test.ts b/packages/ledger-core/src/__tests__/exchange-posting-builders.test.ts new file mode 100644 index 0000000..78c99cb --- /dev/null +++ b/packages/ledger-core/src/__tests__/exchange-posting-builders.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { + buildDepositPosting, + buildFeePosting, + buildTradePosting, + buildWithdrawalPosting, +} from "../index.js"; + +describe("exchange posting builders", () => { + it("builds a balanced deposit posting", () => { + const transaction = buildDepositPosting({ + id: "deposit-ledger-1", + reference: "deposit-1", + custodyLedgerAccountId: "custody-btc", + assetLedgerAccountId: "customer-btc", + amount: "1000", + }); + + expect(transaction.operation).toBe("deposit"); + expect(transaction.postings).toEqual([ + { + ledgerAccountId: "custody-btc", + type: "debit", + amount: "1000", + }, + { + ledgerAccountId: "customer-btc", + type: "credit", + amount: "1000", + }, + ]); + }); + + it("builds a balanced withdrawal posting", () => { + const transaction = buildWithdrawalPosting({ + id: "withdrawal-ledger-1", + reference: "withdrawal-1", + customerLedgerAccountId: "customer-btc", + custodyLedgerAccountId: "custody-btc", + amount: "1000", + }); + + expect(transaction.operation).toBe("withdrawal"); + expect(transaction.postings).toEqual([ + { + ledgerAccountId: "customer-btc", + type: "debit", + amount: "1000", + }, + { + ledgerAccountId: "custody-btc", + type: "credit", + amount: "1000", + }, + ]); + }); + + it("builds a four-posting trade settlement", () => { + const transaction = buildTradePosting({ + id: "trade-ledger-1", + reference: "trade-1", + buyerBaseLedgerAccountId: "buyer-btc", + sellerBaseLedgerAccountId: "seller-btc", + buyerQuoteLedgerAccountId: "buyer-usdc", + sellerQuoteLedgerAccountId: "seller-usdc", + baseAmount: "100", + quoteAmount: "5000000", + }); + + expect(transaction.operation).toBe("trade"); + expect(transaction.postings).toEqual([ + { + ledgerAccountId: "buyer-btc", + type: "debit", + amount: "100", + }, + { + ledgerAccountId: "seller-btc", + type: "credit", + amount: "100", + }, + { + ledgerAccountId: "buyer-usdc", + type: "credit", + amount: "5000000", + }, + { + ledgerAccountId: "seller-usdc", + type: "debit", + amount: "5000000", + }, + ]); + }); + + it("builds a balanced fee posting", () => { + const transaction = buildFeePosting({ + id: "fee-ledger-1", + reference: "fee-1", + sourceLedgerAccountId: "customer-usdc", + feeLedgerAccountId: "exchange-fee-usdc", + amount: "50000", + }); + + expect(transaction.operation).toBe("fee"); + expect(transaction.postings).toEqual([ + { + ledgerAccountId: "customer-usdc", + type: "debit", + amount: "50000", + }, + { + ledgerAccountId: "exchange-fee-usdc", + type: "credit", + amount: "50000", + }, + ]); + }); + + it("rejects invalid deposit amounts", () => { + expect(() => + buildDepositPosting({ + id: "deposit-ledger-1", + reference: "deposit-1", + custodyLedgerAccountId: "custody-btc", + assetLedgerAccountId: "customer-btc", + amount: "0", + }), + ).toThrow("Exchange ledger posting amount must be positive"); + }); + + it("rejects invalid withdrawal amounts", () => { + expect(() => + buildWithdrawalPosting({ + id: "withdrawal-ledger-1", + reference: "withdrawal-1", + customerLedgerAccountId: "customer-btc", + custodyLedgerAccountId: "custody-btc", + amount: "1.5", + }), + ).toThrow("Exchange ledger posting amount must be positive"); + }); + + it("rejects invalid trade amounts", () => { + expect(() => + buildTradePosting({ + id: "trade-ledger-1", + reference: "trade-1", + buyerBaseLedgerAccountId: "buyer-btc", + sellerBaseLedgerAccountId: "seller-btc", + buyerQuoteLedgerAccountId: "buyer-usdc", + sellerQuoteLedgerAccountId: "seller-usdc", + baseAmount: "100", + quoteAmount: "0", + }), + ).toThrow("Exchange ledger posting amount must be positive"); + }); + + it("rejects invalid fee amounts", () => { + expect(() => + buildFeePosting({ + id: "fee-ledger-1", + reference: "fee-1", + sourceLedgerAccountId: "customer-usdc", + feeLedgerAccountId: "exchange-fee-usdc", + amount: "0", + }), + ).toThrow("Exchange ledger posting amount must be positive"); + }); + + it("returns immutable transactions", () => { + const transaction = buildDepositPosting({ + id: "deposit-ledger-1", + reference: "deposit-1", + custodyLedgerAccountId: "custody-btc", + assetLedgerAccountId: "customer-btc", + amount: "1000", + }); + + expect(() => { + transaction.postings[0]!.amount = "2000"; + }).toThrow(); + + expect(transaction.postings[0]!.amount).toBe("1000"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/exchange-posting-validator.test.ts b/packages/ledger-core/src/__tests__/exchange-posting-validator.test.ts new file mode 100644 index 0000000..efdc8ae --- /dev/null +++ b/packages/ledger-core/src/__tests__/exchange-posting-validator.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import { validateExchangeLedgerTransaction, type ExchangeLedgerTransaction } from "../index.js"; + +function createTransaction( + overrides: Partial = {}, +): ExchangeLedgerTransaction { + return { + id: "exchange-ledger-tx-1", + reference: "deposit-ref-1", + operation: "deposit", + postings: [ + { + ledgerAccountId: "cash-account", + type: "debit", + amount: "1000000", + }, + { + ledgerAccountId: "customer-account", + type: "credit", + amount: "1000000", + }, + ], + ...overrides, + }; +} + +describe("validateExchangeLedgerTransaction", () => { + it("accepts a balanced transaction", () => { + expect(() => validateExchangeLedgerTransaction(createTransaction())).not.toThrow(); + }); + + it("accepts multiple balanced postings", () => { + expect(() => + validateExchangeLedgerTransaction( + createTransaction({ + postings: [ + { + ledgerAccountId: "account-a", + type: "debit", + amount: "600", + }, + { + ledgerAccountId: "account-b", + type: "debit", + amount: "400", + }, + { + ledgerAccountId: "account-c", + type: "credit", + amount: "1000", + }, + ], + }), + ), + ).not.toThrow(); + }); + + it("rejects an empty transaction ID", () => { + expect(() => + validateExchangeLedgerTransaction( + createTransaction({ + id: " ", + }), + ), + ).toThrow("Exchange ledger transaction ID must not be empty"); + }); + + it("rejects an empty reference", () => { + expect(() => + validateExchangeLedgerTransaction( + createTransaction({ + reference: " ", + }), + ), + ).toThrow("Exchange ledger transaction reference must not be empty"); + }); + + it("requires at least two postings", () => { + expect(() => + validateExchangeLedgerTransaction( + createTransaction({ + postings: [ + { + ledgerAccountId: "account-a", + type: "debit", + amount: "100", + }, + ], + }), + ), + ).toThrow("Exchange ledger transaction requires at least two postings"); + }); + + it("rejects unbalanced postings", () => { + expect(() => + validateExchangeLedgerTransaction( + createTransaction({ + postings: [ + { + ledgerAccountId: "account-a", + type: "debit", + amount: "100", + }, + { + ledgerAccountId: "account-b", + type: "credit", + amount: "99", + }, + ], + }), + ), + ).toThrow("Exchange ledger transaction must balance"); + }); + + it("rejects an empty posting account ID", () => { + expect(() => + validateExchangeLedgerTransaction( + createTransaction({ + postings: [ + { + ledgerAccountId: " ", + type: "debit", + amount: "100", + }, + { + ledgerAccountId: "account-b", + type: "credit", + amount: "100", + }, + ], + }), + ), + ).toThrow("Exchange ledger posting account ID must not be empty"); + }); + + it("rejects a zero posting amount", () => { + expect(() => + validateExchangeLedgerTransaction( + createTransaction({ + postings: [ + { + ledgerAccountId: "account-a", + type: "debit", + amount: "0", + }, + { + ledgerAccountId: "account-b", + type: "credit", + amount: "0", + }, + ], + }), + ), + ).toThrow("Exchange ledger posting amount must be positive"); + }); + + it("rejects a fractional posting amount", () => { + expect(() => + validateExchangeLedgerTransaction( + createTransaction({ + postings: [ + { + ledgerAccountId: "account-a", + type: "debit", + amount: "1.5", + }, + { + ledgerAccountId: "account-b", + type: "credit", + amount: "1.5", + }, + ], + }), + ), + ).toThrow("Exchange ledger posting amount must be positive"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/exchange-subaccount-registry.test.ts b/packages/ledger-core/src/__tests__/exchange-subaccount-registry.test.ts new file mode 100644 index 0000000..22b93a3 --- /dev/null +++ b/packages/ledger-core/src/__tests__/exchange-subaccount-registry.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { ExchangeSubaccountRegistry, type ExchangeSubaccountRecord } from "../index.js"; + +function createSubaccount( + overrides: Partial = {}, +): ExchangeSubaccountRecord { + return { + id: "exchange-subaccount-1", + exchangeAccountId: "exchange-account-1", + name: "spot", + createdAt: "2026-09-12T00:00:00.000Z", + ...overrides, + }; +} + +describe("ExchangeSubaccountRegistry", () => { + it("creates and retrieves an exchange subaccount", () => { + const registry = new ExchangeSubaccountRegistry(); + const subaccount = createSubaccount(); + + const created = registry.create(subaccount); + + expect(created).toEqual(subaccount); + expect(registry.getById(subaccount.id)).toEqual(subaccount); + }); + + it("rejects duplicate subaccount IDs", () => { + const registry = new ExchangeSubaccountRegistry(); + + registry.create(createSubaccount()); + + expect(() => + registry.create( + createSubaccount({ + exchangeAccountId: "exchange-account-2", + }), + ), + ).toThrow("Exchange subaccount already exists"); + }); + + it("rejects duplicate names within the same exchange account", () => { + const registry = new ExchangeSubaccountRegistry(); + + registry.create(createSubaccount()); + + expect(() => + registry.create( + createSubaccount({ + id: "exchange-subaccount-2", + }), + ), + ).toThrow("Exchange subaccount already exists for exchange account and name"); + }); + + it("allows the same name for different exchange accounts", () => { + const registry = new ExchangeSubaccountRegistry(); + + registry.create(createSubaccount()); + + const second = registry.create( + createSubaccount({ + id: "exchange-subaccount-2", + exchangeAccountId: "exchange-account-2", + }), + ); + + expect(second.exchangeAccountId).toBe("exchange-account-2"); + }); + + it("returns null for an unknown subaccount", () => { + const registry = new ExchangeSubaccountRegistry(); + + expect(registry.getById("unknown")).toBeNull(); + }); + + it("returns an immutable subaccount", () => { + const registry = new ExchangeSubaccountRegistry(); + + const created = registry.create(createSubaccount()); + + expect(() => { + created.name = "mutated"; + }).toThrow(); + + expect(registry.getById(created.id)?.name).toBe("spot"); + }); + + it("rejects an empty exchange account ID", () => { + const registry = new ExchangeSubaccountRegistry(); + + expect(() => + registry.create( + createSubaccount({ + exchangeAccountId: " ", + }), + ), + ).toThrow("Exchange subaccount exchange account ID must not be empty"); + }); + + it("rejects an empty name", () => { + const registry = new ExchangeSubaccountRegistry(); + + expect(() => + registry.create( + createSubaccount({ + name: " ", + }), + ), + ).toThrow("Exchange subaccount name must not be empty"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/exchange-trade-registry.test.ts b/packages/ledger-core/src/__tests__/exchange-trade-registry.test.ts new file mode 100644 index 0000000..7e640e2 --- /dev/null +++ b/packages/ledger-core/src/__tests__/exchange-trade-registry.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { ExchangeTradeRegistry, type ExchangeTradeRecord } from "../index.js"; + +function createTrade(overrides: Partial = {}): ExchangeTradeRecord { + return { + id: "trade-1", + buyerExchangeAssetAccountId: "buyer-asset-account", + sellerExchangeAssetAccountId: "seller-asset-account", + baseAssetId: "btc", + quoteAssetId: "usdc", + baseAmount: "100000", + quoteAmount: "5000000000", + priceNumerator: "50000", + priceDenominator: "1", + status: "executed", + reference: "trade-ref-1", + executedAt: "2026-09-12T00:00:00.000Z", + ...overrides, + }; +} + +describe("ExchangeTradeRegistry", () => { + it("creates and retrieves a trade", () => { + const registry = new ExchangeTradeRegistry(); + const trade = createTrade(); + + const created = registry.create(trade); + + expect(created).toEqual(trade); + expect(registry.getById(trade.id)).toEqual(trade); + }); + + it("rejects duplicate trade IDs", () => { + const registry = new ExchangeTradeRegistry(); + + registry.create(createTrade()); + + expect(() => + registry.create( + createTrade({ + reference: "trade-ref-2", + }), + ), + ).toThrow("Exchange trade already exists"); + }); + + it("rejects duplicate references", () => { + const registry = new ExchangeTradeRegistry(); + + registry.create(createTrade()); + + expect(() => + registry.create( + createTrade({ + id: "trade-2", + }), + ), + ).toThrow("Exchange trade reference already exists"); + }); + + it("returns null for an unknown trade", () => { + const registry = new ExchangeTradeRegistry(); + + expect(registry.getById("unknown")).toBeNull(); + }); + + it("returns an immutable trade", () => { + const registry = new ExchangeTradeRegistry(); + + const created = registry.create(createTrade()); + + expect(() => { + created.status = "reversed"; + }).toThrow(); + + expect(registry.getById(created.id)?.status).toBe("executed"); + }); + + it("rejects identical buyer and seller accounts", () => { + const registry = new ExchangeTradeRegistry(); + + expect(() => + registry.create( + createTrade({ + sellerExchangeAssetAccountId: "buyer-asset-account", + }), + ), + ).toThrow("Exchange trade buyer and seller asset accounts must differ"); + }); + + it("rejects identical base and quote assets", () => { + const registry = new ExchangeTradeRegistry(); + + expect(() => + registry.create( + createTrade({ + quoteAssetId: "btc", + }), + ), + ).toThrow("Exchange trade base and quote assets must differ"); + }); + + it("rejects a non-positive base amount", () => { + const registry = new ExchangeTradeRegistry(); + + expect(() => + registry.create( + createTrade({ + baseAmount: "0", + }), + ), + ).toThrow("Exchange trade base amount must be positive"); + }); + + it("rejects a non-integer quote amount", () => { + const registry = new ExchangeTradeRegistry(); + + expect(() => + registry.create( + createTrade({ + quoteAmount: "1.5", + }), + ), + ).toThrow("Exchange trade quote amount must be positive"); + }); + + it("rejects a non-positive price numerator", () => { + const registry = new ExchangeTradeRegistry(); + + expect(() => + registry.create( + createTrade({ + priceNumerator: "0", + }), + ), + ).toThrow("Exchange trade price numerator must be positive"); + }); + + it("rejects a non-positive price denominator", () => { + const registry = new ExchangeTradeRegistry(); + + expect(() => + registry.create( + createTrade({ + priceDenominator: "0", + }), + ), + ).toThrow("Exchange trade price denominator must be positive"); + }); + + it("rejects an empty reference", () => { + const registry = new ExchangeTradeRegistry(); + + expect(() => + registry.create( + createTrade({ + reference: " ", + }), + ), + ).toThrow("Exchange trade reference must not be empty"); + }); + + it("rejects an empty execution time", () => { + const registry = new ExchangeTradeRegistry(); + + expect(() => + registry.create( + createTrade({ + executedAt: " ", + }), + ), + ).toThrow("Exchange trade execution time must not be empty"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/exchange-withdrawal-registry.test.ts b/packages/ledger-core/src/__tests__/exchange-withdrawal-registry.test.ts new file mode 100644 index 0000000..5183438 --- /dev/null +++ b/packages/ledger-core/src/__tests__/exchange-withdrawal-registry.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { ExchangeWithdrawalRegistry, type ExchangeWithdrawalRecord } from "../index.js"; + +function createWithdrawal( + overrides: Partial = {}, +): ExchangeWithdrawalRecord { + return { + id: "withdrawal-1", + exchangeAssetAccountId: "exchange-asset-account-1", + networkId: "ethereum", + destination: "0xrecipient", + amount: "1000000", + status: "requested", + reference: "withdrawal-ref-1", + createdAt: "2026-09-12T00:00:00.000Z", + ...overrides, + }; +} + +describe("ExchangeWithdrawalRegistry", () => { + it("creates and retrieves a withdrawal", () => { + const registry = new ExchangeWithdrawalRegistry(); + const withdrawal = createWithdrawal(); + + const created = registry.create(withdrawal); + + expect(created).toEqual(withdrawal); + expect(registry.getById(withdrawal.id)).toEqual(withdrawal); + }); + + it("rejects duplicate withdrawal IDs", () => { + const registry = new ExchangeWithdrawalRegistry(); + + registry.create(createWithdrawal()); + + expect(() => + registry.create( + createWithdrawal({ + reference: "withdrawal-ref-2", + }), + ), + ).toThrow("Exchange withdrawal already exists"); + }); + + it("rejects duplicate references", () => { + const registry = new ExchangeWithdrawalRegistry(); + + registry.create(createWithdrawal()); + + expect(() => + registry.create( + createWithdrawal({ + id: "withdrawal-2", + }), + ), + ).toThrow("Exchange withdrawal reference already exists"); + }); + + it("returns null for an unknown withdrawal", () => { + const registry = new ExchangeWithdrawalRegistry(); + + expect(registry.getById("unknown")).toBeNull(); + }); + + it("returns an immutable withdrawal", () => { + const registry = new ExchangeWithdrawalRegistry(); + + const created = registry.create(createWithdrawal()); + + expect(() => { + created.status = "approved"; + }).toThrow(); + + expect(registry.getById(created.id)?.status).toBe("requested"); + }); + + it("rejects an empty asset account ID", () => { + const registry = new ExchangeWithdrawalRegistry(); + + expect(() => + registry.create( + createWithdrawal({ + exchangeAssetAccountId: " ", + }), + ), + ).toThrow("Exchange withdrawal asset account ID must not be empty"); + }); + + it("rejects an empty network ID", () => { + const registry = new ExchangeWithdrawalRegistry(); + + expect(() => + registry.create( + createWithdrawal({ + networkId: " ", + }), + ), + ).toThrow("Exchange withdrawal network ID must not be empty"); + }); + + it("rejects an empty destination", () => { + const registry = new ExchangeWithdrawalRegistry(); + + expect(() => + registry.create( + createWithdrawal({ + destination: " ", + }), + ), + ).toThrow("Exchange withdrawal destination must not be empty"); + }); + + it("rejects a non-positive amount", () => { + const registry = new ExchangeWithdrawalRegistry(); + + expect(() => + registry.create( + createWithdrawal({ + amount: "0", + }), + ), + ).toThrow("Exchange withdrawal amount must be positive"); + }); + + it("rejects a non-integer amount", () => { + const registry = new ExchangeWithdrawalRegistry(); + + expect(() => + registry.create( + createWithdrawal({ + amount: "1.5", + }), + ), + ).toThrow("Exchange withdrawal amount must be positive"); + }); + + it("rejects an empty reference", () => { + const registry = new ExchangeWithdrawalRegistry(); + + expect(() => + registry.create( + createWithdrawal({ + reference: " ", + }), + ), + ).toThrow("Exchange withdrawal reference must not be empty"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/ledger-engine.test.ts b/packages/ledger-core/src/__tests__/ledger-engine.test.ts new file mode 100644 index 0000000..1f42876 --- /dev/null +++ b/packages/ledger-core/src/__tests__/ledger-engine.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; + +import { DefaultLedgerEngine } from "../ledger-engine.js"; + +describe("DefaultLedgerEngine", () => { + const debit = { + ledgerAccountId: "account-a", + type: "debit" as const, + amount: "100", + }; + + const credit = { + ledgerAccountId: "account-b", + type: "credit" as const, + amount: "100", + }; + + it("accepts a balanced posting", () => { + const engine = new DefaultLedgerEngine(); + + const transaction = engine.createTransaction({ + id: "ledger-tx-1", + reference: "wallet-tx-1", + postings: [debit, credit], + }); + + expect(transaction.id).toBe("ledger-tx-1"); + expect(transaction.reference).toBe("wallet-tx-1"); + expect(transaction.postings).toHaveLength(2); + }); + + it("rejects an unbalanced posting", () => { + const engine = new DefaultLedgerEngine(); + + expect(() => + engine.createTransaction({ + id: "ledger-tx-2", + reference: "wallet-tx-2", + postings: [ + debit, + { + ...credit, + amount: "99", + }, + ], + }), + ).toThrow("Ledger transaction must balance"); + }); + + it("rejects a transaction without enough postings", () => { + const engine = new DefaultLedgerEngine(); + + expect(() => + engine.createTransaction({ + id: "ledger-tx-3", + reference: "wallet-tx-3", + postings: [debit], + }), + ).toThrow("Ledger transaction requires at least two postings"); + }); + + it("rejects zero amounts", () => { + const engine = new DefaultLedgerEngine(); + + expect(() => + engine.createTransaction({ + id: "ledger-tx-4", + reference: "wallet-tx-4", + postings: [ + debit, + { + ...credit, + amount: "0", + }, + ], + }), + ).toThrow("Ledger posting amount must be positive"); + }); + + it("rejects negative amounts", () => { + const engine = new DefaultLedgerEngine(); + + expect(() => + engine.createTransaction({ + id: "ledger-tx-5", + reference: "wallet-tx-5", + postings: [ + debit, + { + ...credit, + amount: "-100", + }, + ], + }), + ).toThrow("Ledger posting amount must be positive"); + }); + + it("rejects duplicate transaction references", () => { + const engine = new DefaultLedgerEngine(); + + engine.createTransaction({ + id: "ledger-tx-6", + reference: "wallet-tx-6", + postings: [debit, credit], + }); + + expect(() => + engine.createTransaction({ + id: "ledger-tx-7", + reference: "wallet-tx-6", + postings: [debit, credit], + }), + ).toThrow("Ledger transaction reference already exists"); + }); + it("does not allow mutation of a created transaction", () => { + const engine = new DefaultLedgerEngine(); + + const transaction = engine.createTransaction({ + id: "ledger-tx-7", + reference: "wallet-tx-7", + postings: [debit, credit], + }); + + expect(() => { + transaction.postings[0]!.amount = "999"; + }).toThrow(); + + const stored = engine.getById("ledger-tx-7"); + + expect(stored?.postings[0]!.amount).toBe("100"); + }); + + it("does not allow mutation of the original input to alter ledger state", () => { + const engine = new DefaultLedgerEngine(); + + const input = { + id: "ledger-tx-8", + reference: "wallet-tx-8", + postings: [{ ...debit }, { ...credit }], + }; + + engine.createTransaction(input); + + input.postings[0]!.amount = "999"; + + const stored = engine.getById("ledger-tx-8"); + + expect(stored?.postings[0]!.amount).toBe("100"); + }); + it("derives a ledger balance from postings", () => { + const engine = new DefaultLedgerEngine(); + + engine.createTransaction({ + id: "ledger-tx-9", + reference: "wallet-tx-9", + postings: [debit, credit], + }); + + const accountA = engine.getBalance("account-a"); + const accountB = engine.getBalance("account-b"); + + expect(accountA).toEqual({ + ledgerAccountId: "account-a", + debit: "100", + credit: "0", + balance: "100", + }); + + expect(accountB).toEqual({ + ledgerAccountId: "account-b", + debit: "0", + credit: "100", + balance: "-100", + }); + }); + + it("returns zero balance for an account with no postings", () => { + const engine = new DefaultLedgerEngine(); + + expect(engine.getBalance("account-empty")).toEqual({ + ledgerAccountId: "account-empty", + debit: "0", + credit: "0", + balance: "0", + }); + }); + + it("derives balances across multiple transactions", () => { + const engine = new DefaultLedgerEngine(); + + engine.createTransaction({ + id: "ledger-tx-10", + reference: "wallet-tx-10", + postings: [debit, credit], + }); + + engine.createTransaction({ + id: "ledger-tx-11", + reference: "wallet-tx-11", + postings: [ + { + ledgerAccountId: "account-a", + type: "credit", + amount: "40", + }, + { + ledgerAccountId: "account-b", + type: "debit", + amount: "40", + }, + ], + }); + + expect(engine.getBalance("account-a")).toEqual({ + ledgerAccountId: "account-a", + debit: "100", + credit: "40", + balance: "60", + }); + + expect(engine.getBalance("account-b")).toEqual({ + ledgerAccountId: "account-b", + debit: "40", + credit: "100", + balance: "-60", + }); + }); +}); diff --git a/packages/ledger-core/src/__tests__/ledger-types.test.ts b/packages/ledger-core/src/__tests__/ledger-types.test.ts new file mode 100644 index 0000000..b4b4268 --- /dev/null +++ b/packages/ledger-core/src/__tests__/ledger-types.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import type { + LedgerAccountKind, + LedgerAccountRecord, + LedgerSubaccountRecord, +} from "../ledger-types.js"; + +describe("ledger domain types", () => { + it("supports explicit ledger account kinds", () => { + const kinds: LedgerAccountKind[] = ["asset", "liability", "revenue", "expense", "equity"]; + + expect(kinds).toHaveLength(5); + }); + + it("models a ledger account", () => { + const account: LedgerAccountRecord = { + id: "ledger-account-1", + walletId: "wallet-1", + assetId: "btc", + chain: "bitcoin", + kind: "asset", + createdAt: "2026-09-12T00:00:00.000Z", + }; + + expect(account.kind).toBe("asset"); + expect(account.assetId).toBe("btc"); + }); + + it("models a ledger subaccount", () => { + const subaccount: LedgerSubaccountRecord = { + id: "subaccount-1", + ledgerAccountId: "ledger-account-1", + name: "available", + createdAt: "2026-09-12T00:00:00.000Z", + }; + + expect(subaccount.ledgerAccountId).toBe("ledger-account-1"); + expect(subaccount.name).toBe("available"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/reconciliation-engine.test.ts b/packages/ledger-core/src/__tests__/reconciliation-engine.test.ts new file mode 100644 index 0000000..82be82e --- /dev/null +++ b/packages/ledger-core/src/__tests__/reconciliation-engine.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { DefaultReconciliationEngine } from "../reconciliation-engine.js"; +import type { ReconciliationObservation } from "../reconciliation-types.js"; + +const scope = { + assetId: "usdc", + networkId: "ethereum", + accountId: "exchange-asset-account-1", +}; + +function observation( + source: ReconciliationObservation["source"], + amount: string, +): ReconciliationObservation { + return { + source, + scope, + status: "available", + amount, + observedAt: "2026-09-12T00:00:00.000Z", + }; +} + +describe("DefaultReconciliationEngine", () => { + const engine = new DefaultReconciliationEngine(); + + it("marks equal observations as matched", () => { + const result = engine.compare( + observation("ledger", "1000000"), + observation("custody", "1000000"), + ); + + expect(result.status).toBe("matched"); + expect(result.difference).toBe("0"); + }); + + it("calculates positive differences using integer arithmetic", () => { + const result = engine.compare( + observation("ledger", "1000000"), + observation("blockchain", "1000500"), + ); + + expect(result.status).toBe("mismatched"); + expect(result.difference).toBe("500"); + }); + + it("calculates negative differences using integer arithmetic", () => { + const result = engine.compare( + observation("ledger", "1000000"), + observation("blockchain", "999500"), + ); + + expect(result.status).toBe("mismatched"); + expect(result.difference).toBe("-500"); + }); + + it("rejects non-integer amounts", () => { + expect(() => + engine.compare(observation("ledger", "1000.5"), observation("custody", "1000")), + ).toThrow("Reconciliation amount must be a non-negative integer"); + }); + + it("rejects observations from different scopes", () => { + expect(() => + engine.compare(observation("ledger", "1000"), { + ...observation("custody", "1000"), + scope: { + ...scope, + assetId: "btc", + }, + }), + ).toThrow("Reconciliation observations must use the same scope"); + }); + it("marks the comparison unavailable when an observation is unavailable", () => { + const result = engine.compare( + { + source: "ledger", + scope, + status: "available", + amount: "1000000", + observedAt: "2026-09-12T00:00:00.000Z", + }, + { + source: "custody", + scope, + status: "unavailable", + amount: null, + observedAt: "2026-09-12T00:00:01.000Z", + }, + ); + + expect(result.status).toBe("unavailable"); + expect(result.difference).toBeNull(); + }); +}); diff --git a/packages/ledger-core/src/__tests__/reconciliation-repository.test.ts b/packages/ledger-core/src/__tests__/reconciliation-repository.test.ts new file mode 100644 index 0000000..0db0312 --- /dev/null +++ b/packages/ledger-core/src/__tests__/reconciliation-repository.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { ReconciliationComparison, ReconciliationRepository } from "../index.js"; + +class InMemoryReconciliationRepository implements ReconciliationRepository { + private readonly comparisons: ReconciliationComparison[] = []; + + async save(comparison: ReconciliationComparison): Promise { + this.comparisons.push(comparison); + } + + async findLatest( + assetId: string, + networkId?: string, + accountId?: string, + ): Promise { + const matches = this.comparisons.filter((comparison) => { + return ( + comparison.scope.assetId === assetId && + comparison.scope.networkId === networkId && + comparison.scope.accountId === accountId + ); + }); + + return matches.at(-1) ?? null; + } +} + +describe("ReconciliationRepository contract", () => { + it("stores and retrieves the latest comparison for a scope", async () => { + const repository = new InMemoryReconciliationRepository(); + + const comparison: ReconciliationComparison = { + scope: { + assetId: "usdc", + networkId: "ethereum", + accountId: "exchange-asset-account-1", + }, + expected: { + source: "ledger", + scope: { + assetId: "usdc", + networkId: "ethereum", + accountId: "exchange-asset-account-1", + }, + status: "available", + amount: "1000000", + observedAt: "2026-09-12T00:00:00.000Z", + }, + actual: { + source: "custody", + scope: { + assetId: "usdc", + networkId: "ethereum", + accountId: "exchange-asset-account-1", + }, + status: "available", + amount: "1000000", + observedAt: "2026-09-12T00:00:01.000Z", + }, + difference: "0", + status: "matched", + }; + + await repository.save(comparison); + + await expect( + repository.findLatest("usdc", "ethereum", "exchange-asset-account-1"), + ).resolves.toEqual(comparison); + }); + + it("returns null when no comparison exists", async () => { + const repository = new InMemoryReconciliationRepository(); + + await expect(repository.findLatest("btc", "bitcoin", "missing-account")).resolves.toBeNull(); + }); +}); diff --git a/packages/ledger-core/src/__tests__/reconciliation-types.test.ts b/packages/ledger-core/src/__tests__/reconciliation-types.test.ts new file mode 100644 index 0000000..6dffa5d --- /dev/null +++ b/packages/ledger-core/src/__tests__/reconciliation-types.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import type { + ReconciliationComparison, + ReconciliationObservation, + ReconciliationScope, +} from "../reconciliation-types.js"; + +describe("reconciliation contract", () => { + it("represents ledger and external observations for the same scope", () => { + const scope: ReconciliationScope = { + assetId: "usdc", + networkId: "ethereum", + accountId: "exchange-asset-account-1", + }; + + const ledgerObservation: ReconciliationObservation = { + source: "ledger", + scope, + status: "available", + amount: "1000000", + observedAt: "2026-09-12T00:00:00.000Z", + reference: "ledger-balance-1", + }; + + const custodyObservation: ReconciliationObservation = { + source: "custody", + scope, + status: "available", + amount: "1000000", + observedAt: "2026-09-12T00:00:01.000Z", + reference: "custody-balance-1", + }; + + const comparison: ReconciliationComparison = { + scope, + expected: ledgerObservation, + actual: custodyObservation, + difference: "0", + status: "matched", + }; + + expect(comparison.expected.source).toBe("ledger"); + expect(comparison.actual.source).toBe("custody"); + expect(comparison.difference).toBe("0"); + expect(comparison.status).toBe("matched"); + }); + + it("supports blockchain and fiat as external reconciliation sources", () => { + const scope: ReconciliationScope = { + assetId: "btc", + networkId: "bitcoin", + }; + + const blockchainObservation: ReconciliationObservation = { + source: "blockchain", + scope, + status: "available", + amount: "25000000", + observedAt: "2026-09-12T00:00:00.000Z", + }; + + const fiatObservation: ReconciliationObservation = { + source: "fiat", + scope: { + assetId: "usd", + accountId: "bank-account-1", + }, + status: "available", + amount: "500000", + observedAt: "2026-09-12T00:00:00.000Z", + }; + + expect(blockchainObservation.source).toBe("blockchain"); + expect(fiatObservation.source).toBe("fiat"); + }); + + it("represents mismatches without using floating-point amounts", () => { + const scope: ReconciliationScope = { + assetId: "usdc", + networkId: "ethereum", + }; + + const expected: ReconciliationObservation = { + source: "ledger", + scope, + status: "available", + amount: "1000000", + observedAt: "2026-09-12T00:00:00.000Z", + }; + + const actual: ReconciliationObservation = { + source: "blockchain", + scope, + status: "available", + amount: "999500", + observedAt: "2026-09-12T00:00:00.000Z", + }; + + const comparison: ReconciliationComparison = { + scope, + expected, + actual, + difference: "-500", + status: "mismatched", + }; + + expect(comparison.difference).toBe("-500"); + expect(comparison.status).toBe("mismatched"); + }); +}); diff --git a/packages/ledger-core/src/__tests__/subaccount-registry.test.ts b/packages/ledger-core/src/__tests__/subaccount-registry.test.ts new file mode 100644 index 0000000..a99b971 --- /dev/null +++ b/packages/ledger-core/src/__tests__/subaccount-registry.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { LedgerSubaccountRegistry } from "../subaccount-registry.js"; +import type { LedgerSubaccountRecord } from "../ledger-types.js"; + +describe("LedgerSubaccountRegistry", () => { + const subaccount: LedgerSubaccountRecord = { + id: "subaccount-1", + ledgerAccountId: "ledger-account-1", + name: "available", + createdAt: "2026-09-12T00:00:00.000Z", + }; + + it("creates and retrieves a subaccount", () => { + const registry = new LedgerSubaccountRegistry(); + + const created = registry.create(subaccount); + + expect(created).toEqual(subaccount); + expect(registry.getById(subaccount.id)).toEqual(subaccount); + }); + + it("rejects duplicate subaccount ids", () => { + const registry = new LedgerSubaccountRegistry(); + + registry.create(subaccount); + + expect(() => registry.create(subaccount)).toThrow( + "Ledger subaccount already exists: subaccount-1", + ); + }); + + it("rejects duplicate subaccount names within the same ledger account", () => { + const registry = new LedgerSubaccountRegistry(); + + registry.create(subaccount); + + expect(() => + registry.create({ + ...subaccount, + id: "subaccount-2", + }), + ).toThrow("Ledger subaccount already exists for ledger account and name"); + }); + + it("allows the same subaccount name on different ledger accounts", () => { + const registry = new LedgerSubaccountRegistry(); + + registry.create(subaccount); + + const second = registry.create({ + ...subaccount, + id: "subaccount-2", + ledgerAccountId: "ledger-account-2", + }); + + expect(second).toEqual({ + ...subaccount, + id: "subaccount-2", + ledgerAccountId: "ledger-account-2", + }); + }); + + it("does not expose mutable internal state", () => { + const registry = new LedgerSubaccountRegistry(); + + const created = registry.create(subaccount); + + expect(() => { + created.name = "mutated"; + }).toThrow(); + + expect(registry.getById(subaccount.id)).toEqual(subaccount); + }); + + it("returns null for an unknown subaccount", () => { + const registry = new LedgerSubaccountRegistry(); + + expect(registry.getById("unknown")).toBeNull(); + }); +}); diff --git a/packages/ledger-core/src/account-registry.ts b/packages/ledger-core/src/account-registry.ts new file mode 100644 index 0000000..1e47530 --- /dev/null +++ b/packages/ledger-core/src/account-registry.ts @@ -0,0 +1,39 @@ +import type { LedgerAccountRecord } from "./ledger-types.js"; + +export class LedgerAccountRegistry { + private readonly accounts = new Map(); + private readonly identities = new Map(); + + create(account: LedgerAccountRecord): LedgerAccountRecord { + if (this.accounts.has(account.id)) { + throw new Error(`Ledger account already exists: ${account.id}`); + } + + const identity = this.getIdentity(account); + + if (this.identities.has(identity)) { + throw new Error(`Ledger account already exists for wallet, asset, chain and kind`); + } + + const stored = Object.freeze({ ...account }); + + this.accounts.set(account.id, stored); + this.identities.set(identity, account.id); + + return stored; + } + + getById(id: string): LedgerAccountRecord | null { + const account = this.accounts.get(id); + + if (account === undefined) { + return null; + } + + return Object.freeze({ ...account }); + } + + private getIdentity(account: LedgerAccountRecord): string { + return JSON.stringify([account.walletId, account.assetId, account.chain, account.kind]); + } +} diff --git a/packages/ledger-core/src/exchange-account-registry.ts b/packages/ledger-core/src/exchange-account-registry.ts new file mode 100644 index 0000000..df82a70 --- /dev/null +++ b/packages/ledger-core/src/exchange-account-registry.ts @@ -0,0 +1,55 @@ +import type { ExchangeAccountRecord } from "./exchange-account-types.js"; + +export class ExchangeAccountRegistry { + private readonly accounts = new Map(); + private readonly identities = new Map(); + + create(account: ExchangeAccountRecord): ExchangeAccountRecord { + if (this.accounts.has(account.id)) { + throw new Error(`Exchange account already exists: ${account.id}`); + } + + const identity = this.getIdentity(account); + + if (this.identities.has(identity)) { + throw new Error("Exchange account already exists for owner and account kind"); + } + + this.validate(account); + + const stored = Object.freeze({ ...account }); + + this.accounts.set(account.id, stored); + this.identities.set(identity, account.id); + + return stored; + } + + getById(id: string): ExchangeAccountRecord | null { + const account = this.accounts.get(id); + + if (account === undefined) { + return null; + } + + return Object.freeze({ ...account }); + } + + private getIdentity(account: ExchangeAccountRecord): string { + return JSON.stringify([account.ownerType, account.ownerId, account.kind]); + } + + private validate(account: ExchangeAccountRecord): void { + if (account.ownerId.trim().length === 0) { + throw new Error("Exchange account owner ID must not be empty"); + } + + if (account.ownerType === "customer" && account.kind !== "customer") { + throw new Error("Customer-owned exchange accounts must have customer kind"); + } + + if (account.ownerType === "platform" && account.kind === "customer") { + throw new Error("Platform-owned exchange accounts cannot have customer kind"); + } + } +} diff --git a/packages/ledger-core/src/exchange-account-types.ts b/packages/ledger-core/src/exchange-account-types.ts new file mode 100644 index 0000000..290dbc2 --- /dev/null +++ b/packages/ledger-core/src/exchange-account-types.ts @@ -0,0 +1,29 @@ +export type ExchangeAccountOwnerType = "customer" | "platform"; + +export type ExchangeAccountKind = "customer" | "treasury" | "fee" | "operational"; + +export type ExchangeAccountStatus = "active" | "blocked"; + +export interface ExchangeAccountRecord { + id: string; + ownerType: ExchangeAccountOwnerType; + ownerId: string; + kind: ExchangeAccountKind; + status: ExchangeAccountStatus; + createdAt: string; +} + +export interface ExchangeSubaccountRecord { + id: string; + exchangeAccountId: string; + name: string; + createdAt: string; +} + +export interface ExchangeAssetAccountRecord { + id: string; + exchangeAccountId: string; + assetId: string; + networkId: string; + createdAt: string; +} diff --git a/packages/ledger-core/src/exchange-asset-account-registry.ts b/packages/ledger-core/src/exchange-asset-account-registry.ts new file mode 100644 index 0000000..1f07f91 --- /dev/null +++ b/packages/ledger-core/src/exchange-asset-account-registry.ts @@ -0,0 +1,56 @@ +import type { ExchangeAssetAccountRecord } from "./exchange-account-types.js"; + +export class ExchangeAssetAccountRegistry { + private readonly assetAccounts = new Map(); + + private readonly identities = new Map(); + + create(assetAccount: ExchangeAssetAccountRecord): ExchangeAssetAccountRecord { + if (this.assetAccounts.has(assetAccount.id)) { + throw new Error(`Exchange asset account already exists: ${assetAccount.id}`); + } + + const identity = this.getIdentity(assetAccount); + + if (this.identities.has(identity)) { + throw new Error("Exchange asset account already exists for exchange account and asset"); + } + + this.validate(assetAccount); + + const stored = Object.freeze({ ...assetAccount }); + + this.assetAccounts.set(assetAccount.id, stored); + this.identities.set(identity, assetAccount.id); + + return stored; + } + + getById(id: string): ExchangeAssetAccountRecord | null { + const assetAccount = this.assetAccounts.get(id); + + if (assetAccount === undefined) { + return null; + } + + return Object.freeze({ ...assetAccount }); + } + + private getIdentity(assetAccount: ExchangeAssetAccountRecord): string { + return JSON.stringify([assetAccount.exchangeAccountId, assetAccount.assetId]); + } + + private validate(assetAccount: ExchangeAssetAccountRecord): void { + if (assetAccount.exchangeAccountId.trim().length === 0) { + throw new Error("Exchange asset account exchange account ID must not be empty"); + } + + if (assetAccount.assetId.trim().length === 0) { + throw new Error("Exchange asset account asset ID must not be empty"); + } + + if (assetAccount.networkId.trim().length === 0) { + throw new Error("Exchange asset account network ID must not be empty"); + } + } +} diff --git a/packages/ledger-core/src/exchange-deposit-registry.ts b/packages/ledger-core/src/exchange-deposit-registry.ts new file mode 100644 index 0000000..29afff2 --- /dev/null +++ b/packages/ledger-core/src/exchange-deposit-registry.ts @@ -0,0 +1,57 @@ +import type { ExchangeDepositRecord } from "./exchange-deposit-types.js"; + +export class ExchangeDepositRegistry { + private readonly deposits = new Map(); + private readonly references = new Map(); + + create(deposit: ExchangeDepositRecord): ExchangeDepositRecord { + if (this.deposits.has(deposit.id)) { + throw new Error(`Exchange deposit already exists: ${deposit.id}`); + } + + if (this.references.has(deposit.reference)) { + throw new Error(`Exchange deposit reference already exists: ${deposit.reference}`); + } + + this.validate(deposit); + + const stored = Object.freeze({ ...deposit }); + + this.deposits.set(deposit.id, stored); + this.references.set(deposit.reference, deposit.id); + + return stored; + } + + getById(id: string): ExchangeDepositRecord | null { + const deposit = this.deposits.get(id); + + if (deposit === undefined) { + return null; + } + + return Object.freeze({ ...deposit }); + } + + private validate(deposit: ExchangeDepositRecord): void { + if (deposit.exchangeAssetAccountId.trim().length === 0) { + throw new Error("Exchange deposit asset account ID must not be empty"); + } + + if (deposit.networkId.trim().length === 0) { + throw new Error("Exchange deposit network ID must not be empty"); + } + + if (deposit.transactionHash.trim().length === 0) { + throw new Error("Exchange deposit transaction hash must not be empty"); + } + + if (!/^[0-9]+$/.test(deposit.amount) || BigInt(deposit.amount) <= 0n) { + throw new Error("Exchange deposit amount must be positive"); + } + + if (deposit.reference.trim().length === 0) { + throw new Error("Exchange deposit reference must not be empty"); + } + } +} diff --git a/packages/ledger-core/src/exchange-deposit-types.ts b/packages/ledger-core/src/exchange-deposit-types.ts new file mode 100644 index 0000000..c5ee8a0 --- /dev/null +++ b/packages/ledger-core/src/exchange-deposit-types.ts @@ -0,0 +1,12 @@ +export type ExchangeDepositStatus = "detected" | "confirmed" | "credited" | "reversed"; + +export interface ExchangeDepositRecord { + id: string; + exchangeAssetAccountId: string; + networkId: string; + transactionHash: string; + amount: string; + status: ExchangeDepositStatus; + reference: string; + createdAt: string; +} diff --git a/packages/ledger-core/src/exchange-fee-registry.ts b/packages/ledger-core/src/exchange-fee-registry.ts new file mode 100644 index 0000000..c78b4dc --- /dev/null +++ b/packages/ledger-core/src/exchange-fee-registry.ts @@ -0,0 +1,65 @@ +import type { ExchangeFeeRecord } from "./exchange-fee-types.js"; + +export class ExchangeFeeRegistry { + private readonly fees = new Map(); + private readonly references = new Map(); + + create(fee: ExchangeFeeRecord): ExchangeFeeRecord { + if (this.fees.has(fee.id)) { + throw new Error(`Exchange fee already exists: ${fee.id}`); + } + + if (this.references.has(fee.reference)) { + throw new Error(`Exchange fee reference already exists: ${fee.reference}`); + } + + this.validate(fee); + + const stored = Object.freeze({ ...fee }); + + this.fees.set(fee.id, stored); + this.references.set(fee.reference, fee.id); + + return stored; + } + + getById(id: string): ExchangeFeeRecord | null { + const fee = this.fees.get(id); + + if (fee === undefined) { + return null; + } + + return Object.freeze({ ...fee }); + } + + private validate(fee: ExchangeFeeRecord): void { + if (fee.tradeId.trim().length === 0) { + throw new Error("Exchange fee trade ID must not be empty"); + } + + if (fee.sourceExchangeAssetAccountId.trim().length === 0) { + throw new Error("Exchange fee source asset account ID must not be empty"); + } + + if (fee.feeExchangeAssetAccountId.trim().length === 0) { + throw new Error("Exchange fee fee account ID must not be empty"); + } + + if (fee.sourceExchangeAssetAccountId === fee.feeExchangeAssetAccountId) { + throw new Error("Exchange fee source and fee accounts must differ"); + } + + if (fee.assetId.trim().length === 0) { + throw new Error("Exchange fee asset ID must not be empty"); + } + + if (!/^[0-9]+$/.test(fee.amount) || BigInt(fee.amount) <= 0n) { + throw new Error("Exchange fee amount must be positive"); + } + + if (fee.reference.trim().length === 0) { + throw new Error("Exchange fee reference must not be empty"); + } + } +} diff --git a/packages/ledger-core/src/exchange-fee-types.ts b/packages/ledger-core/src/exchange-fee-types.ts new file mode 100644 index 0000000..b4550ac --- /dev/null +++ b/packages/ledger-core/src/exchange-fee-types.ts @@ -0,0 +1,13 @@ +export type ExchangeFeeStatus = "charged" | "reversed"; + +export interface ExchangeFeeRecord { + id: string; + tradeId: string; + sourceExchangeAssetAccountId: string; + feeExchangeAssetAccountId: string; + assetId: string; + amount: string; + status: ExchangeFeeStatus; + reference: string; + createdAt: string; +} diff --git a/packages/ledger-core/src/exchange-posting-builders.ts b/packages/ledger-core/src/exchange-posting-builders.ts new file mode 100644 index 0000000..1c9b6d2 --- /dev/null +++ b/packages/ledger-core/src/exchange-posting-builders.ts @@ -0,0 +1,155 @@ +import type { ExchangeLedgerTransaction } from "./exchange-posting-types.js"; +import { validateExchangeLedgerTransaction } from "./exchange-posting-validator.js"; + +export interface DepositPostingInput { + id: string; + reference: string; + assetLedgerAccountId: string; + custodyLedgerAccountId: string; + amount: string; +} + +export interface WithdrawalPostingInput { + id: string; + reference: string; + customerLedgerAccountId: string; + custodyLedgerAccountId: string; + amount: string; +} + +export interface TradePostingInput { + id: string; + reference: string; + buyerBaseLedgerAccountId: string; + sellerBaseLedgerAccountId: string; + buyerQuoteLedgerAccountId: string; + sellerQuoteLedgerAccountId: string; + baseAmount: string; + quoteAmount: string; +} + +export interface FeePostingInput { + id: string; + reference: string; + sourceLedgerAccountId: string; + feeLedgerAccountId: string; + amount: string; +} + +export function buildDepositPosting(input: DepositPostingInput): ExchangeLedgerTransaction { + const transaction: ExchangeLedgerTransaction = { + id: input.id, + reference: input.reference, + operation: "deposit", + postings: [ + { + ledgerAccountId: input.custodyLedgerAccountId, + type: "debit", + amount: input.amount, + }, + { + ledgerAccountId: input.assetLedgerAccountId, + type: "credit", + amount: input.amount, + }, + ], + }; + + validateExchangeLedgerTransaction(transaction); + + return Object.freeze({ + ...transaction, + postings: Object.freeze(transaction.postings.map((posting) => Object.freeze({ ...posting }))), + }); +} + +export function buildWithdrawalPosting(input: WithdrawalPostingInput): ExchangeLedgerTransaction { + const transaction: ExchangeLedgerTransaction = { + id: input.id, + reference: input.reference, + operation: "withdrawal", + postings: [ + { + ledgerAccountId: input.customerLedgerAccountId, + type: "debit", + amount: input.amount, + }, + { + ledgerAccountId: input.custodyLedgerAccountId, + type: "credit", + amount: input.amount, + }, + ], + }; + + validateExchangeLedgerTransaction(transaction); + + return Object.freeze({ + ...transaction, + postings: Object.freeze(transaction.postings.map((posting) => Object.freeze({ ...posting }))), + }); +} + +export function buildTradePosting(input: TradePostingInput): ExchangeLedgerTransaction { + const transaction: ExchangeLedgerTransaction = { + id: input.id, + reference: input.reference, + operation: "trade", + postings: [ + { + ledgerAccountId: input.buyerBaseLedgerAccountId, + type: "debit", + amount: input.baseAmount, + }, + { + ledgerAccountId: input.sellerBaseLedgerAccountId, + type: "credit", + amount: input.baseAmount, + }, + { + ledgerAccountId: input.buyerQuoteLedgerAccountId, + type: "credit", + amount: input.quoteAmount, + }, + { + ledgerAccountId: input.sellerQuoteLedgerAccountId, + type: "debit", + amount: input.quoteAmount, + }, + ], + }; + + validateExchangeLedgerTransaction(transaction); + + return Object.freeze({ + ...transaction, + postings: Object.freeze(transaction.postings.map((posting) => Object.freeze({ ...posting }))), + }); +} + +export function buildFeePosting(input: FeePostingInput): ExchangeLedgerTransaction { + const transaction: ExchangeLedgerTransaction = { + id: input.id, + reference: input.reference, + operation: "fee", + postings: [ + { + ledgerAccountId: input.sourceLedgerAccountId, + type: "debit", + amount: input.amount, + }, + { + ledgerAccountId: input.feeLedgerAccountId, + type: "credit", + amount: input.amount, + }, + ], + }; + + validateExchangeLedgerTransaction(transaction); + + return Object.freeze({ + ...transaction, + postings: Object.freeze(transaction.postings.map((posting) => Object.freeze({ ...posting }))), + }); +} diff --git a/packages/ledger-core/src/exchange-posting-types.ts b/packages/ledger-core/src/exchange-posting-types.ts new file mode 100644 index 0000000..3315830 --- /dev/null +++ b/packages/ledger-core/src/exchange-posting-types.ts @@ -0,0 +1,14 @@ +export type ExchangePostingOperation = "deposit" | "withdrawal" | "trade" | "fee"; + +export interface ExchangePosting { + ledgerAccountId: string; + type: "debit" | "credit"; + amount: string; +} + +export interface ExchangeLedgerTransaction { + id: string; + reference: string; + operation: ExchangePostingOperation; + postings: readonly ExchangePosting[]; +} diff --git a/packages/ledger-core/src/exchange-posting-validator.ts b/packages/ledger-core/src/exchange-posting-validator.ts new file mode 100644 index 0000000..acbaac4 --- /dev/null +++ b/packages/ledger-core/src/exchange-posting-validator.ts @@ -0,0 +1,48 @@ +import type { ExchangeLedgerTransaction, ExchangePosting } from "./exchange-posting-types.js"; + +export function validateExchangeLedgerTransaction(transaction: ExchangeLedgerTransaction): void { + if (transaction.id.trim().length === 0) { + throw new Error("Exchange ledger transaction ID must not be empty"); + } + + if (transaction.reference.trim().length === 0) { + throw new Error("Exchange ledger transaction reference must not be empty"); + } + + if (transaction.postings.length < 2) { + throw new Error("Exchange ledger transaction requires at least two postings"); + } + + let debits = 0n; + let credits = 0n; + + for (const posting of transaction.postings) { + validatePosting(posting); + + const amount = BigInt(posting.amount); + + if (posting.type === "debit") { + debits += amount; + } else { + credits += amount; + } + } + + if (debits !== credits) { + throw new Error("Exchange ledger transaction must balance"); + } +} + +function validatePosting(posting: ExchangePosting): void { + if (posting.ledgerAccountId.trim().length === 0) { + throw new Error("Exchange ledger posting account ID must not be empty"); + } + + if (!/^[0-9]+$/.test(posting.amount) || BigInt(posting.amount) <= 0n) { + throw new Error("Exchange ledger posting amount must be positive"); + } + + if (posting.type !== "debit" && posting.type !== "credit") { + throw new Error("Exchange ledger posting type must be debit or credit"); + } +} diff --git a/packages/ledger-core/src/exchange-subaccount-registry.ts b/packages/ledger-core/src/exchange-subaccount-registry.ts new file mode 100644 index 0000000..606c2bf --- /dev/null +++ b/packages/ledger-core/src/exchange-subaccount-registry.ts @@ -0,0 +1,52 @@ +import type { ExchangeSubaccountRecord } from "./exchange-account-types.js"; + +export class ExchangeSubaccountRegistry { + private readonly subaccounts = new Map(); + + private readonly identities = new Map(); + + create(subaccount: ExchangeSubaccountRecord): ExchangeSubaccountRecord { + if (this.subaccounts.has(subaccount.id)) { + throw new Error(`Exchange subaccount already exists: ${subaccount.id}`); + } + + const identity = this.getIdentity(subaccount); + + if (this.identities.has(identity)) { + throw new Error("Exchange subaccount already exists for exchange account and name"); + } + + this.validate(subaccount); + + const stored = Object.freeze({ ...subaccount }); + + this.subaccounts.set(subaccount.id, stored); + this.identities.set(identity, subaccount.id); + + return stored; + } + + getById(id: string): ExchangeSubaccountRecord | null { + const subaccount = this.subaccounts.get(id); + + if (subaccount === undefined) { + return null; + } + + return Object.freeze({ ...subaccount }); + } + + private getIdentity(subaccount: ExchangeSubaccountRecord): string { + return JSON.stringify([subaccount.exchangeAccountId, subaccount.name]); + } + + private validate(subaccount: ExchangeSubaccountRecord): void { + if (subaccount.exchangeAccountId.trim().length === 0) { + throw new Error("Exchange subaccount exchange account ID must not be empty"); + } + + if (subaccount.name.trim().length === 0) { + throw new Error("Exchange subaccount name must not be empty"); + } + } +} diff --git a/packages/ledger-core/src/exchange-trade-registry.ts b/packages/ledger-core/src/exchange-trade-registry.ts new file mode 100644 index 0000000..9c8a66c --- /dev/null +++ b/packages/ledger-core/src/exchange-trade-registry.ts @@ -0,0 +1,83 @@ +import type { ExchangeTradeRecord } from "./exchange-trade-types.js"; + +export class ExchangeTradeRegistry { + private readonly trades = new Map(); + private readonly references = new Map(); + + create(trade: ExchangeTradeRecord): ExchangeTradeRecord { + if (this.trades.has(trade.id)) { + throw new Error(`Exchange trade already exists: ${trade.id}`); + } + + if (this.references.has(trade.reference)) { + throw new Error(`Exchange trade reference already exists: ${trade.reference}`); + } + + this.validate(trade); + + const stored = Object.freeze({ ...trade }); + + this.trades.set(trade.id, stored); + this.references.set(trade.reference, trade.id); + + return stored; + } + + getById(id: string): ExchangeTradeRecord | null { + const trade = this.trades.get(id); + + if (trade === undefined) { + return null; + } + + return Object.freeze({ ...trade }); + } + + private validate(trade: ExchangeTradeRecord): void { + if (trade.buyerExchangeAssetAccountId.trim().length === 0) { + throw new Error("Exchange trade buyer asset account ID must not be empty"); + } + + if (trade.sellerExchangeAssetAccountId.trim().length === 0) { + throw new Error("Exchange trade seller asset account ID must not be empty"); + } + + if (trade.buyerExchangeAssetAccountId === trade.sellerExchangeAssetAccountId) { + throw new Error("Exchange trade buyer and seller asset accounts must differ"); + } + + if (trade.baseAssetId.trim().length === 0) { + throw new Error("Exchange trade base asset ID must not be empty"); + } + + if (trade.quoteAssetId.trim().length === 0) { + throw new Error("Exchange trade quote asset ID must not be empty"); + } + + if (trade.baseAssetId === trade.quoteAssetId) { + throw new Error("Exchange trade base and quote assets must differ"); + } + + this.validatePositiveInteger(trade.baseAmount, "Exchange trade base amount"); + + this.validatePositiveInteger(trade.quoteAmount, "Exchange trade quote amount"); + + this.validatePositiveInteger(trade.priceNumerator, "Exchange trade price numerator"); + + this.validatePositiveInteger(trade.priceDenominator, "Exchange trade price denominator"); + + if (trade.reference.trim().length === 0) { + throw new Error("Exchange trade reference must not be empty"); + } + + if (trade.executedAt.trim().length === 0) { + throw new Error("Exchange trade execution time must not be empty"); + } + } + + private validatePositiveInteger(value: string, field: string): void { + if (!/^[0-9]+$/.test(value) || BigInt(value) <= 0n) { + throw new Error(`${field} must be positive`); + } + } +} diff --git a/packages/ledger-core/src/exchange-trade-types.ts b/packages/ledger-core/src/exchange-trade-types.ts new file mode 100644 index 0000000..ca931d6 --- /dev/null +++ b/packages/ledger-core/src/exchange-trade-types.ts @@ -0,0 +1,16 @@ +export type ExchangeTradeStatus = "executed" | "reversed"; + +export interface ExchangeTradeRecord { + id: string; + buyerExchangeAssetAccountId: string; + sellerExchangeAssetAccountId: string; + baseAssetId: string; + quoteAssetId: string; + baseAmount: string; + quoteAmount: string; + priceNumerator: string; + priceDenominator: string; + status: ExchangeTradeStatus; + reference: string; + executedAt: string; +} diff --git a/packages/ledger-core/src/exchange-withdrawal-registry.ts b/packages/ledger-core/src/exchange-withdrawal-registry.ts new file mode 100644 index 0000000..cabd15e --- /dev/null +++ b/packages/ledger-core/src/exchange-withdrawal-registry.ts @@ -0,0 +1,58 @@ +import type { ExchangeWithdrawalRecord } from "./exchange-withdrawal-types.js"; + +export class ExchangeWithdrawalRegistry { + private readonly withdrawals = new Map(); + + private readonly references = new Map(); + + create(withdrawal: ExchangeWithdrawalRecord): ExchangeWithdrawalRecord { + if (this.withdrawals.has(withdrawal.id)) { + throw new Error(`Exchange withdrawal already exists: ${withdrawal.id}`); + } + + if (this.references.has(withdrawal.reference)) { + throw new Error(`Exchange withdrawal reference already exists: ${withdrawal.reference}`); + } + + this.validate(withdrawal); + + const stored = Object.freeze({ ...withdrawal }); + + this.withdrawals.set(withdrawal.id, stored); + this.references.set(withdrawal.reference, withdrawal.id); + + return stored; + } + + getById(id: string): ExchangeWithdrawalRecord | null { + const withdrawal = this.withdrawals.get(id); + + if (withdrawal === undefined) { + return null; + } + + return Object.freeze({ ...withdrawal }); + } + + private validate(withdrawal: ExchangeWithdrawalRecord): void { + if (withdrawal.exchangeAssetAccountId.trim().length === 0) { + throw new Error("Exchange withdrawal asset account ID must not be empty"); + } + + if (withdrawal.networkId.trim().length === 0) { + throw new Error("Exchange withdrawal network ID must not be empty"); + } + + if (withdrawal.destination.trim().length === 0) { + throw new Error("Exchange withdrawal destination must not be empty"); + } + + if (!/^[0-9]+$/.test(withdrawal.amount) || BigInt(withdrawal.amount) <= 0n) { + throw new Error("Exchange withdrawal amount must be positive"); + } + + if (withdrawal.reference.trim().length === 0) { + throw new Error("Exchange withdrawal reference must not be empty"); + } + } +} diff --git a/packages/ledger-core/src/exchange-withdrawal-types.ts b/packages/ledger-core/src/exchange-withdrawal-types.ts new file mode 100644 index 0000000..0223730 --- /dev/null +++ b/packages/ledger-core/src/exchange-withdrawal-types.ts @@ -0,0 +1,13 @@ +export type ExchangeWithdrawalStatus = + "requested" | "approved" | "rejected" | "submitted" | "confirmed" | "failed"; + +export interface ExchangeWithdrawalRecord { + id: string; + exchangeAssetAccountId: string; + networkId: string; + destination: string; + amount: string; + status: ExchangeWithdrawalStatus; + reference: string; + createdAt: string; +} diff --git a/packages/ledger-core/src/index.ts b/packages/ledger-core/src/index.ts new file mode 100644 index 0000000..66260d5 --- /dev/null +++ b/packages/ledger-core/src/index.ts @@ -0,0 +1,22 @@ +export * from "./ledger-types.js"; +export * from "./ledger-engine.js"; +export * from "./account-registry.js"; +export * from "./subaccount-registry.js"; +export * from "./exchange-account-types.js"; +export * from "./exchange-account-registry.js"; +export * from "./exchange-subaccount-registry.js"; +export * from "./exchange-asset-account-registry.js"; +export * from "./exchange-deposit-types.js"; +export * from "./exchange-deposit-registry.js"; +export * from "./exchange-withdrawal-types.js"; +export * from "./exchange-withdrawal-registry.js"; +export * from "./exchange-trade-types.js"; +export * from "./exchange-trade-registry.js"; +export * from "./exchange-fee-types.js"; +export * from "./exchange-fee-registry.js"; +export * from "./exchange-posting-types.js"; +export * from "./exchange-posting-validator.js"; +export * from "./exchange-posting-builders.js"; +export * from "./reconciliation-types.js"; +export * from "./reconciliation-engine.js"; +export * from "./reconciliation-repository.js"; diff --git a/packages/ledger-core/src/ledger-engine.ts b/packages/ledger-core/src/ledger-engine.ts new file mode 100644 index 0000000..77d9b6e --- /dev/null +++ b/packages/ledger-core/src/ledger-engine.ts @@ -0,0 +1,99 @@ +import type { CreateLedgerTransactionInput, LedgerBalance, LedgerPosting } from "./ledger-types.js"; + +function freezeTransaction( + transaction: CreateLedgerTransactionInput, +): CreateLedgerTransactionInput { + const postings = transaction.postings.map((posting: LedgerPosting) => + Object.freeze({ ...posting }), + ); + + return Object.freeze({ + ...transaction, + postings: Object.freeze(postings), + }); +} + +export class DefaultLedgerEngine { + private readonly transactions = new Map(); + + private readonly references = new Map(); + + createTransaction(transaction: CreateLedgerTransactionInput): CreateLedgerTransactionInput { + if (this.transactions.has(transaction.id)) { + throw new Error(`Ledger transaction already exists: ${transaction.id}`); + } + + if (this.references.has(transaction.reference)) { + throw new Error(`Ledger transaction reference already exists: ${transaction.reference}`); + } + + if (transaction.postings.length < 2) { + throw new Error("Ledger transaction requires at least two postings"); + } + + let debits = 0n; + let credits = 0n; + + for (const posting of transaction.postings) { + if (!/^[0-9]+$/.test(posting.amount) || BigInt(posting.amount) <= 0n) { + throw new Error("Ledger posting amount must be positive"); + } + + const amount = BigInt(posting.amount); + + if (posting.type === "debit") { + debits += amount; + } else if (posting.type === "credit") { + credits += amount; + } + } + + if (debits !== credits) { + throw new Error("Ledger transaction must balance"); + } + + const stored = freezeTransaction(transaction); + + this.transactions.set(transaction.id, stored); + this.references.set(transaction.reference, transaction.id); + + return stored; + } + + getById(id: string): CreateLedgerTransactionInput | null { + const transaction = this.transactions.get(id); + + if (transaction === undefined) { + return null; + } + + return freezeTransaction(transaction); + } + getBalance(ledgerAccountId: string): LedgerBalance { + let debit = 0n; + let credit = 0n; + + for (const transaction of this.transactions.values()) { + for (const posting of transaction.postings) { + if (posting.ledgerAccountId !== ledgerAccountId) { + continue; + } + + const amount = BigInt(posting.amount); + + if (posting.type === "debit") { + debit += amount; + } else if (posting.type === "credit") { + credit += amount; + } + } + } + + return { + ledgerAccountId, + debit: debit.toString(), + credit: credit.toString(), + balance: (debit - credit).toString(), + }; + } +} diff --git a/packages/ledger-core/src/ledger-types.ts b/packages/ledger-core/src/ledger-types.ts new file mode 100644 index 0000000..09a8e1f --- /dev/null +++ b/packages/ledger-core/src/ledger-types.ts @@ -0,0 +1,55 @@ +import type { LedgerEntryStatus, LedgerEntryType } from "@crypto-wallet/shared-types"; + +export interface LedgerAccountRecord { + id: string; + walletId: string; + assetId: string; + chain: string; + kind: LedgerAccountKind; + createdAt: string; +} + +export type LedgerAccountKind = "asset" | "liability" | "revenue" | "expense" | "equity"; + +export interface LedgerSubaccountRecord { + id: string; + ledgerAccountId: string; + name: string; + createdAt: string; +} + +export interface LedgerTransactionRecord { + id: string; + reference: string; + status: LedgerEntryStatus; + createdAt: string; +} + +export interface LedgerEntryRecord { + id: string; + ledgerAccountId: string; + transactionId: string; + type: LedgerEntryType; + amount: string; + status: LedgerEntryStatus; + createdAt: string; +} + +export interface LedgerPosting { + ledgerAccountId: string; + type: LedgerEntryType; + amount: string; +} + +export interface CreateLedgerTransactionInput { + id: string; + reference: string; + postings: readonly LedgerPosting[]; +} + +export interface LedgerBalance { + ledgerAccountId: string; + debit: string; + credit: string; + balance: string; +} diff --git a/packages/ledger-core/src/reconciliation-engine.ts b/packages/ledger-core/src/reconciliation-engine.ts new file mode 100644 index 0000000..c1ae99a --- /dev/null +++ b/packages/ledger-core/src/reconciliation-engine.ts @@ -0,0 +1,61 @@ +import type { + ReconciliationComparison, + ReconciliationContract, + ReconciliationObservation, +} from "./reconciliation-types.js"; + +export class DefaultReconciliationEngine implements ReconciliationContract { + compare( + expected: ReconciliationObservation, + actual: ReconciliationObservation, + ): ReconciliationComparison { + if (!this.isSameScope(expected, actual)) { + throw new Error("Reconciliation observations must use the same scope"); + } + + if (expected.status === "unavailable" || actual.status === "unavailable") { + return { + scope: expected.scope, + expected, + actual, + difference: null, + status: "unavailable", + }; + } + + if (expected.amount === null || actual.amount === null) { + throw new Error("Available reconciliation observations must have an amount"); + } + + const expectedAmount = this.parseAmount(expected.amount); + const actualAmount = this.parseAmount(actual.amount); + const difference = actualAmount - expectedAmount; + + return { + scope: expected.scope, + expected, + actual, + difference: difference.toString(), + status: difference === 0n ? "matched" : "mismatched", + }; + } + + private parseAmount(amount: string): bigint { + if (!/^[0-9]+$/.test(amount)) { + throw new Error("Reconciliation amount must be a non-negative integer"); + } + + return BigInt(amount); + } + + private isSameScope( + expected: ReconciliationObservation, + actual: ReconciliationObservation, + ): boolean { + return ( + expected.scope.assetId === actual.scope.assetId && + expected.scope.networkId === actual.scope.networkId && + expected.scope.accountId === actual.scope.accountId + ); + } +} diff --git a/packages/ledger-core/src/reconciliation-repository.ts b/packages/ledger-core/src/reconciliation-repository.ts new file mode 100644 index 0000000..f22a3fe --- /dev/null +++ b/packages/ledger-core/src/reconciliation-repository.ts @@ -0,0 +1,11 @@ +import type { ReconciliationComparison } from "./reconciliation-types.js"; + +export interface ReconciliationRepository { + save(comparison: ReconciliationComparison): Promise; + + findLatest( + assetId: string, + networkId?: string, + accountId?: string, + ): Promise; +} diff --git a/packages/ledger-core/src/reconciliation-types.ts b/packages/ledger-core/src/reconciliation-types.ts new file mode 100644 index 0000000..26faa3e --- /dev/null +++ b/packages/ledger-core/src/reconciliation-types.ts @@ -0,0 +1,35 @@ +export type ReconciliationSource = "ledger" | "custody" | "blockchain" | "fiat"; + +export type ReconciliationStatus = "matched" | "mismatched" | "unavailable"; + +export type ReconciliationObservationStatus = "available" | "unavailable"; + +export interface ReconciliationScope { + assetId: string; + networkId?: string; + accountId?: string; +} + +export interface ReconciliationObservation { + source: ReconciliationSource; + scope: ReconciliationScope; + status: ReconciliationObservationStatus; + amount: string | null; + observedAt: string; + reference?: string; +} + +export interface ReconciliationComparison { + scope: ReconciliationScope; + expected: ReconciliationObservation; + actual: ReconciliationObservation; + difference: string | null; + status: ReconciliationStatus; +} + +export interface ReconciliationContract { + compare( + expected: ReconciliationObservation, + actual: ReconciliationObservation, + ): ReconciliationComparison; +} diff --git a/packages/ledger-core/src/subaccount-registry.ts b/packages/ledger-core/src/subaccount-registry.ts new file mode 100644 index 0000000..d725e29 --- /dev/null +++ b/packages/ledger-core/src/subaccount-registry.ts @@ -0,0 +1,39 @@ +import type { LedgerSubaccountRecord } from "./ledger-types.js"; + +export class LedgerSubaccountRegistry { + private readonly subaccounts = new Map(); + private readonly identities = new Map(); + + create(subaccount: LedgerSubaccountRecord): LedgerSubaccountRecord { + if (this.subaccounts.has(subaccount.id)) { + throw new Error(`Ledger subaccount already exists: ${subaccount.id}`); + } + + const identity = this.getIdentity(subaccount); + + if (this.identities.has(identity)) { + throw new Error(`Ledger subaccount already exists for ledger account and name`); + } + + const stored = Object.freeze({ ...subaccount }); + + this.subaccounts.set(subaccount.id, stored); + this.identities.set(identity, subaccount.id); + + return stored; + } + + getById(id: string): LedgerSubaccountRecord | null { + const subaccount = this.subaccounts.get(id); + + if (subaccount === undefined) { + return null; + } + + return Object.freeze({ ...subaccount }); + } + + private getIdentity(subaccount: LedgerSubaccountRecord): string { + return JSON.stringify([subaccount.ledgerAccountId, subaccount.name]); + } +} diff --git a/packages/ledger-core/tsconfig.json b/packages/ledger-core/tsconfig.json new file mode 100644 index 0000000..6d34b17 --- /dev/null +++ b/packages/ledger-core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmitOnError": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/storage/migrations/007_exchange_domain.sql b/packages/storage/migrations/007_exchange_domain.sql new file mode 100644 index 0000000..2cce1eb --- /dev/null +++ b/packages/storage/migrations/007_exchange_domain.sql @@ -0,0 +1,367 @@ +-- Exchange / custody domain persistence foundation +-- +-- This schema is intentionally separate from the self-custody wallet domain. +-- PostgreSQL remains the authoritative source of truth. +-- +-- Never store private keys, seed phrases, signing secrets, custody +-- credentials, or other key material in these tables. + +CREATE TABLE IF NOT EXISTS exchange_accounts ( + id UUID PRIMARY KEY, + + owner_type TEXT NOT NULL, + owner_id TEXT NOT NULL, + + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT exchange_accounts_owner_type_check + CHECK (owner_type IN ('customer', 'platform')), + + CONSTRAINT exchange_accounts_kind_check + CHECK ( + kind IN ( + 'customer', + 'treasury', + 'fee', + 'operational' + ) + ), + + CONSTRAINT exchange_accounts_status_check + CHECK ( + status IN ( + 'active', + 'blocked' + ) + ), + + CONSTRAINT exchange_accounts_owner_id_not_empty + CHECK (length(trim(owner_id)) > 0), + + CONSTRAINT exchange_accounts_owner_kind_check + CHECK ( + (owner_type = 'customer' AND kind = 'customer') + OR + (owner_type = 'platform' AND kind <> 'customer') + ), + + UNIQUE(owner_type, owner_id, kind) +); + +CREATE INDEX IF NOT EXISTS idx_exchange_accounts_owner + ON exchange_accounts(owner_type, owner_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_accounts_kind + ON exchange_accounts(kind); + +CREATE INDEX IF NOT EXISTS idx_exchange_accounts_status + ON exchange_accounts(status); + + +CREATE TABLE IF NOT EXISTS exchange_subaccounts ( + id UUID PRIMARY KEY, + + exchange_account_id UUID NOT NULL + REFERENCES exchange_accounts(id), + + name TEXT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT exchange_subaccounts_name_not_empty + CHECK (length(trim(name)) > 0), + + UNIQUE(exchange_account_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_exchange_subaccounts_account + ON exchange_subaccounts(exchange_account_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_id_network_id + ON assets(id, network_id); + +CREATE TABLE IF NOT EXISTS exchange_asset_accounts ( + id UUID PRIMARY KEY, + + exchange_account_id UUID NOT NULL + REFERENCES exchange_accounts(id), + + asset_id UUID NOT NULL, + + network_id UUID NOT NULL + REFERENCES networks(id), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(exchange_account_id, asset_id), + UNIQUE(id, asset_id), + UNIQUE(id, network_id), + + CONSTRAINT exchange_asset_accounts_asset_network_fk + FOREIGN KEY (asset_id, network_id) + REFERENCES assets(id, network_id) +); + +CREATE INDEX IF NOT EXISTS idx_exchange_asset_accounts_account + ON exchange_asset_accounts(exchange_account_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_asset_accounts_asset + ON exchange_asset_accounts(asset_id); + + +CREATE TABLE IF NOT EXISTS exchange_deposits ( + id UUID PRIMARY KEY, + + exchange_asset_account_id UUID NOT NULL, + + network_id UUID NOT NULL, + + CONSTRAINT exchange_deposits_asset_account_network_fk + FOREIGN KEY (exchange_asset_account_id, network_id) + REFERENCES exchange_asset_accounts(id, network_id), + + transaction_hash TEXT NOT NULL, + + amount NUMERIC(78, 0) NOT NULL, + + status TEXT NOT NULL, + + reference TEXT NOT NULL UNIQUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT exchange_deposits_amount_check + CHECK (amount > 0), + + CONSTRAINT exchange_deposits_status_check + CHECK ( + status IN ( + 'detected', + 'confirmed', + 'credited', + 'reversed' + ) + ), + + CONSTRAINT exchange_deposits_transaction_hash_not_empty + CHECK (length(trim(transaction_hash)) > 0) +); + +CREATE INDEX IF NOT EXISTS idx_exchange_deposits_asset_account + ON exchange_deposits(exchange_asset_account_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_deposits_network + ON exchange_deposits(network_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_deposits_transaction + ON exchange_deposits(network_id, transaction_hash); + +CREATE INDEX IF NOT EXISTS idx_exchange_deposits_status + ON exchange_deposits(status); + + +CREATE TABLE IF NOT EXISTS exchange_withdrawals ( + id UUID PRIMARY KEY, + + exchange_asset_account_id UUID NOT NULL, + + network_id UUID NOT NULL, + + CONSTRAINT exchange_withdrawals_asset_account_network_fk + FOREIGN KEY (exchange_asset_account_id, network_id) + REFERENCES exchange_asset_accounts(id, network_id), + + destination TEXT NOT NULL, + + amount NUMERIC(78, 0) NOT NULL, + + status TEXT NOT NULL, + + reference TEXT NOT NULL UNIQUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT exchange_withdrawals_amount_check + CHECK (amount > 0), + + CONSTRAINT exchange_withdrawals_status_check + CHECK ( + status IN ( + 'requested', + 'approved', + 'rejected', + 'submitted', + 'confirmed', + 'failed' + ) + ), + + CONSTRAINT exchange_withdrawals_destination_not_empty + CHECK (length(trim(destination)) > 0) +); + +CREATE INDEX IF NOT EXISTS idx_exchange_withdrawals_asset_account + ON exchange_withdrawals(exchange_asset_account_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_withdrawals_network + ON exchange_withdrawals(network_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_withdrawals_status + ON exchange_withdrawals(status); + + +CREATE TABLE IF NOT EXISTS exchange_trades ( + id UUID PRIMARY KEY, + + buyer_exchange_asset_account_id UUID NOT NULL, + + seller_exchange_asset_account_id UUID NOT NULL, + + base_asset_id UUID NOT NULL + REFERENCES assets(id), + + quote_asset_id UUID NOT NULL + REFERENCES assets(id), + + base_amount NUMERIC(78, 0) NOT NULL, + quote_amount NUMERIC(78, 0) NOT NULL, + + price_numerator NUMERIC(78, 0) NOT NULL, + price_denominator NUMERIC(78, 0) NOT NULL, + + status TEXT NOT NULL, + + reference TEXT NOT NULL UNIQUE, + + executed_at TIMESTAMPTZ NOT NULL, + + CONSTRAINT exchange_trades_base_amount_check + CHECK (base_amount > 0), + + CONSTRAINT exchange_trades_quote_amount_check + CHECK (quote_amount > 0), + + CONSTRAINT exchange_trades_price_numerator_check + CHECK (price_numerator > 0), + + CONSTRAINT exchange_trades_price_denominator_check + CHECK (price_denominator > 0), + + CONSTRAINT exchange_trades_status_check + CHECK ( + status IN ( + 'executed', + 'reversed' + ) + ), + + CONSTRAINT exchange_trades_accounts_differ_check + CHECK ( + buyer_exchange_asset_account_id + <> seller_exchange_asset_account_id + ), + + CONSTRAINT exchange_trades_assets_differ_check + CHECK ( + base_asset_id <> quote_asset_id + ), + + CONSTRAINT exchange_trades_buyer_base_asset_fk + FOREIGN KEY (buyer_exchange_asset_account_id, base_asset_id) + REFERENCES exchange_asset_accounts(id, asset_id), + + CONSTRAINT exchange_trades_seller_base_asset_fk + FOREIGN KEY (seller_exchange_asset_account_id, base_asset_id) + REFERENCES exchange_asset_accounts(id, asset_id), + + CONSTRAINT exchange_trades_buyer_quote_asset_fk + FOREIGN KEY (buyer_exchange_asset_account_id, quote_asset_id) + REFERENCES exchange_asset_accounts(id, asset_id), + + CONSTRAINT exchange_trades_seller_quote_asset_fk + FOREIGN KEY (seller_exchange_asset_account_id, quote_asset_id) + REFERENCES exchange_asset_accounts(id, asset_id) +); + +CREATE INDEX IF NOT EXISTS idx_exchange_trades_buyer_account + ON exchange_trades(buyer_exchange_asset_account_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_trades_seller_account + ON exchange_trades(seller_exchange_asset_account_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_trades_base_asset + ON exchange_trades(base_asset_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_trades_quote_asset + ON exchange_trades(quote_asset_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_trades_status + ON exchange_trades(status); + +CREATE INDEX IF NOT EXISTS idx_exchange_trades_executed_at + ON exchange_trades(executed_at); + + +CREATE TABLE IF NOT EXISTS exchange_fees ( + id UUID PRIMARY KEY, + + trade_id UUID NOT NULL + REFERENCES exchange_trades(id), + + source_exchange_asset_account_id UUID NOT NULL, + + fee_exchange_asset_account_id UUID NOT NULL, + + asset_id UUID NOT NULL, + + CONSTRAINT exchange_fees_source_asset_fk + FOREIGN KEY (source_exchange_asset_account_id, asset_id) + REFERENCES exchange_asset_accounts(id, asset_id), + + CONSTRAINT exchange_fees_fee_asset_fk + FOREIGN KEY (fee_exchange_asset_account_id, asset_id) + REFERENCES exchange_asset_accounts(id, asset_id), + + amount NUMERIC(78, 0) NOT NULL, + + status TEXT NOT NULL, + + reference TEXT NOT NULL UNIQUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT exchange_fees_amount_check + CHECK (amount > 0), + + CONSTRAINT exchange_fees_status_check + CHECK ( + status IN ( + 'charged', + 'reversed' + ) + ), + + CONSTRAINT exchange_fees_accounts_differ_check + CHECK ( + source_exchange_asset_account_id + <> fee_exchange_asset_account_id + ) +); + +CREATE INDEX IF NOT EXISTS idx_exchange_fees_trade + ON exchange_fees(trade_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_fees_source_account + ON exchange_fees(source_exchange_asset_account_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_fees_fee_account + ON exchange_fees(fee_exchange_asset_account_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_fees_asset + ON exchange_fees(asset_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_fees_status + ON exchange_fees(status); diff --git a/packages/storage/migrations/008_exchange_trade_accounts.sql b/packages/storage/migrations/008_exchange_trade_accounts.sql new file mode 100644 index 0000000..525e816 --- /dev/null +++ b/packages/storage/migrations/008_exchange_trade_accounts.sql @@ -0,0 +1,76 @@ +-- Exchange trade account model correction +-- +-- Replace the original two-account trade representation with explicit +-- base/quote asset accounts for both buyer and seller. +-- +-- Migration 007 is already applied and must not be modified. + +ALTER TABLE exchange_trades + DROP CONSTRAINT IF EXISTS exchange_trades_accounts_differ_check, + DROP CONSTRAINT IF EXISTS exchange_trades_buyer_base_asset_fk, + DROP CONSTRAINT IF EXISTS exchange_trades_buyer_quote_asset_fk, + DROP CONSTRAINT IF EXISTS exchange_trades_seller_base_asset_fk, + DROP CONSTRAINT IF EXISTS exchange_trades_seller_quote_asset_fk; + +DROP INDEX IF EXISTS idx_exchange_trades_buyer_account; +DROP INDEX IF EXISTS idx_exchange_trades_seller_account; + +ALTER TABLE exchange_trades + DROP COLUMN IF EXISTS buyer_exchange_asset_account_id, + DROP COLUMN IF EXISTS seller_exchange_asset_account_id; + +ALTER TABLE exchange_trades + ADD COLUMN buyer_base_exchange_asset_account_id UUID NOT NULL, + ADD COLUMN buyer_quote_exchange_asset_account_id UUID NOT NULL, + ADD COLUMN seller_base_exchange_asset_account_id UUID NOT NULL, + ADD COLUMN seller_quote_exchange_asset_account_id UUID NOT NULL; + +ALTER TABLE exchange_trades + ADD CONSTRAINT exchange_trades_accounts_differ_check + CHECK ( + buyer_base_exchange_asset_account_id + <> seller_base_exchange_asset_account_id + OR + buyer_quote_exchange_asset_account_id + <> seller_quote_exchange_asset_account_id + ), + + ADD CONSTRAINT exchange_trades_buyer_base_asset_fk + FOREIGN KEY ( + buyer_base_exchange_asset_account_id, + base_asset_id + ) + REFERENCES exchange_asset_accounts(id, asset_id), + + ADD CONSTRAINT exchange_trades_buyer_quote_asset_fk + FOREIGN KEY ( + buyer_quote_exchange_asset_account_id, + quote_asset_id + ) + REFERENCES exchange_asset_accounts(id, asset_id), + + ADD CONSTRAINT exchange_trades_seller_base_asset_fk + FOREIGN KEY ( + seller_base_exchange_asset_account_id, + base_asset_id + ) + REFERENCES exchange_asset_accounts(id, asset_id), + + ADD CONSTRAINT exchange_trades_seller_quote_asset_fk + FOREIGN KEY ( + seller_quote_exchange_asset_account_id, + quote_asset_id + ) + REFERENCES exchange_asset_accounts(id, asset_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_trades_buyer_base_account + ON exchange_trades(buyer_base_exchange_asset_account_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_trades_buyer_quote_account + ON exchange_trades(buyer_quote_exchange_asset_account_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_trades_seller_base_account + ON exchange_trades(seller_base_exchange_asset_account_id); + +CREATE INDEX IF NOT EXISTS idx_exchange_trades_seller_quote_account + ON exchange_trades(seller_quote_exchange_asset_account_id); \ No newline at end of file diff --git a/packages/storage/migrations/009_reconciliation.sql b/packages/storage/migrations/009_reconciliation.sql new file mode 100644 index 0000000..c4caca9 --- /dev/null +++ b/packages/storage/migrations/009_reconciliation.sql @@ -0,0 +1,53 @@ +-- Reconciliation observations are an audit trail between +-- the authoritative ledger and external accounting sources. + +CREATE TABLE IF NOT EXISTS reconciliation_comparisons ( + id UUID PRIMARY KEY, + + asset_id UUID NOT NULL, + + network_id UUID, + account_id UUID, + + expected_source TEXT NOT NULL, + expected_amount NUMERIC(78, 0) NOT NULL, + + actual_source TEXT NOT NULL, + actual_amount NUMERIC(78, 0) NOT NULL, + + difference NUMERIC(78, 0) NOT NULL, + + status TEXT NOT NULL, + + expected_observed_at TIMESTAMPTZ NOT NULL, + actual_observed_at TIMESTAMPTZ NOT NULL, + + expected_reference TEXT, + actual_reference TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT reconciliation_expected_source_check + CHECK (expected_source IN ('ledger', 'custody', 'blockchain', 'fiat')), + + CONSTRAINT reconciliation_actual_source_check + CHECK (actual_source IN ('ledger', 'custody', 'blockchain', 'fiat')), + + CONSTRAINT reconciliation_status_check + CHECK (status IN ('matched', 'mismatched', 'unavailable')), + + CONSTRAINT reconciliation_expected_amount_check + CHECK (expected_amount >= 0), + + CONSTRAINT reconciliation_actual_amount_check + CHECK (actual_amount >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_reconciliation_scope + ON reconciliation_comparisons(asset_id, network_id, account_id); + +CREATE INDEX IF NOT EXISTS idx_reconciliation_status + ON reconciliation_comparisons(status); + +CREATE INDEX IF NOT EXISTS idx_reconciliation_created_at + ON reconciliation_comparisons(created_at DESC); \ No newline at end of file diff --git a/packages/storage/migrations/010_reconciliation_availability.sql b/packages/storage/migrations/010_reconciliation_availability.sql new file mode 100644 index 0000000..346db6a --- /dev/null +++ b/packages/storage/migrations/010_reconciliation_availability.sql @@ -0,0 +1,59 @@ +-- Preserve individual source availability in reconciliation records. +-- NULL amount means that the corresponding source was unavailable. + +ALTER TABLE reconciliation_comparisons + ADD COLUMN IF NOT EXISTS expected_observation_status TEXT, + ADD COLUMN IF NOT EXISTS actual_observation_status TEXT; + +UPDATE reconciliation_comparisons +SET + expected_observation_status = 'available', + actual_observation_status = 'available' +WHERE expected_observation_status IS NULL + OR actual_observation_status IS NULL; + +ALTER TABLE reconciliation_comparisons + ALTER COLUMN expected_observation_status SET NOT NULL, + ALTER COLUMN actual_observation_status SET NOT NULL; + +ALTER TABLE reconciliation_comparisons + ALTER COLUMN expected_amount DROP NOT NULL, + ALTER COLUMN actual_amount DROP NOT NULL; + +ALTER TABLE reconciliation_comparisons + ADD CONSTRAINT reconciliation_expected_observation_status_check + CHECK ( + expected_observation_status IN ('available', 'unavailable') + ), + ADD CONSTRAINT reconciliation_actual_observation_status_check + CHECK ( + actual_observation_status IN ('available', 'unavailable') + ); + +ALTER TABLE reconciliation_comparisons + ADD CONSTRAINT reconciliation_expected_amount_availability_check + CHECK ( + ( + expected_observation_status = 'available' + AND expected_amount IS NOT NULL + AND expected_amount >= 0 + ) + OR + ( + expected_observation_status = 'unavailable' + AND expected_amount IS NULL + ) + ), + ADD CONSTRAINT reconciliation_actual_amount_availability_check + CHECK ( + ( + actual_observation_status = 'available' + AND actual_amount IS NOT NULL + AND actual_amount >= 0 + ) + OR + ( + actual_observation_status = 'unavailable' + AND actual_amount IS NULL + ) + ); \ No newline at end of file diff --git a/packages/storage/migrations/011_reconciliation_difference_nullable.sql b/packages/storage/migrations/011_reconciliation_difference_nullable.sql new file mode 100644 index 0000000..ae04a65 --- /dev/null +++ b/packages/storage/migrations/011_reconciliation_difference_nullable.sql @@ -0,0 +1,2 @@ +ALTER TABLE reconciliation_comparisons + ALTER COLUMN difference DROP NOT NULL; \ No newline at end of file diff --git a/packages/storage/migrations/012_withdrawal_segregation_of_duties.sql b/packages/storage/migrations/012_withdrawal_segregation_of_duties.sql new file mode 100644 index 0000000..4caaca6 --- /dev/null +++ b/packages/storage/migrations/012_withdrawal_segregation_of_duties.sql @@ -0,0 +1,48 @@ +ALTER TABLE exchange_withdrawals + ADD COLUMN IF NOT EXISTS requested_by TEXT, + ADD COLUMN IF NOT EXISTS approved_by TEXT; + +UPDATE exchange_withdrawals +SET requested_by = 'legacy-system' +WHERE requested_by IS NULL; + +ALTER TABLE exchange_withdrawals + ALTER COLUMN requested_by SET NOT NULL; + +ALTER TABLE exchange_withdrawals + ADD CONSTRAINT exchange_withdrawals_requested_by_not_empty + CHECK (length(trim(requested_by)) > 0); + +ALTER TABLE exchange_withdrawals + ADD CONSTRAINT exchange_withdrawals_approved_by_not_empty + CHECK ( + approved_by IS NULL + OR length(trim(approved_by)) > 0 + ); + +ALTER TABLE exchange_withdrawals + ADD CONSTRAINT exchange_withdrawals_approver_differs_from_requester + CHECK ( + approved_by IS NULL + OR approved_by <> requested_by + ); + +ALTER TABLE exchange_withdrawals + ADD CONSTRAINT exchange_withdrawals_approval_actor_check + CHECK ( + ( + status = 'requested' + AND approved_by IS NULL + ) + OR + ( + status IN ( + 'approved', + 'rejected', + 'submitted', + 'confirmed', + 'failed' + ) + AND approved_by IS NOT NULL + ) + ); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef9be3f..2c9e9db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,6 +299,9 @@ importers: "@crypto-wallet/config": specifier: workspace:* version: link:../../packages/config + "@crypto-wallet/ledger-core": + specifier: workspace:* + version: link:../../packages/ledger-core "@crypto-wallet/shared-types": specifier: workspace:* version: link:../../packages/shared-types @@ -513,6 +516,19 @@ importers: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + packages/ledger-core: + dependencies: + "@crypto-wallet/shared-types": + specifier: workspace:* + version: link:../shared-types + "@crypto-wallet/storage": + specifier: workspace:* + version: link:../storage + devDependencies: + vitest: + specifier: ^4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + packages/secure-storage: dependencies: "@crypto-wallet/crypto":