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

@@ -21,6 +21,81 @@ export interface LedgerEntry {
reference?: string;
}
export interface BasketAllocation {
symbol: string;
targetWeightBps: number;
routeHint?: string;
}
export interface BasketMandate {
id: string;
clientId: string;
mandateName: string;
chain138VaultAddress: string;
baseAssetSymbol: string;
status: 'draft' | 'active' | 'rebalancing' | 'closed';
allocations: BasketAllocation[];
createdAt: string;
updatedAt: string;
}
export interface BtcDeposit {
id: string;
clientId: string;
basketId: string;
chain138VaultAddress: string;
depositAddress: string;
expectedAmountSats?: number;
confirmationsRequired: number;
currentConfirmations: number;
status:
| 'instruction_created'
| 'pending_confirmations'
| 'confirmed'
| 'minted'
| 'frozen';
observedTxId?: string;
freezeReason?: string;
createdAt: string;
updatedAt: string;
}
export interface Holding {
clientId: string;
basketId: string;
symbol: string;
allocationWeightBps: number;
bookValueSats: number;
status: 'pending_funding' | 'funded';
updatedAt: string;
}
export interface Rebalance {
id: string;
clientId: string;
basketId: string;
sourceSymbol: string;
targetSymbols: string[];
reason: string;
status: 'planned' | 'queued' | 'executed' | 'failed';
createdAt: string;
updatedAt: string;
}
export interface BridgeWithdrawal {
id: string;
clientId: string;
basketId: string;
sourceSymbol: string;
destinationSymbol: string;
destinationChainId: number;
destinationAddress: string;
amount: string;
status: 'pending' | 'approved' | 'submitted' | 'failed';
createdAt: string;
updatedAt: string;
}
export class FinanceClient {
protected client: AxiosInstance;
@@ -28,8 +103,8 @@ export class FinanceClient {
const apiBaseURL =
baseURL ||
(typeof window !== 'undefined'
? process.env.NEXT_PUBLIC_FINANCE_SERVICE_URL || 'http://localhost:4005'
: 'http://localhost:4005');
? process.env.NEXT_PUBLIC_FINANCE_SERVICE_URL || 'http://localhost:4003'
: 'http://localhost:4003');
this.client = axios.create({
baseURL: apiBaseURL,
@@ -38,7 +113,6 @@ export class FinanceClient {
},
});
// Set up request interceptor for authentication
this.client.interceptors.request.use(
(config) => {
const token = this.getAuthToken();
@@ -47,7 +121,7 @@ export class FinanceClient {
}
return config;
},
(error) => Promise.reject(error)
(error) => Promise.reject(error),
);
}
@@ -74,8 +148,8 @@ export class FinanceClient {
paymentMethod: string;
description?: string;
}): Promise<Payment> {
const response = await this.client.post<Payment>('/api/v1/payments', data);
return response.data;
const response = await this.client.post<{ payment: Payment }>('/api/v1/payments', data);
return response.data.payment;
}
async getPayment(paymentId: string): Promise<Payment> {
@@ -104,8 +178,71 @@ export class FinanceClient {
}): Promise<{ entries: LedgerEntry[]; total: number }> {
const response = await this.client.get<{ entries: LedgerEntry[]; total: number }>(
'/api/v1/ledger',
{ params: filters }
{ params: filters },
);
return response.data;
}
async createBasket(data: {
clientId: string;
mandateName: string;
chain138VaultAddress: string;
allocations: BasketAllocation[];
baseAssetSymbol?: string;
}): Promise<BasketMandate> {
const response = await this.client.post<{ basket: BasketMandate }>('/api/v1/baskets', data);
return response.data.basket;
}
async createBtcDeposit(data: {
clientId: string;
basketId?: string;
mandateName?: string;
chain138VaultAddress: string;
allocations?: BasketAllocation[];
expectedAmountSats?: number;
clientReference?: string;
}): Promise<{ deposit: BtcDeposit; basket: BasketMandate; createdBasket: boolean }> {
const response = await this.client.post<{
deposit: BtcDeposit;
basket: BasketMandate;
createdBasket: boolean;
}>('/api/v1/btc-deposits', data);
return response.data;
}
async getBtcDeposit(id: string): Promise<BtcDeposit> {
const response = await this.client.get<{ deposit: BtcDeposit }>(`/api/v1/btc-deposits/${id}`);
return response.data.deposit;
}
async getHoldings(filters?: { clientId?: string; basketId?: string }): Promise<Holding[]> {
const response = await this.client.get<{ holdings: Holding[] }>('/api/v1/holdings', {
params: filters,
});
return response.data.holdings;
}
async getRebalances(filters?: { clientId?: string; basketId?: string }): Promise<Rebalance[]> {
const response = await this.client.get<{ rebalances: Rebalance[] }>('/api/v1/rebalances', {
params: filters,
});
return response.data.rebalances;
}
async requestBridgeWithdrawal(data: {
clientId: string;
basketId: string;
sourceSymbol?: string;
destinationSymbol?: string;
destinationChainId: number;
destinationAddress: string;
amount: string;
}): Promise<BridgeWithdrawal> {
const response = await this.client.post<{ withdrawal: BridgeWithdrawal }>(
'/api/v1/withdrawals/bridge',
data,
);
return response.data.withdrawal;
}
}

