Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions apps/api/src/treasury/errors/treasury-balance.errors.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
66 changes: 66 additions & 0 deletions apps/api/src/treasury/interfaces/treasury-balance.interface.ts
Original file line number Diff line number Diff line change
@@ -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;
}
128 changes: 128 additions & 0 deletions apps/api/src/treasury/treasury-balance.invariants.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
}
}
Loading