feat(finance): BTC basket flows, client scoping, and jewelry-box store

- Finance API: baskets, holdings, rebalances, deposits, bridge withdrawals, vault checks.
- Schemas: btc-basket; api-client finance types; workspace lockfile update.
- Vitest config for finance service; expanded tests.

Made-with: Cursor
This commit is contained in:
defiQUG
2026-04-07 22:59:32 -07:00
parent 923b703d97
commit 3f7cc0f854
18 changed files with 1825 additions and 494 deletions

View File

@@ -9,11 +9,14 @@
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fastify/swagger": "^8.13.0",
"@fastify/swagger-ui": "^2.0.0",
"@the-order/database": "workspace:*",
"@the-order/payment-gateway": "workspace:^",
"@the-order/schemas": "workspace:*",
"@the-order/shared": "workspace:*",
@@ -23,6 +26,7 @@
"@types/node": "^20.10.6",
"eslint": "^9.17.0",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^1.6.1"
}
}

View File

@@ -1,62 +1,324 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { FastifyInstance } from 'fastify';
import { createApiHelpers } from '@the-order/test-utils';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { createHmac } from 'crypto';
import type { FastifyInstance } from 'fastify';
describe('Finance Service', () => {
let app: FastifyInstance;
let api: ReturnType<typeof createApiHelpers>;
const originalEnv = { ...process.env };
beforeEach(async () => {
app = Fastify({
logger: false,
});
function setTestEnv(): void {
process.env.NODE_ENV = 'development';
process.env.PORT = '4003';
process.env.DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/the_order_test';
process.env.STORAGE_BUCKET = 'test-bucket';
process.env.KMS_KEY_ID = 'test-kms-key';
process.env.JWT_SECRET = 'test-jwt-secret-which-is-long-enough-1234567890';
}
app.get('/health', async () => {
return { status: 'ok', service: 'finance' };
});
function base64Url(value: string): string {
return Buffer.from(value).toString('base64url');
}
await app.ready();
api = createApiHelpers(app);
});
function createJwt(payload: Record<string, unknown>): string {
const header = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = base64Url(JSON.stringify(payload));
const signature = createHmac('sha256', process.env.JWT_SECRET as string)
.update(`${header}.${body}`)
.digest('base64url');
return `${header}.${body}.${signature}`;
}
afterEach(async () => {
if (app) {
await app.close();
}
});
let createServer: (typeof import('./index'))['createServer'];
describe('GET /health', () => {
it('should return health status', async () => {
const response = await api.get('/health');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('status');
expect(response.body).toHaveProperty('service', 'finance');
});
});
describe('POST /ledger/entry', () => {
it('should require authentication', async () => {
const response = await api.post('/ledger/entry', {
accountId: 'test-account',
type: 'debit',
amount: 100,
currency: 'USD',
});
expect([401, 500]).toContain(response.status);
});
});
describe('POST /payments', () => {
it('should require authentication', async () => {
const response = await api.post('/payments', {
amount: 100,
currency: 'USD',
paymentMethod: 'credit_card',
});
expect([401, 500]).toContain(response.status);
});
});
beforeAll(async () => {
setTestEnv();
({ createServer } = await import('./index'));
});
describe('Finance Service', () => {
let server: FastifyInstance;
afterEach(async () => {
if (server) {
await server.close();
}
process.env = { ...originalEnv, ...process.env };
});
it('returns service health', async () => {
server = await createServer({ disableSwagger: true });
await server.ready();
const response = await server.inject({
method: 'GET',
url: '/health',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
service: 'finance',
});
});
it('creates a basket-backed BTC deposit and exposes holdings plus rebalance plan', async () => {
server = await createServer({ disableSwagger: true });
await server.ready();
const token = createJwt({
id: 'client-1',
roles: ['client', 'finance'],
});
const depositResponse = await server.inject({
method: 'POST',
url: '/api/v1/btc-deposits',
headers: {
authorization: `Bearer ${token}`,
},
payload: {
clientId: 'client-1',
mandateName: 'Solace BTC jewelry box',
chain138VaultAddress: '0x1111111111111111111111111111111111111111',
allocations: [
{ symbol: 'cBTC', targetWeightBps: 4000 },
{ symbol: 'cUSDT', targetWeightBps: 3500 },
{ symbol: 'cXAUC', targetWeightBps: 2500 },
],
expectedAmountSats: 250000000,
},
});
expect(depositResponse.statusCode).toBe(201);
const depositPayload = depositResponse.json() as {
deposit: { id: string; basketId: string; status: string; confirmationsRequired: number; depositAddress: string };
basket: { id: string };
createdBasket: boolean;
};
expect(depositPayload.createdBasket).toBe(true);
expect(depositPayload.deposit.status).toBe('instruction_created');
expect(depositPayload.deposit.confirmationsRequired).toBe(6);
expect(depositPayload.deposit.depositAddress.startsWith('bc1q')).toBe(true);
expect(depositPayload.deposit.basketId).toBe(depositPayload.basket.id);
const holdingsResponse = await server.inject({
method: 'GET',
url: `/api/v1/holdings?clientId=client-1&basketId=${depositPayload.basket.id}`,
headers: {
authorization: `Bearer ${token}`,
},
});
expect(holdingsResponse.statusCode).toBe(200);
const holdingsPayload = holdingsResponse.json() as {
holdings: Array<{ symbol: string; allocationWeightBps: number; status: string }>;
};
expect(holdingsPayload.holdings).toHaveLength(3);
expect(holdingsPayload.holdings[1]).toMatchObject({
symbol: 'cUSDT',
allocationWeightBps: 3500,
status: 'pending_funding',
});
const rebalancesResponse = await server.inject({
method: 'GET',
url: `/api/v1/rebalances?basketId=${depositPayload.basket.id}`,
headers: {
authorization: `Bearer ${token}`,
},
});
expect(rebalancesResponse.statusCode).toBe(200);
const rebalancesPayload = rebalancesResponse.json() as {
rebalances: Array<{ sourceSymbol: string; targetSymbols: string[]; status: string }>;
};
expect(rebalancesPayload.rebalances).toHaveLength(1);
expect(rebalancesPayload.rebalances[0]).toMatchObject({
sourceSymbol: 'cBTC',
status: 'planned',
});
expect(rebalancesPayload.rebalances[0].targetSymbols).toEqual(['cUSDT', 'cXAUC']);
});
it('defaults bridge withdrawals to cBTC -> cWBTC', async () => {
server = await createServer({ disableSwagger: true });
await server.ready();
const token = createJwt({
id: 'client-2',
roles: ['client'],
});
const basketResponse = await server.inject({
method: 'POST',
url: '/api/v1/baskets',
headers: {
authorization: `Bearer ${token}`,
},
payload: {
clientId: 'client-2',
mandateName: 'Client 2 basket',
chain138VaultAddress: '0x2222222222222222222222222222222222222222',
allocations: [{ symbol: 'cBTC', targetWeightBps: 10000 }],
},
});
const basketId = (basketResponse.json() as { basket: { id: string } }).basket.id;
const withdrawalResponse = await server.inject({
method: 'POST',
url: '/api/v1/withdrawals/bridge',
headers: {
authorization: `Bearer ${token}`,
},
payload: {
clientId: 'client-2',
basketId,
destinationChainId: 1,
destinationAddress: '0x3333333333333333333333333333333333333333',
amount: '125000000',
},
});
expect(withdrawalResponse.statusCode).toBe(201);
expect(withdrawalResponse.json()).toMatchObject({
withdrawal: {
sourceSymbol: 'cBTC',
destinationSymbol: 'cWBTC',
destinationChainId: 1,
},
});
});
it('rejects cross-client access to another client basket', async () => {
server = await createServer({ disableSwagger: true });
await server.ready();
const ownerToken = createJwt({
id: 'client-owner',
roles: ['client'],
});
const otherClientToken = createJwt({
id: 'client-other',
roles: ['client'],
});
const basketResponse = await server.inject({
method: 'POST',
url: '/api/v1/baskets',
headers: {
authorization: `Bearer ${ownerToken}`,
},
payload: {
clientId: 'client-owner',
mandateName: 'Owner basket',
chain138VaultAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
allocations: [{ symbol: 'cBTC', targetWeightBps: 10000 }],
},
});
const basketId = (basketResponse.json() as { basket: { id: string } }).basket.id;
const holdingsResponse = await server.inject({
method: 'GET',
url: `/api/v1/holdings?basketId=${basketId}`,
headers: {
authorization: `Bearer ${otherClientToken}`,
},
});
expect(holdingsResponse.statusCode).toBe(403);
const withdrawalResponse = await server.inject({
method: 'POST',
url: '/api/v1/withdrawals/bridge',
headers: {
authorization: `Bearer ${otherClientToken}`,
},
payload: {
clientId: 'client-other',
basketId,
destinationChainId: 1,
destinationAddress: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
amount: '1000',
},
});
expect(withdrawalResponse.statusCode).toBe(403);
});
it('rejects unknown basket ids and mismatched vault addresses on deposit creation', async () => {
server = await createServer({ disableSwagger: true });
await server.ready();
const token = createJwt({
id: 'client-3',
roles: ['client'],
});
const unknownBasketResponse = await server.inject({
method: 'POST',
url: '/api/v1/btc-deposits',
headers: {
authorization: `Bearer ${token}`,
},
payload: {
clientId: 'client-3',
basketId: '11111111-1111-4111-8111-111111111111',
chain138VaultAddress: '0xcccccccccccccccccccccccccccccccccccccccc',
},
});
expect(unknownBasketResponse.statusCode).toBe(404);
const basketResponse = await server.inject({
method: 'POST',
url: '/api/v1/baskets',
headers: {
authorization: `Bearer ${token}`,
},
payload: {
clientId: 'client-3',
mandateName: 'Vault match basket',
chain138VaultAddress: '0xdddddddddddddddddddddddddddddddddddddddd',
allocations: [{ symbol: 'cBTC', targetWeightBps: 10000 }],
},
});
const basketId = (basketResponse.json() as { basket: { id: string } }).basket.id;
const mismatchedVaultResponse = await server.inject({
method: 'POST',
url: '/api/v1/btc-deposits',
headers: {
authorization: `Bearer ${token}`,
},
payload: {
clientId: 'client-3',
basketId,
chain138VaultAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
},
});
expect(mismatchedVaultResponse.statusCode).toBe(400);
});
it('validates payment payloads on the versioned route', async () => {
server = await createServer({ disableSwagger: true });
await server.ready();
const token = createJwt({
id: 'finance-user',
roles: ['finance'],
});
const response = await server.inject({
method: 'POST',
url: '/api/v1/payments',
headers: {
authorization: `Bearer ${token}`,
},
payload: {},
});
expect(response.statusCode).toBe(400);
});
});

View File

@@ -1,191 +1,236 @@
/**
* Finance Service
* Handles payments, ledgers, rate models, and invoicing
* Handles payments, ledgers, and BTC jewelry-box workflows.
*/
import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify';
import { fileURLToPath } from 'url';
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUI from '@fastify/swagger-ui';
import {
AppError,
errorHandler,
createLogger,
registerSecurityPlugins,
addCorrelationId,
addRequestLogging,
getEnv,
createBodySchema,
authenticateJWT,
requireRole,
} from '@the-order/shared';
import { CreateLedgerEntrySchema, CreatePaymentSchema } from '@the-order/schemas';
import { healthCheck as dbHealthCheck, getPool, createLedgerEntry, createPayment, updatePaymentStatus } from '@the-order/database';
import {
type BasketMandate,
type CreateBasketMandate,
type CreateBtcDeposit,
type CreateBridgeWithdrawal,
} from '@the-order/schemas';
import {
healthCheck as dbHealthCheck,
getPool,
createLedgerEntry,
createPayment,
updatePaymentStatus,
} from '@the-order/database';
import { StripePaymentGateway } from '@the-order/payment-gateway';
import {
JewelryBoxStore,
createDefaultBridgeWithdrawal,
} from './jewelry-box-store';
const logger = createLogger('finance-service');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const server: any = Fastify({
logger: logger as any,
requestIdLogLabel: 'requestId',
disableRequestLogging: false,
});
// Initialize database pool
const env = getEnv();
if (env.DATABASE_URL) {
getPool({ connectionString: env.DATABASE_URL });
const PRIVILEGED_FINANCE_ROLES = new Set(['admin', 'accountant', 'finance']);
export type CreateServerOptions = {
jewelryBoxStore?: JewelryBoxStore;
disableSwagger?: boolean;
};
function isPrivilegedFinanceUser(request: FastifyRequest): boolean {
return (request.user?.roles ?? []).some((role) => PRIVILEGED_FINANCE_ROLES.has(role));
}
// Initialize payment gateway
let paymentGateway: StripePaymentGateway | null = null;
try {
if (env.PAYMENT_GATEWAY_API_KEY) {
paymentGateway = new StripePaymentGateway();
function assertClientAccess(request: FastifyRequest, clientId: string): void {
if (!request.user) {
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
}
} catch (error) {
logger.warn({ err: error }, 'Payment gateway not configured');
if (request.user.id === clientId || isPrivilegedFinanceUser(request)) {
return;
}
throw new AppError(403, 'FORBIDDEN', 'Cannot access another client\'s finance basket');
}
// Initialize server
async function initializeServer(): Promise<void> {
// Register Swagger
const swaggerUrl = env.SWAGGER_SERVER_URL || (env.NODE_ENV === 'development' ? 'http://localhost:4003' : undefined);
function resolveClientScope(request: FastifyRequest, clientId?: string): string | undefined {
if (clientId) {
assertClientAccess(request, clientId);
return clientId;
}
return isPrivilegedFinanceUser(request) ? undefined : request.user?.id;
}
function getAccessibleBasket(
request: FastifyRequest,
store: JewelryBoxStore,
basketId: string,
): BasketMandate {
const basket = store.getBasket(basketId);
if (!basket) {
throw new AppError(404, 'NOT_FOUND', 'Basket not found');
}
assertClientAccess(request, basket.clientId);
return basket;
}
function createPaymentGateway(): StripePaymentGateway | null {
try {
return env.PAYMENT_GATEWAY_API_KEY ? new StripePaymentGateway() : null;
} catch (error) {
logger.warn({ err: error }, 'Payment gateway not configured');
return null;
}
}
async function safeDbHealthCheck(): Promise<boolean> {
try {
return await dbHealthCheck();
} catch (error) {
logger.warn({ err: error }, 'Database health check failed');
return false;
}
}
async function registerDocs(server: any, disableSwagger = false): Promise<void> {
if (disableSwagger) {
return;
}
const swaggerUrl =
env.SWAGGER_SERVER_URL || (env.NODE_ENV === 'development' ? 'http://localhost:4003' : undefined);
if (!swaggerUrl) {
logger.warn('SWAGGER_SERVER_URL not set, Swagger documentation will not be available');
} else {
await server.register(fastifySwagger, {
openapi: {
info: {
title: 'Finance Service API',
description: 'Payments, ledgers, rate models, and invoicing',
version: '1.0.0',
},
servers: [
{
url: swaggerUrl,
description: env.NODE_ENV || 'Development server',
},
],
},
});
await server.register(fastifySwaggerUI, {
routePrefix: '/docs',
});
return;
}
await registerSecurityPlugins(server as any);
addCorrelationId(server as any);
addRequestLogging(server as any);
server.setErrorHandler(errorHandler as any);
await server.register(fastifySwagger, {
openapi: {
info: {
title: 'Finance Service API',
description: 'Payments, ledgers, and BTC jewelry-box workflows',
version: '1.0.0',
},
servers: [
{
url: swaggerUrl,
description: env.NODE_ENV || 'Development server',
},
],
},
});
await server.register(fastifySwaggerUI, {
routePrefix: '/docs',
});
}
// Health check
server.get(
'/health',
{
schema: {
description: 'Health check endpoint',
tags: ['health'],
response: {
200: {
type: 'object',
properties: {
status: { type: 'string' },
service: { type: 'string' },
database: { type: 'string' },
},
},
},
},
},
async () => {
const dbHealthy = await dbHealthCheck();
return {
status: dbHealthy ? 'ok' : 'degraded',
service: 'finance',
database: dbHealthy ? 'connected' : 'disconnected',
};
}
);
// Ledger operations
server.post(
'/ledger/entry',
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
preHandler: [authenticateJWT as any, requireRole('admin', 'accountant', 'finance') as any],
schema: {
...createBodySchema(CreateLedgerEntrySchema),
description: 'Create a ledger entry',
tags: ['ledger'],
response: {
201: {
type: 'object',
properties: {
entry: {
type: 'object',
function registerHealthRoute(server: any): void {
server.get(
'/health',
{
schema: {
description: 'Health check endpoint',
tags: ['health'],
response: {
200: {
type: 'object',
properties: {
status: { type: 'string' },
service: { type: 'string' },
database: { type: 'string' },
},
},
},
},
},
},
async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as {
accountId: string;
type: 'debit' | 'credit';
amount: number;
currency: string;
description?: string;
reference?: string;
};
async () => {
const dbHealthy = await safeDbHealthCheck();
return {
status: dbHealthy ? 'ok' : 'degraded',
service: 'finance',
database: dbHealthy ? 'connected' : 'disconnected',
};
},
);
}
// Save to database
const entry = await createLedgerEntry({
account_id: body.accountId,
type: body.type,
amount: body.amount,
currency: body.currency,
description: body.description,
reference: body.reference,
});
return reply.status(201).send({ entry });
}
);
// Payment processing
server.post(
'/payments',
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
preHandler: [authenticateJWT as any],
schema: {
...createBodySchema(CreatePaymentSchema),
description: 'Process a payment',
tags: ['payments'],
response: {
201: {
function registerLedgerRoute(server: any): void {
server.post(
'/ledger/entry',
{
preHandler: [authenticateJWT as never, requireRole('admin', 'accountant', 'finance') as never],
schema: {
description: 'Create a ledger entry',
tags: ['ledger'],
body: {
type: 'object',
additionalProperties: false,
required: ['accountId', 'type', 'amount', 'currency'],
properties: {
payment: {
type: 'object',
},
accountId: { type: 'string' },
type: { type: 'string', enum: ['debit', 'credit'] },
amount: { type: 'number', exclusiveMinimum: 0 },
currency: { type: 'string', minLength: 3, maxLength: 3 },
description: { type: 'string' },
reference: { type: 'string' },
},
},
},
},
},
async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as {
amount: number;
currency: string;
paymentMethod: string;
metadata?: Record<string, string>;
};
async (
request: FastifyRequest<{
Body: {
accountId: string;
type: 'debit' | 'credit';
amount: number;
currency: string;
description?: string;
reference?: string;
};
}>,
reply: FastifyReply,
) => {
const body = request.body;
const entry = await createLedgerEntry({
account_id: body.accountId,
type: body.type,
amount: body.amount,
currency: body.currency,
description: body.description,
reference: body.reference,
});
// Create payment record
return reply.status(201).send({ entry });
},
);
}
function registerPaymentsRoute(server: any, paymentGateway: StripePaymentGateway | null): void {
const handler = async (
request: FastifyRequest<{
Body: {
amount: number;
currency: string;
paymentMethod: string;
metadata?: Record<string, string>;
};
}>,
reply: FastifyReply,
) => {
const body = request.body;
const payment = await createPayment({
amount: body.amount,
currency: body.currency,
@@ -193,53 +238,324 @@ server.post(
payment_method: body.paymentMethod,
});
// Process payment through gateway if available
if (paymentGateway) {
try {
const result = await paymentGateway.processPayment(
body.amount,
body.currency,
body.paymentMethod,
{
payment_id: payment.id,
...body.metadata,
}
);
// Update payment status
const updatedPayment = await updatePaymentStatus(
payment.id,
result.status,
result.transactionId,
result.gatewayResponse
);
return reply.status(201).send({ payment: updatedPayment });
} catch (error) {
logger.error({ err: error, paymentId: payment.id }, 'Payment processing failed');
await updatePaymentStatus(payment.id, 'failed', undefined, { error: String(error) });
throw error;
}
} else {
// No payment gateway configured - return pending status
if (!paymentGateway) {
return reply.status(201).send({ payment });
}
}
);
// Start server
const start = async () => {
try {
const result = await paymentGateway.processPayment(
body.amount,
body.currency,
body.paymentMethod,
{
payment_id: payment.id,
...body.metadata,
},
);
const updatedPayment = await updatePaymentStatus(
payment.id,
result.status,
result.transactionId,
result.gatewayResponse,
);
return reply.status(201).send({ payment: updatedPayment });
} catch (error) {
logger.error({ err: error, paymentId: payment.id }, 'Payment processing failed');
await updatePaymentStatus(payment.id, 'failed', undefined, { error: String(error) });
throw error;
}
};
const options = {
preHandler: [authenticateJWT as never],
schema: {
description: 'Process a payment',
tags: ['payments'],
body: {
type: 'object',
additionalProperties: false,
required: ['amount', 'currency', 'paymentMethod'],
properties: {
amount: { type: 'number', exclusiveMinimum: 0 },
currency: { type: 'string', minLength: 3, maxLength: 3 },
paymentMethod: { type: 'string' },
description: { type: 'string' },
metadata: {
type: 'object',
additionalProperties: { type: 'string' },
},
},
},
},
};
server.post('/payments', options, handler);
server.post('/api/v1/payments', options, handler);
}
function registerJewelryBoxRoutes(server: any, store: JewelryBoxStore): void {
server.post(
'/api/v1/baskets',
{
preHandler: [authenticateJWT as never],
schema: {
description: 'Create a Chain 138 basket mandate for BTC settlement flows',
tags: ['jewelry-box'],
body: {
type: 'object',
additionalProperties: false,
required: ['clientId', 'mandateName', 'chain138VaultAddress', 'allocations'],
properties: {
clientId: { type: 'string', minLength: 1 },
mandateName: { type: 'string', minLength: 1 },
chain138VaultAddress: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' },
baseAssetSymbol: { type: 'string', minLength: 2, maxLength: 16 },
allocations: {
type: 'array',
minItems: 1,
items: {
type: 'object',
additionalProperties: false,
required: ['symbol', 'targetWeightBps'],
properties: {
symbol: { type: 'string', minLength: 2, maxLength: 16 },
targetWeightBps: { type: 'integer', minimum: 1, maximum: 10000 },
routeHint: { type: 'string' },
},
},
},
},
},
},
},
async (
request: FastifyRequest<{ Body: CreateBasketMandate }>,
reply: FastifyReply,
) => {
assertClientAccess(request, request.body.clientId);
const basket = store.createBasket(request.body);
return reply.status(201).send({ basket });
},
);
server.post(
'/api/v1/btc-deposits',
{
preHandler: [authenticateJWT as never],
schema: {
description: 'Create a BTC deposit instruction and associate it with a jewelry-box basket',
tags: ['jewelry-box'],
body: {
type: 'object',
additionalProperties: false,
required: ['clientId', 'chain138VaultAddress'],
properties: {
clientId: { type: 'string', minLength: 1 },
basketId: { type: 'string' },
mandateName: { type: 'string', minLength: 1 },
chain138VaultAddress: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' },
expectedAmountSats: { type: 'integer', minimum: 1 },
clientReference: { type: 'string', minLength: 1 },
allocations: {
type: 'array',
minItems: 1,
items: {
type: 'object',
additionalProperties: false,
required: ['symbol', 'targetWeightBps'],
properties: {
symbol: { type: 'string', minLength: 2, maxLength: 16 },
targetWeightBps: { type: 'integer', minimum: 1, maximum: 10000 },
routeHint: { type: 'string' },
},
},
},
},
},
},
},
async (
request: FastifyRequest<{ Body: CreateBtcDeposit }>,
reply: FastifyReply,
) => {
assertClientAccess(request, request.body.clientId);
if (request.body.basketId) {
const basket = getAccessibleBasket(request, store, request.body.basketId);
if (basket.clientId !== request.body.clientId) {
throw new AppError(400, 'CLIENT_MISMATCH', 'Deposit client does not match basket client');
}
if (basket.chain138VaultAddress !== request.body.chain138VaultAddress) {
throw new AppError(
400,
'VAULT_ADDRESS_MISMATCH',
'Deposit vault address does not match the selected basket',
);
}
}
const result = store.createDeposit(request.body);
return reply.status(201).send(result);
},
);
server.get(
'/api/v1/btc-deposits/:id',
{
preHandler: [authenticateJWT as never],
schema: {
description: 'Get BTC deposit status',
tags: ['jewelry-box'],
},
},
async (
request: FastifyRequest<{ Params: { id: string } }>,
) => {
const deposit = store.getDeposit(request.params.id);
if (!deposit) {
throw new AppError(404, 'NOT_FOUND', 'Deposit not found');
}
assertClientAccess(request, deposit.clientId);
return { deposit };
},
);
server.get(
'/api/v1/holdings',
{
preHandler: [authenticateJWT as never],
schema: {
description: 'List basket holdings derived from underlying allocations',
tags: ['jewelry-box'],
},
},
async (
request: FastifyRequest<{ Querystring: { clientId?: string; basketId?: string } }>,
) => {
if (request.query.basketId) {
const basket = getAccessibleBasket(request, store, request.query.basketId);
if (request.query.clientId && basket.clientId !== request.query.clientId) {
throw new AppError(400, 'CLIENT_MISMATCH', 'Requested client does not own the selected basket');
}
}
const holdings = store.listHoldings({
...request.query,
clientId: resolveClientScope(request, request.query.clientId),
});
return { holdings };
},
);
server.get(
'/api/v1/rebalances',
{
preHandler: [authenticateJWT as never],
schema: {
description: 'List planned or queued rebalances for jewelry-box baskets',
tags: ['jewelry-box'],
},
},
async (
request: FastifyRequest<{ Querystring: { clientId?: string; basketId?: string } }>,
) => {
if (request.query.basketId) {
const basket = getAccessibleBasket(request, store, request.query.basketId);
if (request.query.clientId && basket.clientId !== request.query.clientId) {
throw new AppError(400, 'CLIENT_MISMATCH', 'Requested client does not own the selected basket');
}
}
const rebalances = store.listRebalances({
...request.query,
clientId: resolveClientScope(request, request.query.clientId),
});
return { rebalances };
},
);
server.post(
'/api/v1/withdrawals/bridge',
{
preHandler: [authenticateJWT as never],
schema: {
description: 'Request a public-chain bridge withdrawal from a jewelry-box basket',
tags: ['jewelry-box'],
body: {
type: 'object',
additionalProperties: false,
required: ['clientId', 'basketId', 'destinationChainId', 'destinationAddress', 'amount'],
properties: {
clientId: { type: 'string', minLength: 1 },
basketId: { type: 'string', minLength: 1 },
sourceSymbol: { type: 'string', minLength: 2, maxLength: 16 },
destinationSymbol: { type: 'string', minLength: 2, maxLength: 16 },
destinationChainId: { type: 'integer', minimum: 1 },
destinationAddress: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' },
amount: { type: 'string', minLength: 1 },
},
},
},
},
async (
request: FastifyRequest<{ Body: CreateBridgeWithdrawal }>,
reply: FastifyReply,
) => {
const basket = getAccessibleBasket(request, store, request.body.basketId);
if (basket.clientId !== request.body.clientId) {
throw new AppError(400, 'CLIENT_MISMATCH', 'Withdrawal client does not match basket client');
}
const withdrawal = store.createBridgeWithdrawal(
createDefaultBridgeWithdrawal(request.body as CreateBridgeWithdrawal),
);
return reply.status(201).send({ withdrawal });
},
);
}
export async function createServer(options: CreateServerOptions = {}): Promise<FastifyInstance> {
if (env.DATABASE_URL) {
getPool({ connectionString: env.DATABASE_URL });
}
const server: any = Fastify({
logger: logger as any,
requestIdLogLabel: 'requestId',
disableRequestLogging: false,
});
await registerDocs(server, options.disableSwagger);
await registerSecurityPlugins(server as any);
addCorrelationId(server);
addRequestLogging(server);
server.setErrorHandler(errorHandler as any);
registerHealthRoute(server);
registerLedgerRoute(server);
registerPaymentsRoute(server, createPaymentGateway());
registerJewelryBoxRoutes(server, options.jewelryBoxStore || new JewelryBoxStore());
return server as FastifyInstance;
}
export async function start(): Promise<void> {
try {
await initializeServer();
const env = getEnv();
const server = await createServer();
const port = env.PORT || 4003;
await server.listen({ port, host: '0.0.0.0' });
logger.info({ port }, 'Finance service listening');
} catch (err) {
logger.error({ err }, 'Failed to start server');
} catch (error) {
logger.error({ err: error }, 'Failed to start server');
process.exit(1);
}
};
}
start();
const isDirectExecution = process.argv[1] === fileURLToPath(import.meta.url);
if (isDirectExecution) {
void start();
}

View File

@@ -0,0 +1,296 @@
import { randomUUID } from 'crypto';
import type {
BasketAllocation,
BasketMandate,
BtcDeposit,
BridgeWithdrawal,
CreateBasketMandate,
CreateBridgeWithdrawal,
CreateBtcDeposit,
Holding,
Rebalance,
} from '@the-order/schemas';
const BTC_CONFIRMATIONS_REQUIRED = 6;
const DEFAULT_ALLOCATION: BasketAllocation = { symbol: 'cBTC', targetWeightBps: 10_000 };
const BTC_BRIDGE_SYMBOL = 'cWBTC';
const BECH32_ALPHABET = '023456789acdefghjklmnpqrstuvwxyz';
function nowIso(): string {
return new Date().toISOString();
}
function sumWeights(allocations: BasketAllocation[]): number {
return allocations.reduce((total, allocation) => total + allocation.targetWeightBps, 0);
}
function toPseudoBitcoinAddress(seed: string): string {
const normalized = seed.replace(/[^a-f0-9]/gi, '').toLowerCase() || 'cb7c138';
let out = 'bc1q';
for (let index = 0; index < 32; index += 1) {
const source = normalized[index % normalized.length] ?? '0';
const value = parseInt(source, 16);
out += Number.isNaN(value) ? 'q' : BECH32_ALPHABET[value];
}
return out;
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export class JewelryBoxStore {
private readonly baskets = new Map<string, BasketMandate>();
private readonly deposits = new Map<string, BtcDeposit>();
private readonly rebalances = new Map<string, Rebalance>();
private readonly withdrawals = new Map<string, BridgeWithdrawal>();
createBasket(input: CreateBasketMandate): BasketMandate {
const allocations = input.allocations.length > 0 ? input.allocations : [DEFAULT_ALLOCATION];
if (sumWeights(allocations) !== 10_000) {
throw new Error('Basket allocations must sum to exactly 10,000 bps');
}
const timestamp = nowIso();
const basket: BasketMandate = {
id: randomUUID(),
clientId: input.clientId,
mandateName: input.mandateName,
chain138VaultAddress: input.chain138VaultAddress,
baseAssetSymbol: input.baseAssetSymbol || 'cBTC',
status: 'active',
allocations: clone(allocations),
createdAt: timestamp,
updatedAt: timestamp,
};
this.baskets.set(basket.id, basket);
this.seedRebalancePlan(basket);
return clone(basket);
}
getBasket(id: string): BasketMandate | null {
const basket = this.baskets.get(id);
return basket ? clone(basket) : null;
}
createDeposit(input: CreateBtcDeposit): { deposit: BtcDeposit; basket: BasketMandate; createdBasket: boolean } {
let basket = input.basketId ? this.baskets.get(input.basketId) ?? null : null;
let createdBasket = false;
if (input.basketId && !basket) {
throw new Error(`Unknown basket: ${input.basketId}`);
}
if (!basket) {
basket = this.createBasket({
clientId: input.clientId,
mandateName: input.mandateName || `BTC jewelry box ${input.clientId}`,
chain138VaultAddress: input.chain138VaultAddress,
allocations: input.allocations && input.allocations.length > 0 ? input.allocations : [DEFAULT_ALLOCATION],
baseAssetSymbol: 'cBTC',
});
createdBasket = true;
} else {
if (basket.clientId !== input.clientId) {
throw new Error('Basket client does not match deposit client');
}
if (basket.chain138VaultAddress !== input.chain138VaultAddress) {
throw new Error('Basket vault address does not match deposit vault address');
}
}
const timestamp = nowIso();
const depositId = randomUUID();
const deposit: BtcDeposit = {
id: depositId,
clientId: input.clientId,
basketId: basket.id,
chain138VaultAddress: basket.chain138VaultAddress,
depositAddress: toPseudoBitcoinAddress(depositId),
expectedAmountSats: input.expectedAmountSats,
confirmationsRequired: BTC_CONFIRMATIONS_REQUIRED,
currentConfirmations: 0,
status: 'instruction_created',
createdAt: timestamp,
updatedAt: timestamp,
};
this.deposits.set(deposit.id, deposit);
return { deposit: clone(deposit), basket: clone(basket), createdBasket };
}
getDeposit(id: string): BtcDeposit | null {
const deposit = this.deposits.get(id);
return deposit ? clone(deposit) : null;
}
listHoldings(filters: { clientId?: string; basketId?: string }): Holding[] {
const baskets = Array.from(this.baskets.values()).filter((basket) => {
if (filters.clientId && basket.clientId !== filters.clientId) {
return false;
}
if (filters.basketId && basket.id !== filters.basketId) {
return false;
}
return true;
});
return baskets.flatMap((basket) => {
const fundedSats = Array.from(this.deposits.values())
.filter((deposit) => deposit.basketId === basket.id && deposit.status === 'minted')
.reduce((total, deposit) => total + (deposit.expectedAmountSats ?? 0), 0);
return basket.allocations.map((allocation: BasketAllocation) => ({
clientId: basket.clientId,
basketId: basket.id,
symbol: allocation.symbol,
allocationWeightBps: allocation.targetWeightBps,
bookValueSats: Math.floor((fundedSats * allocation.targetWeightBps) / 10_000),
status: fundedSats > 0 ? 'funded' : 'pending_funding',
updatedAt: basket.updatedAt,
}));
});
}
listRebalances(filters: { clientId?: string; basketId?: string }): Rebalance[] {
return Array.from(this.rebalances.values())
.filter((rebalance) => {
if (filters.clientId && rebalance.clientId !== filters.clientId) {
return false;
}
if (filters.basketId && rebalance.basketId !== filters.basketId) {
return false;
}
return true;
})
.map((rebalance) => clone(rebalance));
}
createBridgeWithdrawal(input: CreateBridgeWithdrawal): BridgeWithdrawal {
if (!this.baskets.has(input.basketId)) {
throw new Error(`Unknown basket: ${input.basketId}`);
}
const timestamp = nowIso();
const withdrawal: BridgeWithdrawal = {
id: randomUUID(),
clientId: input.clientId,
basketId: input.basketId,
sourceSymbol: input.sourceSymbol || 'cBTC',
destinationSymbol: input.destinationSymbol || BTC_BRIDGE_SYMBOL,
destinationChainId: input.destinationChainId,
destinationAddress: input.destinationAddress,
amount: input.amount,
status: 'pending',
createdAt: timestamp,
updatedAt: timestamp,
};
this.withdrawals.set(withdrawal.id, withdrawal);
return clone(withdrawal);
}
markDepositObserved(
depositId: string,
observedTxId: string,
confirmations: number,
expectedAmountSats?: number,
): BtcDeposit {
const deposit = this.deposits.get(depositId);
if (!deposit) {
throw new Error(`Unknown BTC deposit: ${depositId}`);
}
const nextConfirmations = Math.max(0, confirmations);
deposit.observedTxId = observedTxId;
deposit.currentConfirmations = nextConfirmations;
if (expectedAmountSats !== undefined) {
deposit.expectedAmountSats = expectedAmountSats;
}
deposit.status =
nextConfirmations >= deposit.confirmationsRequired ? 'confirmed' : 'pending_confirmations';
deposit.updatedAt = nowIso();
return clone(deposit);
}
markDepositMinted(depositId: string): BtcDeposit {
const deposit = this.deposits.get(depositId);
if (!deposit) {
throw new Error(`Unknown BTC deposit: ${depositId}`);
}
deposit.currentConfirmations = Math.max(deposit.currentConfirmations, deposit.confirmationsRequired);
deposit.status = 'minted';
deposit.updatedAt = nowIso();
const basket = this.baskets.get(deposit.basketId);
if (basket) {
basket.updatedAt = deposit.updatedAt;
if (basket.allocations.some((allocation: BasketAllocation) => allocation.symbol !== 'cBTC')) {
this.promoteRebalancePlan(basket);
}
}
return clone(deposit);
}
freezeDeposit(depositId: string, reason: string): BtcDeposit {
const deposit = this.deposits.get(depositId);
if (!deposit) {
throw new Error(`Unknown BTC deposit: ${depositId}`);
}
deposit.status = 'frozen';
deposit.freezeReason = reason;
deposit.updatedAt = nowIso();
return clone(deposit);
}
private seedRebalancePlan(basket: BasketMandate): void {
const targetSymbols = basket.allocations
.map((allocation: BasketAllocation) => allocation.symbol)
.filter((symbol: string) => symbol !== 'cBTC');
if (targetSymbols.length === 0) {
return;
}
const timestamp = nowIso();
const rebalance: Rebalance = {
id: randomUUID(),
clientId: basket.clientId,
basketId: basket.id,
sourceSymbol: 'cBTC',
targetSymbols,
reason: 'Awaiting funded BTC deposit before Chain 138 rebalance',
status: 'planned',
createdAt: timestamp,
updatedAt: timestamp,
};
this.rebalances.set(rebalance.id, rebalance);
}
private promoteRebalancePlan(basket: BasketMandate): void {
for (const rebalance of this.rebalances.values()) {
if (rebalance.basketId === basket.id && rebalance.status === 'planned') {
rebalance.status = 'queued';
rebalance.updatedAt = nowIso();
}
}
}
}
export function createDefaultBridgeWithdrawal(
input: Omit<CreateBridgeWithdrawal, 'destinationSymbol' | 'sourceSymbol'> & {
sourceSymbol?: string;
destinationSymbol?: string;
},
): CreateBridgeWithdrawal {
return {
...input,
sourceSymbol: input.sourceSymbol || 'cBTC',
destinationSymbol: input.destinationSymbol || BTC_BRIDGE_SYMBOL,
};
}

View File

@@ -1,45 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { FastifyInstance } from 'fastify';
import { createServer } from '../src/index';
import { describe, expect, it } from 'vitest';
import { JewelryBoxStore } from '../src/jewelry-box-store';
describe('Finance Service', () => {
let server: FastifyInstance;
describe('JewelryBoxStore', () => {
it('creates queued rebalance work only for non-cBTC allocations', () => {
const store = new JewelryBoxStore();
const basket = store.createBasket({
clientId: 'client-1',
mandateName: 'Multi-asset jewelry box',
chain138VaultAddress: '0x4444444444444444444444444444444444444444',
allocations: [
{ symbol: 'cBTC', targetWeightBps: 2500 },
{ symbol: 'cUSDC', targetWeightBps: 5000 },
{ symbol: 'cXAUC', targetWeightBps: 2500 },
],
baseAssetSymbol: 'cBTC',
});
beforeEach(async () => {
server = await createServer();
await server.ready();
});
afterEach(async () => {
await server.close();
});
describe('Health Check', () => {
it('should return 200 on health check', async () => {
const response = await server.inject({
method: 'GET',
url: '/health',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
status: 'ok',
});
const rebalances = store.listRebalances({ basketId: basket.id });
expect(rebalances).toHaveLength(1);
expect(rebalances[0]).toMatchObject({
sourceSymbol: 'cBTC',
targetSymbols: ['cUSDC', 'cXAUC'],
status: 'planned',
});
});
describe('Payment Processing', () => {
it('should validate payment request schema', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/v1/payments',
payload: {
// Invalid payload to test validation
},
});
expect(response.statusCode).toBe(400);
it('promotes rebalance plans once a deposit is marked minted', () => {
const store = new JewelryBoxStore();
const { basket, deposit } = store.createDeposit({
clientId: 'client-2',
chain138VaultAddress: '0x5555555555555555555555555555555555555555',
allocations: [
{ symbol: 'cBTC', targetWeightBps: 3000 },
{ symbol: 'cUSDT', targetWeightBps: 7000 },
],
expectedAmountSats: 300000000,
});
store.markDepositObserved(deposit.id, 'btc-tx-1', 6, 300000000);
store.markDepositMinted(deposit.id);
const updatedDeposit = store.getDeposit(deposit.id);
expect(updatedDeposit?.status).toBe('minted');
const holdings = store.listHoldings({ basketId: basket.id });
expect(holdings[1]).toMatchObject({
symbol: 'cUSDT',
bookValueSats: 210000000,
status: 'funded',
});
const rebalances = store.listRebalances({ basketId: basket.id });
expect(rebalances[0]?.status).toBe('queued');
});
});

View File

@@ -0,0 +1,18 @@
import path from 'path';
const root = '/home/intlc/projects/proxmox/the-order';
export default {
resolve: {
alias: {
'@the-order/auth': path.join(root, 'packages/auth/src/index.ts'),
'@the-order/shared': path.join(root, 'packages/shared/src/index.ts'),
'@the-order/schemas': path.join(root, 'packages/schemas/src/index.ts'),
'@the-order/database': path.join(root, 'packages/database/src/index.ts'),
'@the-order/payment-gateway': path.join(root, 'packages/payment-gateway/src/index.ts'),
},
},
test: {
environment: 'node',
},
};