View File

@@ -14,5 +14,14 @@ export type {
} from './identity';
export type { SubmitApplicationRequest, AdjudicateRequest } from './eresidency';
export type { DocumentUpload, DocumentMetadata } from './intake';
export type { Payment, LedgerEntry } from './finance';
export type {
Payment,
LedgerEntry,
BasketAllocation,
BasketMandate,
BtcDeposit,
Holding,
Rebalance,
BridgeWithdrawal,
} from './finance';
export type { DealRoom, Document } from './dataroom';

View File

@@ -6,11 +6,13 @@
import fetch from 'node-fetch';
import { createVerify, createPublicKey } from 'crypto';
import { decode as multibaseDecode } from 'multibase';
import base58 from 'base58-universal';
import { importJWK } from 'jose';
import forge from 'node-forge';
import { verify as ed25519Verify } from '@noble/ed25519';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { decode: base58Decode }: { decode: (value: string) => Uint8Array } = require('base58-universal');
export interface DIDDocument {
id: string;
'@context': string[];
@@ -89,7 +91,7 @@ export class DIDResolver {
if (multibaseKey.startsWith('z')) {
try {
const base58Encoded = multibaseKey.slice(1);
const decoded = base58.decode(base58Encoded);
const decoded = base58Decode(base58Encoded);
return Buffer.from(decoded);
} catch {
throw new Error('Failed to decode multibase key');
@@ -296,4 +298,3 @@ export class DIDResolver {
}
}
}

View File

@@ -122,6 +122,9 @@ export async function prepareCredentialImage(
if (imageData.startsWith('data:')) {
// Extract base64 data
const base64Data = imageData.split(',')[1];
if (!base64Data) {
throw new Error('Invalid image data URL: missing base64 payload');
}
imageBuffer = Buffer.from(base64Data, 'base64');
} else {
imageBuffer = Buffer.from(imageData);
@@ -180,4 +183,3 @@ export function getRecommendedImageSpecs(): {
maxSizeKB: 100, // Max 100KB recommended
};
}

View File

@@ -4,7 +4,6 @@
*/
import { query } from './client';
import { listDocuments, getDocumentById } from './schema';
export interface DocumentSearchResult {
documents: Array<{
@@ -102,8 +101,8 @@ export async function searchDocuments(
/**
* Get search suggestions
*/
export async function getSearchSuggestions(query: string, limit = 10): Promise<string[]> {
if (!query || query.length < 2) {
export async function getSearchSuggestions(searchTerm: string, limit = 10): Promise<string[]> {
if (!searchTerm || searchTerm.length < 2) {
return [];
}
@@ -113,9 +112,8 @@ export async function getSearchSuggestions(query: string, limit = 10): Promise<s
WHERE title ILIKE $1
ORDER BY title
LIMIT $2`,
[`%${query}%`, limit]
[`%${searchTerm}%`, limit]
);
return result.rows.map((row) => row.title);
}

View File

@@ -320,9 +320,11 @@ export function extractTemplateVariables(template_content: string): string[] {
const matches = template_content.matchAll(/\{\{(\w+(?:\.\w+)*)\}\}/g);
for (const match of matches) {
variables.add(match[1]);
const variableName = match[1];
if (variableName) {
variables.add(variableName);
}
}
return Array.from(variables).sort();
}

View File

@@ -0,0 +1,136 @@
import { z } from 'zod';
const EthAddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/);
export const BasketAllocationSchema = z.object({
symbol: z.string().min(2).max(16),
targetWeightBps: z.number().int().positive().max(10_000),
routeHint: z.string().optional(),
});
export type BasketAllocation = z.infer<typeof BasketAllocationSchema>;
export const BasketStatusSchema = z.enum(['draft', 'active', 'rebalancing', 'closed']);
export const BasketMandateSchema = z.object({
id: z.string().uuid(),
clientId: z.string().min(1),
mandateName: z.string().min(1),
chain138VaultAddress: EthAddressSchema,
baseAssetSymbol: z.string().min(2).max(16).default('cBTC'),
status: BasketStatusSchema,
allocations: z.array(BasketAllocationSchema).min(1),
createdAt: z.date().or(z.string().datetime()),
updatedAt: z.date().or(z.string().datetime()),
});
export type BasketMandate = z.infer<typeof BasketMandateSchema>;
export const CreateBasketMandateSchema = BasketMandateSchema.omit({
id: true,
status: true,
createdAt: true,
updatedAt: true,
}).extend({
baseAssetSymbol: z.string().min(2).max(16).default('cBTC'),
});
export type CreateBasketMandate = z.infer<typeof CreateBasketMandateSchema>;
export const BtcDepositStatusSchema = z.enum([
'instruction_created',
'pending_confirmations',
'confirmed',
'minted',
'frozen',
]);
export const BtcDepositSchema = z.object({
id: z.string().uuid(),
clientId: z.string().min(1),
basketId: z.string().uuid(),
chain138VaultAddress: EthAddressSchema,
depositAddress: z.string().min(16),
expectedAmountSats: z.number().int().positive().optional(),
confirmationsRequired: z.number().int().positive(),
currentConfirmations: z.number().int().nonnegative(),
status: BtcDepositStatusSchema,
observedTxId: z.string().optional(),
freezeReason: z.string().optional(),
createdAt: z.date().or(z.string().datetime()),
updatedAt: z.date().or(z.string().datetime()),
});
export type BtcDeposit = z.infer<typeof BtcDepositSchema>;
export const CreateBtcDepositSchema = z.object({
clientId: z.string().min(1),
basketId: z.string().uuid().optional(),
mandateName: z.string().min(1).optional(),
chain138VaultAddress: EthAddressSchema,
allocations: z.array(BasketAllocationSchema).min(1).optional(),
expectedAmountSats: z.number().int().positive().optional(),
clientReference: z.string().min(1).optional(),
});
export type CreateBtcDeposit = z.infer<typeof CreateBtcDepositSchema>;
export const HoldingStatusSchema = z.enum(['pending_funding', 'funded']);
export const HoldingSchema = z.object({
clientId: z.string().min(1),
basketId: z.string().uuid(),
symbol: z.string().min(2).max(16),
allocationWeightBps: z.number().int().min(0).max(10_000),
bookValueSats: z.number().int().nonnegative(),
status: HoldingStatusSchema,
updatedAt: z.date().or(z.string().datetime()),
});
export type Holding = z.infer<typeof HoldingSchema>;
export const RebalanceStatusSchema = z.enum(['planned', 'queued', 'executed', 'failed']);
export const RebalanceSchema = z.object({
id: z.string().uuid(),
clientId: z.string().min(1),
basketId: z.string().uuid(),
sourceSymbol: z.string().min(2).max(16),
targetSymbols: z.array(z.string().min(2).max(16)).min(1),
reason: z.string().min(1),
status: RebalanceStatusSchema,
createdAt: z.date().or(z.string().datetime()),
updatedAt: z.date().or(z.string().datetime()),
});
export type Rebalance = z.infer<typeof RebalanceSchema>;
export const BridgeWithdrawalStatusSchema = z.enum(['pending', 'approved', 'submitted', 'failed']);
export const BridgeWithdrawalSchema = z.object({
id: z.string().uuid(),
clientId: z.string().min(1),
basketId: z.string().uuid(),
sourceSymbol: z.string().min(2).max(16),
destinationSymbol: z.string().min(2).max(16),
destinationChainId: z.number().int().positive(),
destinationAddress: EthAddressSchema,
amount: z.string().min(1),
status: BridgeWithdrawalStatusSchema,
createdAt: z.date().or(z.string().datetime()),
updatedAt: z.date().or(z.string().datetime()),
});
export type BridgeWithdrawal = z.infer<typeof BridgeWithdrawalSchema>;
export const CreateBridgeWithdrawalSchema = BridgeWithdrawalSchema.omit({
id: true,
status: true,
createdAt: true,
updatedAt: true,
}).extend({
sourceSymbol: z.string().min(2).max(16).optional(),
destinationSymbol: z.string().min(2).max(16).optional(),
});
export type CreateBridgeWithdrawal = z.infer<typeof CreateBridgeWithdrawalSchema>;

View File

@@ -9,4 +9,4 @@ export * from './vc';
export * from './payment';
export * from './ledger';
export * from './eresidency';
export * from './btc-basket';

View File

@@ -77,9 +77,9 @@ export function createSecurityTestData() {
export async function testAuthenticationBypass(
makeRequest: (headers?: Record<string, string>) => Promise<{ status: number }>
): Promise<boolean> {
const testCases = [
const testCases: Array<Record<string, string> | undefined> = [
// Missing token
{},
undefined,
// Invalid token
{ Authorization: 'Bearer invalid-token' },
// Expired token
@@ -226,4 +226,3 @@ export async function testCSRFProtection(
return true; // CSRF protection is working correctly
}

View File

@@ -5,7 +5,6 @@
import { FastifyInstance } from 'fastify';
import Fastify from 'fastify';
import { getEnv } from '@the-order/shared';
export interface ServerFactoryOptions {
port?: number;
@@ -41,4 +40,3 @@ export async function createTestServer(
export function closeTestServer(server: FastifyInstance): Promise<void> {
return server.close();
}

View File

@@ -62,7 +62,10 @@ export async function reviewWorkflow(
if (process.env.APPROVAL_SERVICE_URL) {
try {
const res = await fetch(`${process.env.APPROVAL_SERVICE_URL}/status/${input.documentId}/${input.reviewerId}`);
if (res.ok) approved = (await res.json()).approved ?? false;
if (res.ok) {
const approvalResponse = (await res.json()) as { approved?: boolean };
approved = approvalResponse.approved ?? false;
}
} catch { /* fall through */ }
}
if (!approved && getApprovalStatus) {
@@ -151,4 +154,3 @@ export async function checkApprovalStatus(
// Fallback: assume not approved if we can't check
return false;
}