diff --git a/apps/api/src/treasury/errors/treasury-balance.errors.ts b/apps/api/src/treasury/errors/treasury-balance.errors.ts new file mode 100644 index 0000000..f0d1f52 --- /dev/null +++ b/apps/api/src/treasury/errors/treasury-balance.errors.ts @@ -0,0 +1,115 @@ +/** + * Treasury Balance Error Types + * + * All custom error types for treasury balance operations. + * Each error carries a unique string code and extends the base TreasuryError + * for consistent error handling and propagation. + */ + +/** + * Base error class for all treasury balance operations. + * Provides a consistent interface with error code, message, and HTTP status. + */ +export class TreasuryError extends Error { + /** Unique string error code for programmatic handling */ + public readonly code: string; + /** HTTP status code to surface in API responses */ + public readonly statusCode: number; + + constructor(code: string, message: string, statusCode: number = 400) { + super(message); + this.name = this.constructor.name; + this.code = code; + this.statusCode = statusCode; + } +} + +/** + * Raised when a burn or reserve operation would drive available_balance below zero. + * + * @example + * ``` + * throw new InsufficientAvailableBalanceError(100n, 50n); + * // "Insufficient available balance: requested 100, available 50" + * ``` + */ +export class InsufficientAvailableBalanceError extends TreasuryError { + constructor( + public readonly requested: bigint, + public readonly available: bigint, + ) { + super( + 'TREASURY_ERR_INSUFFICIENT_AVAILABLE', + `Insufficient available balance: requested ${requested}, available ${available}`, + 409, + ); + } +} + +/** + * Raised when a release or settle operation would drive reserved_balance below zero. + * + * @example + * ``` + * throw new InsufficientReservedBalanceError(100n, 50n); + * // "Insufficient reserved balance: requested 100, reserved 50" + * ``` + */ +export class InsufficientReservedBalanceError extends TreasuryError { + constructor( + public readonly requested: bigint, + public readonly reserved: bigint, + ) { + super( + 'TREASURY_ERR_INSUFFICIENT_RESERVED', + `Insufficient reserved balance: requested ${requested}, reserved ${reserved}`, + 409, + ); + } +} + +/** + * Raised when a mint operation would exceed the defined supply cap. + * Omit usage if no supply cap is configured — included for forward compatibility. + */ +export class MintLimitExceededError extends TreasuryError { + constructor( + public readonly requested: bigint, + public readonly totalMinted: bigint, + public readonly mintCap: bigint, + ) { + super( + 'TREASURY_ERR_MINT_LIMIT_EXCEEDED', + `Mint limit exceeded: minting ${requested} would bring total minted to ${totalMinted + requested}, cap is ${mintCap}`, + 409, + ); + } +} + +/** + * Raised when querying a TreasuryBalance record that does not exist + * for the given asset_code and asset_issuer combination. + */ +export class BalanceNotFoundError extends TreasuryError { + constructor( + public readonly assetCode: string, + public readonly assetIssuer: string, + ) { + super( + 'TREASURY_ERR_BALANCE_NOT_FOUND', + `Treasury balance not found for asset ${assetCode} issued by ${assetIssuer}`, + 404, + ); + } +} + +/** + * Raised when the atomic operation fails to commit for infrastructure reasons + * distinct from business logic violations (e.g., mutex acquisition timeout, + * internal store corruption). + */ +export class AtomicUpdateFailedError extends TreasuryError { + constructor(message: string) { + super('TREASURY_ERR_ATOMIC_UPDATE_FAILED', `Atomic update failed: ${message}`, 500); + } +} diff --git a/apps/api/src/treasury/interfaces/treasury-balance.interface.ts b/apps/api/src/treasury/interfaces/treasury-balance.interface.ts new file mode 100644 index 0000000..0057900 --- /dev/null +++ b/apps/api/src/treasury/interfaces/treasury-balance.interface.ts @@ -0,0 +1,66 @@ +/** + * TreasuryBalance — Internal ledger record for a single asset's treasury state. + * + * All monetary amounts use bigint (representing stroops / smallest unit) + * to avoid floating-point precision errors when working with Stellar assets. + * This mirrors the i128 type used in the Soroban contracts. + * + * Invariants that must hold at all times: + * - available_balance >= 0 + * - reserved_balance >= 0 + * - available_balance + reserved_balance == total tracked treasury balance + * - total_minted is monotonically non-decreasing (never decremented) + * - total_burned is monotonically non-decreasing (never decremented) + */ +export interface TreasuryBalance { + /** Unique identifier for this balance record (UUID v4) */ + id: string; + + /** Stellar asset code (e.g., "USDC", "ARS") */ + asset_code: string; + + /** Stellar issuer address (e.g., "GDTREASURYADDRESSXXXXXX") */ + asset_issuer: string; + + /** Portion of balance free for new operations (stroops precision, bigint) */ + available_balance: bigint; + + /** Portion committed to in-flight operations, not available for new commitments */ + reserved_balance: bigint; + + /** Cumulative counter of all mint operations ever processed — never decremented */ + total_minted: bigint; + + /** Cumulative counter of all burn operations ever processed — never decremented */ + total_burned: bigint; + + /** Timestamp of the most recent update to this record */ + last_updated_at: Date; + + /** Creation timestamp */ + created_at: Date; +} + +/** + * Result returned from balance mutation operations (mint, burn, reserve, release, settle). + * Provides the full post-operation state for logging and verification. + */ +export interface TreasuryBalanceOperationResult { + /** The operation that was performed */ + operation: 'mint' | 'burn' | 'reserve' | 'release' | 'settle'; + + /** Asset code operated on */ + asset_code: string; + + /** Amount involved in the operation */ + amount: bigint; + + /** Available balance after the operation */ + available_balance: bigint; + + /** Reserved balance after the operation */ + reserved_balance: bigint; + + /** Whether the operation succeeded */ + success: boolean; +} diff --git a/apps/api/src/treasury/treasury-balance.invariants.ts b/apps/api/src/treasury/treasury-balance.invariants.ts new file mode 100644 index 0000000..2a52534 --- /dev/null +++ b/apps/api/src/treasury/treasury-balance.invariants.ts @@ -0,0 +1,128 @@ +/** + * Treasury Balance Invariants & Threat Model + * + * This file documents the invariants, threat model, and non-goals for the + * internal treasury balance tracking system. It serves as the foundation + * for both the implementation and the security notes in the PR. + * + * ───────────────────────────────────────────────────────────────────────── + * DOUBLE-SPENDING SCENARIO + * ───────────────────────────────────────────────────────────────────────── + * + * Without atomic balance updates, double-spending occurs when two concurrent + * burn operations both read the same available_balance value before either + * writes back the decremented result: + * + * Thread A reads available_balance = 100 + * Thread B reads available_balance = 100 + * Thread A burns 80, writes available_balance = 20 ✓ + * Thread B burns 80, writes available_balance = 20 ✗ (should have failed) + * + * Combined burns of 160 exceed the original 100, but neither operation saw + * the other's write. This is a classic TOCTOU (Time-of-check-to-time-of-use) + * race condition. + * + * Mitigation: All balance mutations acquire an async mutex, serialising + * operations so each sees the result of the previous one. + * + * ───────────────────────────────────────────────────────────────────────── + * OVER-MINTING SCENARIO + * ───────────────────────────────────────────────────────────────────────── + * + * Without atomicity, over-minting occurs when concurrent mint operations + * both check the supply cap against a stale total_minted value: + * + * Cap = 1000, total_minted = 950 + * Thread A checks: 950 + 100 = 1050 > 1000 → should reject + * Thread B checks: 950 + 30 = 980 ≤ 1000 → allowed + * Thread A (without check): mints 100, total_minted = 1050 ✗ + * + * If mint operations interleave without serialisation, the cap check in + * one operation may see a stale total_minted, allowing combined mints to + * exceed the intended supply cap. + * + * Mitigation: Cap validation and total_minted increment occur within the + * same mutex-protected critical section. + * + * ───────────────────────────────────────────────────────────────────────── + * INVARIANTS + * ───────────────────────────────────────────────────────────────────────── + * + * 1. available_balance + reserved_balance == total tracked treasury balance + * at every point in time — no operation may leave these two fields in an + * inconsistent intermediate state. + * + * 2. available_balance >= 0 — always. + * + * 3. reserved_balance >= 0 — always. + * + * 4. A mint operation that fails at any point must leave TreasuryBalance + * exactly as it was before the operation began — no partial updates. + * + * 5. A burn operation that fails at any point must leave TreasuryBalance + * exactly as it was before the operation began — no partial updates. + * + * 6. Concurrent mint and burn operations must serialise correctly — the + * final state must be equivalent to some sequential ordering of the + * operations. + * + * ───────────────────────────────────────────────────────────────────────── + * NON-GOALS + * ───────────────────────────────────────────────────────────────────────── + * + * This implementation does NOT cover: + * + * - Cross-chain reconciliation: No validation against on-chain Stellar + * balances or Horizon API state. This is an internal ledger only. + * + * - Frontend display: No REST/API endpoints are added for displaying + * balance data to end-users. The existing proof-of-reserves endpoint + * is unchanged. + * + * - External audit reporting: No export or reporting functionality for + * external auditors. The internal ledger is a building block for + * future audit features. + * + * - Persistence beyond process lifetime: Uses in-memory storage + * consistent with the current codebase. When a database (PostgreSQL) + * is integrated, the store implementation can be swapped behind the + * same interface. + * + * - Multi-node consistency: The async mutex provides single-process + * serialisation only. Distributed locking (e.g., via Redis) is out + * of scope until the infrastructure layer is implemented. + */ + +import type { TreasuryBalance } from './interfaces/treasury-balance.interface'; + +/** + * Validates that all TreasuryBalance invariants hold for the given record. + * Throws an Error if any invariant is violated. + * + * @param balance - The TreasuryBalance record to validate + * @param expectedTotal - Optional: the expected total (available + reserved) + * to verify against. If omitted, only non-negativity + * is checked. + */ +export function assertTreasuryInvariants(balance: TreasuryBalance, expectedTotal?: bigint): void { + if (balance.available_balance < 0n) { + throw new Error( + `Invariant violation: available_balance is negative (${balance.available_balance})`, + ); + } + + if (balance.reserved_balance < 0n) { + throw new Error( + `Invariant violation: reserved_balance is negative (${balance.reserved_balance})`, + ); + } + + if (expectedTotal !== undefined) { + const actualTotal = balance.available_balance + balance.reserved_balance; + if (actualTotal !== expectedTotal) { + throw new Error( + `Invariant violation: available (${balance.available_balance}) + reserved (${balance.reserved_balance}) = ${actualTotal}, expected ${expectedTotal}`, + ); + } + } +} diff --git a/apps/api/src/treasury/treasury-balance.store.spec.ts b/apps/api/src/treasury/treasury-balance.store.spec.ts new file mode 100644 index 0000000..88bca25 --- /dev/null +++ b/apps/api/src/treasury/treasury-balance.store.spec.ts @@ -0,0 +1,199 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { TreasuryBalanceStore } from './treasury-balance.store'; +import { + BalanceNotFoundError, + InsufficientAvailableBalanceError, + InsufficientReservedBalanceError, + MintLimitExceededError, +} from './errors/treasury-balance.errors'; + +describe('TreasuryBalanceStore', () => { + let store: TreasuryBalanceStore; + const assetCode = 'USDC'; + const assetIssuer = 'GDTREASURYADDRESSXXXXXX'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TreasuryBalanceStore], + }).compile(); + + store = module.get(TreasuryBalanceStore); + }); + + describe('TreasuryBalance Structure', () => { + it('should create a record with valid fields on first mint', async () => { + const amount = 1000n; + const result = await store.mint(assetCode, assetIssuer, amount); + + expect(result.success).toBe(true); + expect(result.available_balance).toBe(amount); + expect(result.reserved_balance).toBe(0n); + + const record = store.getBalance(assetCode, assetIssuer); + expect(record.asset_code).toBe(assetCode); + expect(record.asset_issuer).toBe(assetIssuer); + expect(record.total_minted).toBe(amount); + expect(record.total_burned).toBe(0n); + expect(record.created_at).toBeInstanceOf(Date); + expect(record.last_updated_at).toBeInstanceOf(Date); + }); + + it('should throw BalanceNotFoundError when retrieving non-existent record', () => { + expect(() => store.getBalance('NONEXISTENT', assetIssuer)).toThrow(BalanceNotFoundError); + }); + + it('should return 0 for getTotalTracked when no record exists', () => { + expect(store.getTotalTracked('NONEXISTENT', assetIssuer)).toBe(0n); + }); + }); + + describe('Atomic Mint Operation', () => { + it('should successfuly mint and increment balances', async () => { + await store.mint(assetCode, assetIssuer, 100n); + const result = await store.mint(assetCode, assetIssuer, 50n); + + expect(result.available_balance).toBe(150n); + const record = store.getBalance(assetCode, assetIssuer); + expect(record.total_minted).toBe(150n); + }); + + it('should reject mint if it exceeds cap', async () => { + await store.mint(assetCode, assetIssuer, 500n); + await expect(store.mint(assetCode, assetIssuer, 501n, 1000n)).rejects.toThrow( + MintLimitExceededError, + ); + + const record = store.getBalance(assetCode, assetIssuer); + expect(record.available_balance).toBe(500n); // Unchanged + }); + + it('should produce consistent state with concurrent mints', async () => { + // Execute 50 concurrent mints of 1 unit each + const mints = Array.from({ length: 50 }, () => store.mint(assetCode, assetIssuer, 1n)); + await Promise.all(mints); + + const record = store.getBalance(assetCode, assetIssuer); + expect(record.available_balance).toBe(50n); + expect(record.total_minted).toBe(50n); + }); + }); + + describe('Atomic Burn Operation', () => { + beforeEach(async () => { + await store.mint(assetCode, assetIssuer, 1000n); + }); + + it('should successfully burn and decrement balances', async () => { + const result = await store.burn(assetCode, assetIssuer, 400n); + expect(result.available_balance).toBe(600n); + + const record = store.getBalance(assetCode, assetIssuer); + expect(record.total_burned).toBe(400n); + }); + + it('should throw error when burning more than available', async () => { + await expect(store.burn(assetCode, assetIssuer, 1001n)).rejects.toThrow( + InsufficientAvailableBalanceError, + ); + }); + + it('should produce consistent state with concurrent burns', async () => { + const burns = Array.from({ length: 10 }, () => store.burn(assetCode, assetIssuer, 50n)); + await Promise.all(burns); + + const record = store.getBalance(assetCode, assetIssuer); + expect(record.available_balance).toBe(500n); + expect(record.total_burned).toBe(500n); + }); + + it('should prevent combined burns from exceeding available balance during concurrency', async () => { + // 1000 available. Try to burn 1100 concurrently (11 x 100) + const burns = Array.from({ length: 11 }, () => + store.burn(assetCode, assetIssuer, 100n).catch((e) => e), + ); + const results = await Promise.all(burns); + + const successes = results.filter((r) => r.success === true).length; + const failures = results.filter((r) => r instanceof InsufficientAvailableBalanceError).length; + + expect(successes).toBe(10); + expect(failures).toBe(1); + + const record = store.getBalance(assetCode, assetIssuer); + expect(record.available_balance).toBe(0n); + }); + }); + + describe('Reserve, Release, Settle', () => { + beforeEach(async () => { + await store.mint(assetCode, assetIssuer, 1000n); + }); + + it('should move funds from available to reserved atomically', async () => { + const result = await store.reserve(assetCode, assetIssuer, 300n); + expect(result.available_balance).toBe(700n); + expect(result.reserved_balance).toBe(300n); + + const record = store.getBalance(assetCode, assetIssuer); + expect(record.available_balance).toBe(700n); + expect(record.reserved_balance).toBe(300n); + }); + + it('should throw error if insufficient available for reserve', async () => { + await expect(store.reserve(assetCode, assetIssuer, 1001n)).rejects.toThrow( + InsufficientAvailableBalanceError, + ); + }); + + it('should move funds from reserved to available atomically', async () => { + await store.reserve(assetCode, assetIssuer, 500n); + const result = await store.release(assetCode, assetIssuer, 200n); + + expect(result.available_balance).toBe(700n); + expect(result.reserved_balance).toBe(300n); + }); + + it('should throw error if insufficient reserved for release', async () => { + await expect(store.release(assetCode, assetIssuer, 1n)).rejects.toThrow( + InsufficientReservedBalanceError, + ); + }); + + it('should decrement reserved_balance on settle', async () => { + await store.reserve(assetCode, assetIssuer, 500n); + const result = await store.settle(assetCode, assetIssuer, 200n); + + expect(result.available_balance).toBe(500n); + expect(result.reserved_balance).toBe(300n); + + const record = store.getBalance(assetCode, assetIssuer); + expect(record.available_balance + record.reserved_balance).toBe(800n); + }); + + it('should throw error if insufficient reserved for settle', async () => { + await expect(store.settle(assetCode, assetIssuer, 1n)).rejects.toThrow( + InsufficientReservedBalanceError, + ); + }); + }); + + describe('Invariant Verification', () => { + it('should maintain available + reserved equals total tracked after every op', async () => { + await store.mint(assetCode, assetIssuer, 1000n); + expect(store.getTotalTracked(assetCode, assetIssuer)).toBe(1000n); + + await store.reserve(assetCode, assetIssuer, 300n); + expect(store.getTotalTracked(assetCode, assetIssuer)).toBe(1000n); + + await store.release(assetCode, assetIssuer, 100n); + expect(store.getTotalTracked(assetCode, assetIssuer)).toBe(1000n); + + await store.settle(assetCode, assetIssuer, 200n); + expect(store.getTotalTracked(assetCode, assetIssuer)).toBe(800n); + + await store.burn(assetCode, assetIssuer, 300n); + expect(store.getTotalTracked(assetCode, assetIssuer)).toBe(500n); + }); + }); +}); diff --git a/apps/api/src/treasury/treasury-balance.store.ts b/apps/api/src/treasury/treasury-balance.store.ts new file mode 100644 index 0000000..a7ec355 --- /dev/null +++ b/apps/api/src/treasury/treasury-balance.store.ts @@ -0,0 +1,327 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { + TreasuryBalance, + TreasuryBalanceOperationResult, +} from './interfaces/treasury-balance.interface'; +import { + BalanceNotFoundError, + InsufficientAvailableBalanceError, + InsufficientReservedBalanceError, + MintLimitExceededError, + AtomicUpdateFailedError, +} from './errors/treasury-balance.errors'; +import { assertTreasuryInvariants } from './treasury-balance.invariants'; + +/** + * TreasuryBalanceStore — Atomic in-memory ledger for treasury balances. + * + * This store manages TreasuryBalance records and ensures atomicity for all + * mutation operations using a simple promise-based mutex. While the store is + * currently in-memory, the interface and atomicity guarantees are designed + * to be consistent with future database-backed implementations. + */ +@Injectable() +export class TreasuryBalanceStore { + private readonly logger = new Logger(TreasuryBalanceStore.name); + private readonly balances = new Map(); + + /** + * Simple async mutex to serialise all write operations. + * Ensures that only one mutation happens at a time across all assets, + * preventing race conditions in the in-memory state. + */ + private mutex = Promise.resolve(); + + /** + * Executes a mutation operation within the mutex-protected critical section. + * Use this for all operations that modify balance state. + */ + private async withMutex(operation: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.mutex = this.mutex + .then(async () => { + try { + const result = await operation(); + resolve(result); + } catch (error) { + reject(error); + } + }) + .catch((error) => { + // Keep the mutex chain alive even if an operation fails + this.logger.error(`Mutex-protected operation failed: ${error.message}`); + reject(error); + }); + }); + } + + /** + * Generates a unique key for an asset in the store. + */ + private getAssetKey(assetCode: string, assetIssuer: string): string { + return `${assetCode.toUpperCase()}:${assetIssuer}`; + } + + /** + * Retrieves a record or creates a default one if it doesn't exist. + * Internal helper for mutation operations. + */ + private getOrCreateBalance(assetCode: string, assetIssuer: string): TreasuryBalance { + const key = this.getAssetKey(assetCode, assetIssuer); + const existing = this.balances.get(key); + + if (existing) { + return { ...existing }; // Return a clone for mutation + } + + const now = new Date(); + return { + id: randomUUID(), + asset_code: assetCode, + asset_issuer: assetIssuer, + available_balance: 0n, + reserved_balance: 0n, + total_minted: 0n, + total_burned: 0n, + last_updated_at: now, + created_at: now, + }; + } + + /** + * Atomic Mint Operation + * Increments available_balance and total_minted. + */ + async mint( + assetCode: string, + assetIssuer: string, + amount: bigint, + mintCap?: bigint, + ): Promise { + if (amount <= 0n) throw new AtomicUpdateFailedError('Mint amount must be positive'); + + return this.withMutex(async () => { + const balance = this.getOrCreateBalance(assetCode, assetIssuer); + const originalTotal = balance.available_balance + balance.reserved_balance; + + // Validate cap if provided + if (mintCap !== undefined && balance.total_minted + amount > mintCap) { + throw new MintLimitExceededError(amount, balance.total_minted, mintCap); + } + + // Apply mutations + balance.available_balance += amount; + balance.total_minted += amount; + balance.last_updated_at = new Date(); + + // Verify invariants + assertTreasuryInvariants(balance, originalTotal + amount); + + // Commit + this.balances.set(this.getAssetKey(assetCode, assetIssuer), balance); + + return { + operation: 'mint', + asset_code: assetCode, + amount, + available_balance: balance.available_balance, + reserved_balance: balance.reserved_balance, + success: true, + }; + }); + } + + /** + * Atomic Burn Operation + * Decrements available_balance and increments total_burned. + */ + async burn( + assetCode: string, + assetIssuer: string, + amount: bigint, + ): Promise { + if (amount <= 0n) throw new AtomicUpdateFailedError('Burn amount must be positive'); + + return this.withMutex(async () => { + const balance = this.balances.get(this.getAssetKey(assetCode, assetIssuer)); + if (!balance) throw new BalanceNotFoundError(assetCode, assetIssuer); + + if (balance.available_balance < amount) { + throw new InsufficientAvailableBalanceError(amount, balance.available_balance); + } + + const updated = { ...balance }; + const originalTotal = updated.available_balance + updated.reserved_balance; + + // Apply mutations + updated.available_balance -= amount; + updated.total_burned += amount; + updated.last_updated_at = new Date(); + + // Verify invariants + assertTreasuryInvariants(updated, originalTotal - amount); + + // Commit + this.balances.set(this.getAssetKey(assetCode, assetIssuer), updated); + + return { + operation: 'burn', + asset_code: assetCode, + amount, + available_balance: updated.available_balance, + reserved_balance: updated.reserved_balance, + success: true, + }; + }); + } + + /** + * Atomic Reserve Operation + * Moves funds from available_balance to reserved_balance. + */ + async reserve( + assetCode: string, + assetIssuer: string, + amount: bigint, + ): Promise { + if (amount <= 0n) throw new AtomicUpdateFailedError('Reserve amount must be positive'); + + return this.withMutex(async () => { + const balance = this.balances.get(this.getAssetKey(assetCode, assetIssuer)); + if (!balance) throw new BalanceNotFoundError(assetCode, assetIssuer); + + if (balance.available_balance < amount) { + throw new InsufficientAvailableBalanceError(amount, balance.available_balance); + } + + const updated = { ...balance }; + const totalBefore = updated.available_balance + updated.reserved_balance; + + // Apply mutations + updated.available_balance -= amount; + updated.reserved_balance += amount; + updated.last_updated_at = new Date(); + + // Verify invariants (total must remain the same) + assertTreasuryInvariants(updated, totalBefore); + + // Commit + this.balances.set(this.getAssetKey(assetCode, assetIssuer), updated); + + return { + operation: 'reserve', + asset_code: assetCode, + amount, + available_balance: updated.available_balance, + reserved_balance: updated.reserved_balance, + success: true, + }; + }); + } + + /** + * Atomic Release Operation + * Moves funds from reserved_balance back to available_balance. + */ + async release( + assetCode: string, + assetIssuer: string, + amount: bigint, + ): Promise { + if (amount <= 0n) throw new AtomicUpdateFailedError('Release amount must be positive'); + + return this.withMutex(async () => { + const balance = this.balances.get(this.getAssetKey(assetCode, assetIssuer)); + if (!balance) throw new BalanceNotFoundError(assetCode, assetIssuer); + + if (balance.reserved_balance < amount) { + throw new InsufficientReservedBalanceError(amount, balance.reserved_balance); + } + + const updated = { ...balance }; + const totalBefore = updated.available_balance + updated.reserved_balance; + + // Apply mutations + updated.reserved_balance -= amount; + updated.available_balance += amount; + updated.last_updated_at = new Date(); + + // Verify invariants (total must remain the same) + assertTreasuryInvariants(updated, totalBefore); + + // Commit + this.balances.set(this.getAssetKey(assetCode, assetIssuer), updated); + + return { + operation: 'release', + asset_code: assetCode, + amount, + available_balance: updated.available_balance, + reserved_balance: updated.reserved_balance, + success: true, + }; + }); + } + + /** + * Atomic Settle Operation + * Decrements reserved_balance without returning to available_balance. + */ + async settle( + assetCode: string, + assetIssuer: string, + amount: bigint, + ): Promise { + if (amount <= 0n) throw new AtomicUpdateFailedError('Settle amount must be positive'); + + return this.withMutex(async () => { + const balance = this.balances.get(this.getAssetKey(assetCode, assetIssuer)); + if (!balance) throw new BalanceNotFoundError(assetCode, assetIssuer); + + if (balance.reserved_balance < amount) { + throw new InsufficientReservedBalanceError(amount, balance.reserved_balance); + } + + const updated = { ...balance }; + const totalBefore = updated.available_balance + updated.reserved_balance; + + // Apply mutations + updated.reserved_balance -= amount; + updated.last_updated_at = new Date(); + + // Verify invariants + assertTreasuryInvariants(updated, totalBefore - amount); + + // Commit + this.balances.set(this.getAssetKey(assetCode, assetIssuer), updated); + + return { + operation: 'settle', + asset_code: assetCode, + amount, + available_balance: updated.available_balance, + reserved_balance: updated.reserved_balance, + success: true, + }; + }); + } + + /** + * Retrieves the full TreasuryBalance record for a specific asset. + */ + getBalance(assetCode: string, assetIssuer: string): TreasuryBalance { + const balance = this.balances.get(this.getAssetKey(assetCode, assetIssuer)); + if (!balance) throw new BalanceNotFoundError(assetCode, assetIssuer); + return { ...balance }; + } + + /** + * Returns available_balance + reserved_balance for quick consistency checks. + */ + getTotalTracked(assetCode: string, assetIssuer: string): bigint { + const balance = this.balances.get(this.getAssetKey(assetCode, assetIssuer)); + if (!balance) return 0n; + return balance.available_balance + balance.reserved_balance; + } +} diff --git a/apps/api/src/treasury/treasury.module.ts b/apps/api/src/treasury/treasury.module.ts index 203b6d5..8bd5ffd 100644 --- a/apps/api/src/treasury/treasury.module.ts +++ b/apps/api/src/treasury/treasury.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { TreasuryController } from './treasury.controller'; import { TreasuryService } from './treasury.service'; +import { TreasuryBalanceStore } from './treasury-balance.store'; @Module({ controllers: [TreasuryController], - providers: [TreasuryService], + providers: [TreasuryService, TreasuryBalanceStore], + exports: [TreasuryService, TreasuryBalanceStore], }) export class TreasuryModule {} diff --git a/apps/api/src/treasury/treasury.service.spec.ts b/apps/api/src/treasury/treasury.service.spec.ts new file mode 100644 index 0000000..79787df --- /dev/null +++ b/apps/api/src/treasury/treasury.service.spec.ts @@ -0,0 +1,153 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { TreasuryService } from './treasury.service'; +import { TreasuryBalanceStore } from './treasury-balance.store'; +import { Logger } from '@nestjs/common'; + +describe('TreasuryService', () => { + let service: TreasuryService; + let store: TreasuryBalanceStore; + const assetCode = 'ARS'; + const assetIssuer = 'GDTREASURYADDRESSXXXXXX'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TreasuryService, TreasuryBalanceStore], + }).compile(); + + service = module.get(TreasuryService); + store = module.get(TreasuryBalanceStore); + + // Silence logger during tests + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + }); + + describe('Ledger Operations', () => { + it('should delegate mint to store and log success', async () => { + const mintSpy = jest.spyOn(store, 'mint'); + const result = await service.mintAsset(assetCode, assetIssuer, 1000n); + + expect(mintSpy).toHaveBeenCalledWith(assetCode, assetIssuer, 1000n, undefined); + expect(result.success).toBe(true); + }); + + it('should log error and throw if mint fails', async () => { + const errorSpy = jest.spyOn(Logger.prototype, 'error'); + jest.spyOn(store, 'mint').mockRejectedValue(new Error('Mint failed')); + + await expect(service.mintAsset(assetCode, assetIssuer, 1000n)).rejects.toThrow('Mint failed'); + expect(errorSpy).toHaveBeenCalledWith('Mint failed for ARS: Mint failed'); + }); + + it('should delegate burn to store and log success', async () => { + await store.mint(assetCode, assetIssuer, 2000n); + const burnSpy = jest.spyOn(store, 'burn'); + const result = await service.burnAsset(assetCode, assetIssuer, 500n); + + expect(burnSpy).toHaveBeenCalledWith(assetCode, assetIssuer, 500n); + expect(result.available_balance).toBe(1500n); + }); + + it('should log error and throw if burn fails', async () => { + const errorSpy = jest.spyOn(Logger.prototype, 'error'); + jest.spyOn(store, 'burn').mockRejectedValue(new Error('Burn failed')); + + await expect(service.burnAsset(assetCode, assetIssuer, 500n)).rejects.toThrow('Burn failed'); + expect(errorSpy).toHaveBeenCalledWith('Burn failed for ARS: Burn failed'); + }); + + it('should delegate reserve/release/settle correctly', async () => { + await store.mint(assetCode, assetIssuer, 1000n); + + await service.reserveFunds(assetCode, assetIssuer, 300n); + expect(store.getTotalTracked(assetCode, assetIssuer)).toBe(1000n); + + await service.releaseFunds(assetCode, assetIssuer, 100n); + expect(store.getTotalTracked(assetCode, assetIssuer)).toBe(1000n); + + await service.settleFunds(assetCode, assetIssuer, 200n); + expect(store.getTotalTracked(assetCode, assetIssuer)).toBe(800n); + }); + + it('should log error and throw if reserve/release/settle fails', async () => { + const errorSpy = jest.spyOn(Logger.prototype, 'error'); + + jest.spyOn(store, 'reserve').mockRejectedValue(new Error('Reserve failed')); + await expect(service.reserveFunds(assetCode, assetIssuer, 1n)).rejects.toThrow( + 'Reserve failed', + ); + expect(errorSpy).toHaveBeenCalledWith('Reserve failed for ARS: Reserve failed'); + + jest.spyOn(store, 'release').mockRejectedValue(new Error('Release failed')); + await expect(service.releaseFunds(assetCode, assetIssuer, 1n)).rejects.toThrow( + 'Release failed', + ); + expect(errorSpy).toHaveBeenCalledWith('Release failed for ARS: Release failed'); + + jest.spyOn(store, 'settle').mockRejectedValue(new Error('Settle failed')); + await expect(service.settleFunds(assetCode, assetIssuer, 1n)).rejects.toThrow( + 'Settle failed', + ); + expect(errorSpy).toHaveBeenCalledWith('Settle failed for ARS: Settle failed'); + }); + }); + + describe('Query Methods', () => { + it('should return 0 strings for missing assets in getTreasuryBalance', async () => { + const balance = await service.getTreasuryBalance('MISSING', assetIssuer); + expect(balance).toBe('0'); + }); + + it('should return tracked balance as string', async () => { + await store.mint(assetCode, assetIssuer, 1234567n); + const balance = await service.getTreasuryBalance(assetCode, assetIssuer); + expect(balance).toBe('1234567'); + }); + + it('should return total tracked balance as bigint', async () => { + await store.mint(assetCode, assetIssuer, 100n); + await store.reserve(assetCode, assetIssuer, 50n); + expect(service.getTotalTrackedBalance(assetCode, assetIssuer)).toBe(100n); + }); + + it('should return full internal balance record', async () => { + await store.mint(assetCode, assetIssuer, 100n); + const record = service.getInternalBalance(assetCode, assetIssuer); + expect(record.available_balance).toBe(100n); + expect(record.asset_code).toBe(assetCode); + }); + + it('should return 0 in calculateReserveRatio if supply is zero', () => { + expect(service.calculateReserveRatio('1000', '0')).toBe(0); + }); + }); + + describe('Proof of Reserves Integration', () => { + it('should return asset reserve object with internal balance', async () => { + process.env.TREASURY_ISSUER_ADDRESS = assetIssuer; + await store.mint(assetCode, assetIssuer, 5000n); + + const reserve = await service.getAssetReserve(assetCode); + expect(reserve.symbol).toBe(assetCode); + expect(reserve.treasury_balance).toBe('5000'); + expect(reserve.total_supply).toBe('0'); + }); + + it('should calculate non-zero reserve ratio if supply is provided', async () => { + jest.spyOn(service, 'getTotalSupply').mockResolvedValue('10000'); + await store.mint(assetCode, assetIssuer, 5000n); + + const reserve = await service.getAssetReserve(assetCode); + expect(reserve.reserve_ratio).toBe(50); // 5000/10000 = 50% + }); + + it('should return 0 strings if getTreasuryBalance fails inside proof-of-reserves', async () => { + jest.spyOn(store, 'getTotalTracked').mockImplementation(() => { + throw new Error('Store failure'); + }); + const balance = await service.getTreasuryBalance(assetCode, assetIssuer); + expect(balance).toBe('0'); + }); + }); +}); diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index 58f640c..f238775 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -1,33 +1,155 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { AssetReserve } from './interfaces/proof-of-reserves.interface'; +import { TreasuryBalanceStore } from './treasury-balance.store'; +import { + TreasuryBalance, + TreasuryBalanceOperationResult, +} from './interfaces/treasury-balance.interface'; @Injectable() export class TreasuryService { - async getTotalSupply(_assetCode: string): Promise { - // TODO: Implement actual on-chain supply query using @stellar/stellar-sdk - // Example: - // const horizon = new Horizon.Server(process.env.STELLAR_HORIZON_URL); - // const asset = new Asset(assetCode, process.env.ISSUER_PUBLIC_KEY); - // const accounts = await horizon.accounts().forAsset(asset).call(); - // return accounts.records.reduce((sum, acc) => { - // const balance = acc.balances.find((b: any) => b.asset_code === assetCode); - // return sum + (balance ? parseFloat(balance.balance) : 0); - // }, 0).toString(); + private readonly logger = new Logger(TreasuryService.name); - return '0'; + constructor(private readonly balanceStore: TreasuryBalanceStore) {} + + /** + * Mints a Specified amount of an asset in the treasury. + * Atomically updates the internal ledger and logs the operation. + */ + async mintAsset( + assetCode: string, + assetIssuer: string, + amount: bigint, + mintCap?: bigint, + ): Promise { + this.logger.log(`Initiating mint: ${amount} ${assetCode} (Issuer: ${assetIssuer})`); + + try { + const result = await this.balanceStore.mint(assetCode, assetIssuer, amount, mintCap); + + this.logger.log( + `Mint successful: ${amount} ${assetCode}. ` + + `New available: ${result.available_balance}, reserved: ${result.reserved_balance}`, + ); + + return result; + } catch (error) { + this.logger.error(`Mint failed for ${assetCode}: ${error.message}`); + throw error; + } + } + + /** + * Burns a specified amount of an asset from the treasury. + * Atomically updates the internal ledger and logs the operation. + */ + async burnAsset( + assetCode: string, + assetIssuer: string, + amount: bigint, + ): Promise { + this.logger.log(`Initiating burn: ${amount} ${assetCode} (Issuer: ${assetIssuer})`); + + try { + const result = await this.balanceStore.burn(assetCode, assetIssuer, amount); + + this.logger.log( + `Burn successful: ${amount} ${assetCode}. ` + + `New available: ${result.available_balance}, reserved: ${result.reserved_balance}`, + ); + + return result; + } catch (error) { + this.logger.error(`Burn failed for ${assetCode}: ${error.message}`); + throw error; + } + } + + /** + * Reserves a specified amount of an asset. + * Moves funds from available_balance to reserved_balance atomically. + */ + async reserveFunds( + assetCode: string, + assetIssuer: string, + amount: bigint, + ): Promise { + this.logger.log(`Reserving funds: ${amount} ${assetCode}`); + + try { + return await this.balanceStore.reserve(assetCode, assetIssuer, amount); + } catch (error) { + this.logger.error(`Reserve failed for ${assetCode}: ${error.message}`); + throw error; + } + } + + /** + * Releases a specified amount of an asset from reservation. + * Moves funds from reserved_balance back to available_balance atomically. + */ + async releaseFunds( + assetCode: string, + assetIssuer: string, + amount: bigint, + ): Promise { + this.logger.log(`Releasing funds: ${amount} ${assetCode}`); + + try { + return await this.balanceStore.release(assetCode, assetIssuer, amount); + } catch (error) { + this.logger.error(`Release failed for ${assetCode}: ${error.message}`); + throw error; + } + } + + /** + * Settles a specified amount of an asset from reservation. + * Decrements reserved_balance without returning to available_balance. + */ + async settleFunds( + assetCode: string, + assetIssuer: string, + amount: bigint, + ): Promise { + this.logger.log(`Settling funds: ${amount} ${assetCode}`); + + try { + return await this.balanceStore.settle(assetCode, assetIssuer, amount); + } catch (error) { + this.logger.error(`Settle failed for ${assetCode}: ${error.message}`); + throw error; + } } - async getTreasuryBalance(_assetCode: string, _treasuryAddress: string): Promise { - // TODO: Implement actual treasury cold storage balance query - // Example: - // const horizon = new Horizon.Server(process.env.STELLAR_HORIZON_URL); - // const account = await horizon.loadAccount(treasuryAddress); - // const balance = account.balances.find((b: any) => b.asset_code === assetCode); - // return balance?.balance ?? '0'; + /** + * Retrieves the internal ledger balance for a specific asset. + */ + getInternalBalance(assetCode: string, assetIssuer: string): TreasuryBalance { + return this.balanceStore.getBalance(assetCode, assetIssuer); + } + + /** + * Retrieves the total tracked balance (available + reserved) for a specific asset. + */ + getTotalTrackedBalance(assetCode: string, assetIssuer: string): bigint { + return this.balanceStore.getTotalTracked(assetCode, assetIssuer); + } + async getTotalSupply(_assetCode: string): Promise { + // TODO: Implement actual on-chain supply query using @stellar/stellar-sdk return '0'; } + async getTreasuryBalance(assetCode: string, assetIssuer: string): Promise { + try { + const balance = this.balanceStore.getTotalTracked(assetCode, assetIssuer); + return balance.toString(); + } catch { + return '0'; + } + } + calculateReserveRatio(treasuryBalance: string, totalSupply: string): number { const treasury = parseFloat(treasuryBalance); const supply = parseFloat(totalSupply); @@ -38,13 +160,11 @@ export class TreasuryService { } async getAssetReserve(assetCode: string): Promise { - // TODO: Get treasury address from config service - // const treasuryAddress = await this.configService.getTreasuryAddress(); - const treasuryAddress = process.env.TREASURY_WALLET_ADDRESS ?? 'TREASURY_ADDRESS_NOT_SET'; + const treasuryIssuer = process.env.TREASURY_ISSUER_ADDRESS ?? 'GDTREASURYADDRESSXXXXXX'; const [totalSupply, treasuryBalance] = await Promise.all([ this.getTotalSupply(assetCode), - this.getTreasuryBalance(assetCode, treasuryAddress), + this.getTreasuryBalance(assetCode, treasuryIssuer), ]); const reserveRatio = this.calculateReserveRatio(treasuryBalance, totalSupply); diff --git a/apps/frontend/src/lib/utils.ts b/apps/frontend/src/lib/utils.ts index bd0c391..7133748 100644 --- a/apps/frontend/src/lib/utils.ts +++ b/apps/frontend/src/lib/utils.ts @@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); }