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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
136
packages/schemas/src/btc-basket.ts
Normal file
136
packages/schemas/src/btc-basket.ts
Normal 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>;
|
||||
@@ -9,4 +9,4 @@ export * from './vc';
|
||||
export * from './payment';
|
||||
export * from './ledger';
|
||||
export * from './eresidency';
|
||||
|
||||
export * from './btc-basket';
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user