diff --git a/services/token-aggregation/src/api/routes/ho-liquidity-routes.ts b/services/token-aggregation/src/api/routes/ho-liquidity-routes.ts new file mode 100644 index 0000000..cf7c448 --- /dev/null +++ b/services/token-aggregation/src/api/routes/ho-liquidity-routes.ts @@ -0,0 +1,33 @@ +import { Router, Request, Response } from 'express'; +import { cacheMiddleware } from '../middleware/cache'; +import { buildHoLiquiditySnapshot } from '../../services/omnl-ho-liquidity-snapshot'; + +const router = Router(); + +/** + * GET /omnl/ho-liquidity-snapshot — public read-only HO nostro + reserve coverage (cached). + * Refreshed on 30-minute operator timer; ?refresh=1 forces Fineract replay when configured. + */ +router.get('/omnl/ho-liquidity-snapshot', cacheMiddleware(30 * 60 * 1000), async (req: Request, res: Response) => { + try { + const forceRefresh = req.query.refresh === '1'; + const officeId = req.query.officeId != null ? Number(req.query.officeId) : undefined; + const snapshot = await buildHoLiquiditySnapshot({ + officeId: Number.isFinite(officeId) ? officeId : undefined, + forceRefresh, + lineId: String(req.query.lineId || '') || undefined, + }); + + const hasCritical = snapshot.alerts.some((a) => a.severity === 'critical'); + const status = snapshot.fineractConfigured && !hasCritical ? 200 : hasCritical ? 503 : 409; + + res.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=900'); + res.setHeader('X-OMNL-HO-Nostro-Audit-At', snapshot.generatedAt); + res.status(status).json(snapshot); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(500).json({ error: msg }); + } +}); + +export default router; diff --git a/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts b/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts index ba335af..69e36fd 100644 --- a/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts +++ b/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts @@ -5,6 +5,8 @@ import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware'; import { omnlComplianceGateMiddleware } from '../middleware/omnl-compliance-gate'; import { omnlIdempotencyMiddleware } from '../middleware/omnl-idempotency'; import { runTripleStateReconcile } from '../../services/omnl-triple-reconcile'; +import { getCachedCashInBankAuditSnapshot } from '../../services/omnl-cash-in-bank-audit'; +import { getCachedHoReserveCoverageSnapshot } from '../../services/chain138-ho-reserve-coverage'; import { buildIfrs7Disclosure, buildIfrs9Disclosure, @@ -49,6 +51,45 @@ router.get('/omnl/reconcile/triple-state', omnlSensitiveRouteGuard, async (req: } }); +router.get('/omnl/cash-in-bank', omnlSensitiveRouteGuard, async (req: Request, res: Response) => { + try { + const officeId = req.query.officeId != null ? Number(req.query.officeId) : undefined; + const totalSupplyUsdRaw = req.query.totalSupplyUsd != null ? Number(req.query.totalSupplyUsd) : undefined; + const forceRefresh = String(req.query.refresh || '') === '1'; + const totalSupplyUsd = + totalSupplyUsdRaw != null && Number.isFinite(totalSupplyUsdRaw) && totalSupplyUsdRaw > 0 + ? totalSupplyUsdRaw + : undefined; + const snapshot = await getCachedCashInBankAuditSnapshot({ + officeId: Number.isFinite(officeId) ? officeId : undefined, + totalSupplyUsd, + forceRefresh, + lineId: String(req.query.lineId || '') || undefined, + }); + const status = snapshot.audit.status === 'aligned' ? 200 : snapshot.audit.status === 'unavailable' ? 503 : 409; + res.status(status).json(snapshot); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(500).json({ error: msg }); + } +}); + +router.get('/omnl/ho-reserve-coverage', omnlSensitiveRouteGuard, async (req: Request, res: Response) => { + try { + const officeId = req.query.officeId != null ? Number(req.query.officeId) : undefined; + const forceRefresh = String(req.query.refresh || '') === '1'; + const snapshot = await getCachedHoReserveCoverageSnapshot({ + officeId: Number.isFinite(officeId) ? officeId : undefined, + forceRefresh, + }); + const status = snapshot.status === 'sufficient' ? 200 : snapshot.status === 'unavailable' ? 503 : 409; + res.status(status).json(snapshot); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(500).json({ error: msg }); + } +}); + router.get('/omnl/disclosures/ifrs7', omnlSensitiveRouteGuard, async (req: Request, res: Response) => { try { res.json(await buildIfrs7Disclosure(String(req.query.lineId || '') || undefined)); diff --git a/services/token-aggregation/src/api/routes/tokens.ts b/services/token-aggregation/src/api/routes/tokens.ts index 2eab6c0..8da03a5 100644 --- a/services/token-aggregation/src/api/routes/tokens.ts +++ b/services/token-aggregation/src/api/routes/tokens.ts @@ -26,6 +26,17 @@ import { import { fetchBlockscoutTokenMeta } from '../../services/blockscout-token-meta'; import { refreshChain138LiveReferencePrices } from '../../services/chain138-onchain-reference-prices'; import { MAX_SINGLE_POOL_TVL_USD, resolveVisibleTokenLiquidityUsd } from '../../services/token-visible-liquidity'; +import { resolveTokenCashLiquidityUsd } from '../../services/token-cash-liquidity'; +import { isChain138CashUsdTokenAddress } from '../../config/chain138-cash-tokens'; +import { isChain138IssuedCStarTokenAddress } from '../../config/chain138-gru-cstar-tokens'; +import { + evaluateSupplyNostroMatch, + getCachedCashInBankAuditSnapshot, + mergeCashInBankAuditWithSupplyMatch, + type CashInBankAuditInfo, + type SupplyNostroMatchInfo, +} from '../../services/omnl-cash-in-bank-audit'; +import { getCachedHoReserveCoverageSnapshot, type HoReserveCoverageInfo } from '../../services/chain138-ho-reserve-coverage'; const router: Router = Router(); const tokenRepo = new TokenRepository(); @@ -280,6 +291,12 @@ export interface TokenMarketSnapshot { decimals?: number; priceUsd?: number; liquidityUsd?: number; + cashLiquidityUsd?: number; + cashInBankUsd?: number; + cashInBankAudit?: CashInBankAuditInfo; + totalSupplyUsd?: number; + supplyNostroMatch?: SupplyNostroMatchInfo; + hoReserveCoverage?: HoReserveCoverageInfo; volume24h?: number; lastUpdated?: string; source?: string; @@ -436,6 +453,64 @@ async function resolveTokenMarketSnapshot( poolLiquidityUsd, ); const liquidityUsd = bestLiquidityUsd > 0 ? bestLiquidityUsd : undefined; + const cashLiquidityUsdRaw = chainId === 138 + ? await resolveTokenCashLiquidityUsd(chainId, normalizedAddress) + : 0; + const cashLiquidityUsd = cashLiquidityUsdRaw > 0 ? cashLiquidityUsdRaw : undefined; + + let cashInBankUsd: number | undefined; + let cashInBankAudit: CashInBankAuditInfo | undefined; + let totalSupplyUsd: number | undefined; + let supplyNostroMatch: SupplyNostroMatchInfo | undefined; + let hoReserveCoverage: HoReserveCoverageInfo | undefined; + const supplyUnits = decimalStringToNumber(token.totalSupply, token.decimals); + const pegPriceUsd = priceUsd != null && priceUsd > 0 ? priceUsd : 1; + if (supplyUnits != null && supplyUnits > 0) { + totalSupplyUsd = supplyUnits * pegPriceUsd; + } + if (chainId === 138 && isChain138IssuedCStarTokenAddress(normalizedAddress)) { + try { + const [bankSnapshot, reserveSnapshot] = await Promise.all([ + getCachedCashInBankAuditSnapshot({ skipTripleState: true }), + getCachedHoReserveCoverageSnapshot(), + ]); + if (bankSnapshot.cashInBankUsd > 0) { + cashInBankUsd = bankSnapshot.cashInBankUsd; + } + supplyNostroMatch = evaluateSupplyNostroMatch({ + totalSupplyUsd, + externalNostroCashUsd: bankSnapshot.cashInBankUsd, + headOfficeId: bankSnapshot.officeId, + }); + cashInBankAudit = mergeCashInBankAuditWithSupplyMatch(bankSnapshot.audit, supplyNostroMatch); + hoReserveCoverage = reserveSnapshot; + } catch (error) { + logger.warn('HO liquidity snapshot unavailable', { + chainId, + address: normalizedAddress, + error: error instanceof Error ? error.message : String(error), + }); + } + } else if (chainId === 138 && isChain138CashUsdTokenAddress(normalizedAddress)) { + try { + const bankSnapshot = await getCachedCashInBankAuditSnapshot({ skipTripleState: true }); + if (bankSnapshot.cashInBankUsd > 0) { + cashInBankUsd = bankSnapshot.cashInBankUsd; + } + supplyNostroMatch = evaluateSupplyNostroMatch({ + totalSupplyUsd, + externalNostroCashUsd: bankSnapshot.cashInBankUsd, + headOfficeId: bankSnapshot.officeId, + }); + cashInBankAudit = mergeCashInBankAuditWithSupplyMatch(bankSnapshot.audit, supplyNostroMatch); + } catch (error) { + logger.warn('Cash-in-bank audit snapshot unavailable', { + chainId, + address: normalizedAddress, + error: error instanceof Error ? error.message : String(error), + }); + } + } return { address: normalizedAddress, @@ -444,12 +519,23 @@ async function resolveTokenMarketSnapshot( decimals: token.decimals, priceUsd: priceUsd != null && priceUsd > 0 ? priceUsd : undefined, liquidityUsd, + cashLiquidityUsd, + cashInBankUsd, + cashInBankAudit, + totalSupplyUsd, + supplyNostroMatch, + hoReserveCoverage, volume24h: market?.volume24h, - lastUpdated: market?.lastUpdated instanceof Date - ? market.lastUpdated.toISOString() - : market?.lastUpdated - ? new Date(market.lastUpdated).toISOString() - : undefined, + lastUpdated: (() => { + const marketLastUpdated = market?.lastUpdated instanceof Date + ? market.lastUpdated.toISOString() + : market?.lastUpdated + ? new Date(market.lastUpdated).toISOString() + : undefined; + const auditAt = hoReserveCoverage?.generatedAt; + const candidates = [auditAt, marketLastUpdated].filter(Boolean) as string[]; + return candidates.length > 0 ? candidates.sort().reverse()[0] : undefined; + })(), source: pricing?.sourceLayer || (blockscoutMeta?.exchangeRate != null ? 'blockscout' : undefined), blockscoutExchangeRate: blockscoutMeta?.exchangeRate, pricingKind, diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index 5683c15..9344ddf 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -27,6 +27,7 @@ import omnlComplianceRoutes from './routes/omnl-compliance-routes'; import omnlTerminalRoutes from './routes/omnl-terminal-routes'; import checkpointRoutes from './routes/checkpoint'; import reserveCapacityRoutes from './routes/reserve-capacity'; +import hoLiquidityRoutes from './routes/ho-liquidity-routes'; import metamaskPriceRoutes from './routes/metamask-prices'; import { MultiChainIndexer } from '../indexer/chain-indexer'; import { OmnlEventPoller } from '../indexer/omnl-event-poller'; @@ -296,6 +297,7 @@ export class ApiServer { this.app.use('/api/v1', partnerPayloadRoutes); this.app.use('/api/v1', checkpointRoutes); this.app.use('/api/v1', reserveCapacityRoutes); + this.app.use('/api/v1', hoLiquidityRoutes); this.app.use('/api/v1/prices/metamask', metamaskPriceRoutes); this.app.use('/api/v1', omnlComplianceRoutes); this.app.use('/api/v1', omnlTerminalRoutes); diff --git a/services/token-aggregation/src/config/chain138-cash-tokens.test.ts b/services/token-aggregation/src/config/chain138-cash-tokens.test.ts new file mode 100644 index 0000000..e578d31 --- /dev/null +++ b/services/token-aggregation/src/config/chain138-cash-tokens.test.ts @@ -0,0 +1,17 @@ +import { getChain138CashUsdTokenAddresses, isChain138CashUsdTokenAddress } from './chain138-cash-tokens'; + +describe('chain138 cash USD tokens', () => { + it('includes canonical cUSDC, cUSDT, and official mirror stables', () => { + const addresses = getChain138CashUsdTokenAddresses(); + expect(addresses.has('0xf22258f57794cc8e06237084b353ab30fffa640b')).toBe(true); + expect(addresses.has('0x93e66202a11b1772e55407b32b44e5cd8eda7f22')).toBe(true); + expect(addresses.has('0x71d6687f38b93ccad569fa6352c876eea967201b')).toBe(true); + expect(addresses.has('0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1')).toBe(true); + expect(addresses.has('0xe94260c555ac1d9d3cc9e1632883452ebdf0082e')).toBe(false); + }); + + it('detects cash token membership case-insensitively', () => { + expect(isChain138CashUsdTokenAddress('0xF22258f57794CC8E06237084b353Ab30fFfa640b')).toBe(true); + expect(isChain138CashUsdTokenAddress('0xe94260c555ac1d9d3cc9e1632883452ebdf0082e')).toBe(false); + }); +}); diff --git a/services/token-aggregation/src/config/chain138-cash-tokens.ts b/services/token-aggregation/src/config/chain138-cash-tokens.ts new file mode 100644 index 0000000..d564cd5 --- /dev/null +++ b/services/token-aggregation/src/config/chain138-cash-tokens.ts @@ -0,0 +1,28 @@ +import { getCanonicalTokenBySymbol } from './canonical-tokens'; + +const CHAIN_138 = 138; + +/** Hub USD cash eMoney + official mirror stables used in Chain 138 PMM cash pools. */ +const CHAIN138_CASH_USD_SYMBOLS = ['cUSDC', 'cUSDT', 'USDC', 'USDT'] as const; + +function normalizeAddress(value: string | undefined): string | undefined { + const normalized = String(value || '').trim().toLowerCase(); + return /^0x[a-f0-9]{40}$/.test(normalized) ? normalized : undefined; +} + +/** Lowercase addresses for USD cash legs in canonical Stack A PMM pools. */ +export function getChain138CashUsdTokenAddresses(): ReadonlySet { + const addresses = new Set(); + for (const symbol of CHAIN138_CASH_USD_SYMBOLS) { + const spec = getCanonicalTokenBySymbol(CHAIN_138, symbol); + const address = normalizeAddress(spec?.addresses?.[CHAIN_138]); + if (address) { + addresses.add(address); + } + } + return addresses; +} + +export function isChain138CashUsdTokenAddress(address: string): boolean { + return getChain138CashUsdTokenAddresses().has(address.trim().toLowerCase()); +} diff --git a/services/token-aggregation/src/config/chain138-gru-cstar-tokens.ts b/services/token-aggregation/src/config/chain138-gru-cstar-tokens.ts new file mode 100644 index 0000000..e6b76ea --- /dev/null +++ b/services/token-aggregation/src/config/chain138-gru-cstar-tokens.ts @@ -0,0 +1,26 @@ +import { getCanonicalTokenByAddress, getCanonicalTokensByChain, type CanonicalTokenSpec } from './canonical-tokens'; + +const CHAIN_138 = 138; + +function normalizeAddress(value: string): string { + return value.trim().toLowerCase(); +} + +/** Canonical Chain 138 issued c* base tokens (excludes staged V2 duplicates and cW* transport). */ +export function getChain138IssuedCStarTokens(): CanonicalTokenSpec[] { + return getCanonicalTokensByChain(CHAIN_138).filter((spec) => isChain138IssuedCStarTokenSpec(spec)); +} + +export function isChain138IssuedCStarTokenSpec(spec: CanonicalTokenSpec | undefined): boolean { + if (!spec || spec.type !== 'base') return false; + if (spec.deploymentStatus === 'staged') return false; + const symbol = spec.symbol.trim(); + if (!symbol.startsWith('c') || symbol.startsWith('cW')) return false; + const address = spec.addresses[CHAIN_138]; + return Boolean(address && String(address).trim() !== ''); +} + +export function isChain138IssuedCStarTokenAddress(address: string): boolean { + const spec = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address)); + return isChain138IssuedCStarTokenSpec(spec); +} diff --git a/services/token-aggregation/src/config/omnl-ho-liquidity-cache.ts b/services/token-aggregation/src/config/omnl-ho-liquidity-cache.ts new file mode 100644 index 0000000..c747258 --- /dev/null +++ b/services/token-aggregation/src/config/omnl-ho-liquidity-cache.ts @@ -0,0 +1,13 @@ +/** Default 30-minute cache for HO nostro / reserve Fineract snapshots (near-realtime audit cadence). */ +export const OMNL_HO_LIQUIDITY_CACHE_MS_DEFAULT = 30 * 60 * 1000; + +export function hoLiquidityCacheMs(): number { + const raw = process.env.OMNL_CASH_IN_BANK_CACHE_MS + || process.env.OMNL_RESERVE_COVERAGE_CACHE_MS + || process.env.OMNL_OFFICE_GL_CACHE_MS; + if (raw != null && String(raw).trim() !== '') { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return OMNL_HO_LIQUIDITY_CACHE_MS_DEFAULT; +} diff --git a/services/token-aggregation/src/services/chain138-ho-reserve-coverage.test.ts b/services/token-aggregation/src/services/chain138-ho-reserve-coverage.test.ts new file mode 100644 index 0000000..d4ea935 --- /dev/null +++ b/services/token-aggregation/src/services/chain138-ho-reserve-coverage.test.ts @@ -0,0 +1,53 @@ +import { + evaluateHoReserveCoverage, + reserveAssetsUsdFromGlRows, + resetHoReserveCoverageCache, +} from './chain138-ho-reserve-coverage'; + +describe('reserveAssetsUsdFromGlRows', () => { + it('sums Dr balances on configured reserve GL codes only', () => { + const total = reserveAssetsUsdFromGlRows( + [ + { glCode: '1000', side: 'Dr', amount: '900000000000.00' }, + { glCode: '1050', side: 'Dr', amount: '250000000000.00' }, + { glCode: '13010', side: 'Dr', amount: '999998500.00' }, + ], + ['1000', '1050'], + ); + expect(total).toBeCloseTo(1_150_000_000_000, 0); + }); +}); + +describe('evaluateHoReserveCoverage', () => { + it('marks sufficient when reserve assets exceed 1.2× issued c* USD', () => { + const row = evaluateHoReserveCoverage({ + reserveAssetsUsd: 1_150_000_000_000, + issuedCStarUsd: 344_561_282_129, + multiplier: 1.2, + headOfficeId: 1, + reserveAssetGlCodes: ['1000', '1050'], + tokenCount: 13, + }); + + expect(row.status).toBe('sufficient'); + expect(row.requiredReserveUsd).toBeCloseTo(413_473_538_555, -3); + expect(row.coverageRatio).toBeCloseTo(2.78, 2); + }); + + it('marks insufficient when reserve assets fall below 1.2× issued c* USD', () => { + const row = evaluateHoReserveCoverage({ + reserveAssetsUsd: 400_000_000_000, + issuedCStarUsd: 344_561_282_129, + multiplier: 1.2, + }); + + expect(row.status).toBe('insufficient'); + expect(row.coverageRatio).toBeLessThan(1); + }); +}); + +describe('resetHoReserveCoverageCache', () => { + it('clears cache without throwing', () => { + expect(() => resetHoReserveCoverageCache()).not.toThrow(); + }); +}); diff --git a/services/token-aggregation/src/services/chain138-ho-reserve-coverage.ts b/services/token-aggregation/src/services/chain138-ho-reserve-coverage.ts new file mode 100644 index 0000000..ba5fb95 --- /dev/null +++ b/services/token-aggregation/src/services/chain138-ho-reserve-coverage.ts @@ -0,0 +1,291 @@ +import { readFileSync, existsSync } from 'fs'; +import { hoLiquidityCacheMs } from '../config/omnl-ho-liquidity-cache'; +import { resolve } from 'path'; +import { Contract, JsonRpcProvider, formatUnits } from 'ethers'; +import { getChain138IssuedCStarTokens } from '../config/chain138-gru-cstar-tokens'; +import { getCanonicalPriceUsd } from './canonical-price-oracle'; +import { getCachedOfficeSignedGlBalances, fineractConfigured } from './omnl-fineract-office-gl'; +import { loadNostroPolicy } from './omnl-cash-in-bank-audit'; + +const CHAIN_138 = 138; +const ERC20_TOTAL_SUPPLY_ABI = ['function totalSupply() view returns (uint256)']; + +export type HoReserveCoverageStatus = 'sufficient' | 'insufficient' | 'pending' | 'unavailable'; + +export interface HoReserveCoverageInfo { + status: HoReserveCoverageStatus; + multiplier: number; + headOfficeId: number; + reserveAssetGlCodes: string[]; + reserveAssetsUsd: number; + issuedCStarUsd: number; + requiredReserveUsd: number; + coverageRatio: number; + formula: string; + policyId: string; + policyRef: string; + generatedAt: string; + tokenCount?: number; + error?: string; +} + +type ReserveCoveragePolicy = { + policyId?: string; + headOfficeId?: number; + reserveAssetGlCodes?: string[]; + reserveCoverageMultiplier?: number; + supplyNostroMatchMultiplier?: number; +}; + +let cache: { expiresAt: number; snapshot: HoReserveCoverageInfo } | null = null; +let inflight: Promise | null = null; + +function repoRoot(): string { + if (process.env.PROXMOX_ROOT?.trim()) return process.env.PROXMOX_ROOT.trim(); + if (process.env.PHOENIX_REPO_ROOT?.trim()) return process.env.PHOENIX_REPO_ROOT.trim(); + return resolve(__dirname, '../../../../..'); +} + +export function loadReserveCoveragePolicy(): { + policyId: string; + headOfficeId: number; + reserveAssetGlCodes: string[]; + multiplier: number; + policyRef: string; +} { + const nostro = loadNostroPolicy(); + const candidates = [ + process.env.OMNL_NOSTRO_POLICY_PATH?.trim(), + resolve(repoRoot(), 'config/compliance/omnl-nostro-backed-netting.v1.json'), + resolve(__dirname, '../../../../config/compliance/omnl-nostro-backed-netting.v1.json'), + ].filter(Boolean) as string[]; + const path = candidates.find((c) => existsSync(c)); + if (!path) { + return { + policyId: nostro.policyId, + headOfficeId: nostro.headOfficeId, + reserveAssetGlCodes: ['1000', '1050'], + multiplier: nostro.supplyNostroMatchMultiplier, + policyRef: nostro.policyRef, + }; + } + const parsed = JSON.parse(readFileSync(path, 'utf8')) as ReserveCoveragePolicy; + return { + policyId: String(parsed.policyId || nostro.policyId), + headOfficeId: Number(parsed.headOfficeId ?? nostro.headOfficeId), + reserveAssetGlCodes: Array.isArray(parsed.reserveAssetGlCodes) && parsed.reserveAssetGlCodes.length + ? parsed.reserveAssetGlCodes.map(String) + : ['1000', '1050'], + multiplier: Number( + parsed.reserveCoverageMultiplier ?? parsed.supplyNostroMatchMultiplier ?? nostro.supplyNostroMatchMultiplier, + ), + policyRef: path.replace(`${repoRoot()}/`, ''), + }; +} + +export function reserveAssetsUsdFromGlRows( + rows: Array<{ glCode: string; side: string; amount: string }>, + reserveAssetGlCodes: string[], +): number { + let total = 0; + for (const code of reserveAssetGlCodes) { + const row = rows.find((r) => r.glCode === code); + if (!row || row.side !== 'Dr') continue; + const amountUsd = Number(row.amount); + if (Number.isFinite(amountUsd) && amountUsd > 0) { + total += amountUsd; + } + } + return total; +} + +export function evaluateHoReserveCoverage(input: { + reserveAssetsUsd?: number; + issuedCStarUsd?: number; + multiplier?: number; + headOfficeId?: number; + reserveAssetGlCodes?: string[]; + tokenCount?: number; +}): HoReserveCoverageInfo { + const policy = loadReserveCoveragePolicy(); + const multiplier = input.multiplier ?? policy.multiplier; + const headOfficeId = input.headOfficeId ?? policy.headOfficeId; + const reserveAssetGlCodes = input.reserveAssetGlCodes ?? policy.reserveAssetGlCodes; + const generatedAt = new Date().toISOString(); + const formula = `coverageRatio = reserveAssetsUsd / (${multiplier} × issuedCStarUsd)`; + + if (input.issuedCStarUsd == null || !(input.issuedCStarUsd > 0)) { + return { + status: 'pending', + multiplier, + headOfficeId, + reserveAssetGlCodes, + reserveAssetsUsd: input.reserveAssetsUsd ?? 0, + issuedCStarUsd: input.issuedCStarUsd ?? 0, + requiredReserveUsd: 0, + coverageRatio: 0, + formula, + policyId: policy.policyId, + policyRef: policy.policyRef, + generatedAt, + tokenCount: input.tokenCount, + }; + } + + if (input.reserveAssetsUsd == null || !(input.reserveAssetsUsd > 0)) { + return { + status: 'unavailable', + multiplier, + headOfficeId, + reserveAssetGlCodes, + reserveAssetsUsd: input.reserveAssetsUsd ?? 0, + issuedCStarUsd: input.issuedCStarUsd, + requiredReserveUsd: input.issuedCStarUsd * multiplier, + coverageRatio: 0, + formula, + policyId: policy.policyId, + policyRef: policy.policyRef, + generatedAt, + tokenCount: input.tokenCount, + error: 'OMNL Fineract reserve GL balances unavailable', + }; + } + + const requiredReserveUsd = input.issuedCStarUsd * multiplier; + const coverageRatio = input.reserveAssetsUsd / requiredReserveUsd; + const status: HoReserveCoverageStatus = coverageRatio >= 1 ? 'sufficient' : 'insufficient'; + + return { + status, + multiplier, + headOfficeId, + reserveAssetGlCodes, + reserveAssetsUsd: input.reserveAssetsUsd, + issuedCStarUsd: input.issuedCStarUsd, + requiredReserveUsd, + coverageRatio, + formula, + policyId: policy.policyId, + policyRef: policy.policyRef, + generatedAt, + tokenCount: input.tokenCount, + }; +} + +function chain138RpcUrl(): string { + return ( + process.env.RPC_URL_138?.trim() + || process.env.CHAIN_138_RPC_URL?.trim() + || 'http://192.168.11.211:8545' + ); +} + +async function resolveIssuedCStarUsd(): Promise<{ issuedCStarUsd: number; tokenCount: number }> { + const tokens = getChain138IssuedCStarTokens(); + if (tokens.length === 0) { + return { issuedCStarUsd: 0, tokenCount: 0 }; + } + + const provider = new JsonRpcProvider(chain138RpcUrl(), CHAIN_138); + const rows = await Promise.all( + tokens.map(async (spec) => { + const address = String(spec.addresses[CHAIN_138]).trim(); + try { + const contract = new Contract(address, ERC20_TOTAL_SUPPLY_ABI, provider); + const raw = await contract.totalSupply() as bigint; + if (raw <= 0n) return 0; + const units = Number(formatUnits(raw, spec.decimals)); + if (!Number.isFinite(units) || units <= 0) return 0; + const priceUsd = getCanonicalPriceUsd(CHAIN_138, address) ?? 1; + return units * priceUsd; + } catch { + return 0; + } + }), + ); + + return { + issuedCStarUsd: rows.reduce((sum, value) => sum + value, 0), + tokenCount: tokens.length, + }; +} + +export async function buildHoReserveCoverageSnapshot(options?: { + officeId?: number; + forceRefreshGl?: boolean; +}): Promise { + const policy = loadReserveCoveragePolicy(); + const officeId = options?.officeId + ?? Number(process.env.OMNL_CASH_IN_BANK_OFFICE_ID || process.env.OMNL_DEFAULT_OFFICE_ID || policy.headOfficeId); + + let issuedCStarUsd = 0; + let tokenCount = 0; + try { + const issued = await resolveIssuedCStarUsd(); + issuedCStarUsd = issued.issuedCStarUsd; + tokenCount = issued.tokenCount; + } catch { + issuedCStarUsd = 0; + } + + if (!fineractConfigured()) { + return evaluateHoReserveCoverage({ + reserveAssetsUsd: 0, + issuedCStarUsd, + headOfficeId: officeId, + tokenCount, + }); + } + + let reserveAssetsUsd = 0; + try { + const glRows = await getCachedOfficeSignedGlBalances(officeId, { forceRefresh: options?.forceRefreshGl }); + reserveAssetsUsd = reserveAssetsUsdFromGlRows(glRows, policy.reserveAssetGlCodes); + } catch (e) { + return { + ...evaluateHoReserveCoverage({ + reserveAssetsUsd: 0, + issuedCStarUsd, + headOfficeId: officeId, + tokenCount, + }), + error: e instanceof Error ? e.message : String(e), + }; + } + + return evaluateHoReserveCoverage({ + reserveAssetsUsd, + issuedCStarUsd, + headOfficeId: officeId, + tokenCount, + }); +} + +/** Cached HO reserve coverage vs aggregate Chain 138 c* issuance (default TTL 120s). */ +export async function getCachedHoReserveCoverageSnapshot(options?: { + forceRefresh?: boolean; + officeId?: number; +}): Promise { + const ttlMs = Number(process.env.OMNL_RESERVE_COVERAGE_CACHE_MS || hoLiquidityCacheMs()); + const now = Date.now(); + if (!options?.forceRefresh && cache && cache.expiresAt > now) { + return cache.snapshot; + } + if (inflight) { + return inflight; + } + inflight = buildHoReserveCoverageSnapshot({ officeId: options?.officeId }) + .then((snapshot) => { + cache = { expiresAt: Date.now() + ttlMs, snapshot }; + return snapshot; + }) + .finally(() => { + inflight = null; + }); + return inflight; +} + +export function resetHoReserveCoverageCache(): void { + cache = null; + inflight = null; +} diff --git a/services/token-aggregation/src/services/omnl-cash-in-bank-audit.test.ts b/services/token-aggregation/src/services/omnl-cash-in-bank-audit.test.ts new file mode 100644 index 0000000..0f20307 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-cash-in-bank-audit.test.ts @@ -0,0 +1,71 @@ +import { + cashInBankUsdFromGlRows, + evaluateSupplyNostroMatch, + externalNostroCashFromGlRows, + resetCashInBankAuditCache, +} from './omnl-cash-in-bank-audit'; + +describe('externalNostroCashFromGlRows', () => { + it('uses GL 13010 Dr only — excludes reserve GL 1000', () => { + const { cashInBankUsd, legs } = externalNostroCashFromGlRows( + [ + { glCode: '13010', name: 'Nostro USD', side: 'Dr', amount: '999998500.00' }, + { glCode: '1000', name: 'Reserve asset', side: 'Dr', amount: '900000000000.00' }, + ], + '13010', + ); + + expect(cashInBankUsd).toBeCloseTo(999_998_500, 0); + expect(legs).toHaveLength(1); + expect(legs[0]?.glCode).toBe('13010'); + }); +}); + +describe('cashInBankUsdFromGlRows', () => { + it('sums Dr balances on all listed nostro GL codes', () => { + const { cashInBankUsd, legs } = cashInBankUsdFromGlRows( + [ + { glCode: '13010', name: 'Nostro USD', side: 'Dr', amount: '500000000.00' }, + { glCode: '1000', name: 'Reserve asset', side: 'Dr', amount: '250000000.00' }, + ], + ['13010', '1000'], + ); + + expect(cashInBankUsd).toBeCloseTo(750_000_000, 0); + expect(legs).toHaveLength(2); + }); +}); + +describe('evaluateSupplyNostroMatch', () => { + it('matches when total supply USD equals 1.2× external nostro', () => { + const match = evaluateSupplyNostroMatch({ + totalSupplyUsd: 1_200_000_000, + externalNostroCashUsd: 1_000_000_000, + multiplier: 1.2, + toleranceBps: 100, + }); + + expect(match.status).toBe('matched'); + expect(match.expectedSupplyUsd).toBeCloseTo(1_200_000_000, 0); + expect(match.headOfficeId).toBe(1); + expect(match.formula).toContain('1.2'); + }); + + it('breaks when total supply deviates beyond tolerance', () => { + const match = evaluateSupplyNostroMatch({ + totalSupplyUsd: 147_500_000_000, + externalNostroCashUsd: 999_998_500, + multiplier: 1.2, + toleranceBps: 100, + }); + + expect(match.status).toBe('break'); + expect(match.expectedSupplyUsd).toBeCloseTo(1_199_998_200, 0); + }); +}); + +describe('resetCashInBankAuditCache', () => { + it('clears cached snapshot without throwing', () => { + expect(() => resetCashInBankAuditCache()).not.toThrow(); + }); +}); diff --git a/services/token-aggregation/src/services/omnl-cash-in-bank-audit.ts b/services/token-aggregation/src/services/omnl-cash-in-bank-audit.ts new file mode 100644 index 0000000..ae26117 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-cash-in-bank-audit.ts @@ -0,0 +1,444 @@ +import { readFileSync, existsSync } from 'fs'; +import { hoLiquidityCacheMs } from '../config/omnl-ho-liquidity-cache'; +import { resolve } from 'path'; +import type { SignedGlBalanceRow } from './omnl-fineract-office-gl'; +import { getCachedOfficeSignedGlBalances, fineractConfigured } from './omnl-fineract-office-gl'; +import { runTripleStateReconcile } from './omnl-triple-reconcile'; + +export type CashInBankAuditStatus = 'aligned' | 'breaks' | 'pending' | 'unavailable'; +export type SupplyNostroMatchStatus = 'matched' | 'break' | 'pending' | 'unavailable'; + +export interface CashInBankGlLeg { + glCode: string; + name: string; + side: 'Dr' | 'Cr'; + amountUsd: number; +} + +export interface SupplyNostroMatchInfo { + status: SupplyNostroMatchStatus; + multiplier: number; + toleranceBps: number; + headOfficeId: number; + externalNostroGlCode: string; + /** totalSupplyUsd = multiplier × externalNostroCashUsd (HO GL 13010 Dr). */ + formula: string; + totalSupplyUsd?: number; + externalNostroCashUsd?: number; + expectedSupplyUsd?: number; + deltaUsd?: number; + deltaBps?: number; +} + +export interface CashInBankAuditInfo { + status: CashInBankAuditStatus; + breaksCount: number; + tripleStateAligned?: boolean; + supplyNostroMatch?: SupplyNostroMatchStatus; + policyId: string; + policyRef: string; + error?: string; +} + +export interface CashInBankAuditSnapshot { + generatedAt: string; + officeId: number; + /** External correspondent nostro cash only (GL 13010 Dr) — not reserve GL 1000. */ + cashInBankUsd: number; + externalNostroGlCode: string; + nostroGlCodes: string[]; + legs: CashInBankGlLeg[]; + audit: CashInBankAuditInfo; + supplyNostroMatch?: SupplyNostroMatchInfo; + fineractConfigured: boolean; +} + +type NostroPolicy = { + policyId?: string; + nostroGlCodes?: string[]; + externalNostroGlCode?: string; + headOfficeId?: number; + supplyNostroMatchMultiplier?: number; + supplyNostroMatchToleranceBps?: number; +}; + +let cache: { expiresAt: number; snapshot: CashInBankAuditSnapshot } | null = null; + +function repoRoot(): string { + if (process.env.PROXMOX_ROOT?.trim()) return process.env.PROXMOX_ROOT.trim(); + if (process.env.PHOENIX_REPO_ROOT?.trim()) return process.env.PHOENIX_REPO_ROOT.trim(); + return resolve(__dirname, '../../../../..'); +} + +export function loadNostroPolicy(): { + policyId: string; + nostroGlCodes: string[]; + externalNostroGlCode: string; + headOfficeId: number; + supplyNostroMatchMultiplier: number; + supplyNostroMatchToleranceBps: number; + policyRef: string; +} { + const candidates = [ + process.env.OMNL_NOSTRO_POLICY_PATH?.trim(), + resolve(repoRoot(), 'config/compliance/omnl-nostro-backed-netting.v1.json'), + resolve(__dirname, '../../../../config/compliance/omnl-nostro-backed-netting.v1.json'), + ].filter(Boolean) as string[]; + const path = candidates.find((c) => existsSync(c)); + if (!path) { + return { + policyId: 'OMNL-NOSTRO-BACKED-NETTING-V1', + nostroGlCodes: ['13010', '1000'], + externalNostroGlCode: '13010', + headOfficeId: 1, + supplyNostroMatchMultiplier: 1.2, + supplyNostroMatchToleranceBps: 100, + policyRef: 'config/compliance/omnl-nostro-backed-netting.v1.json', + }; + } + const parsed = JSON.parse(readFileSync(path, 'utf8')) as NostroPolicy; + return { + policyId: String(parsed.policyId || 'OMNL-NOSTRO-BACKED-NETTING-V1'), + nostroGlCodes: Array.isArray(parsed.nostroGlCodes) && parsed.nostroGlCodes.length + ? parsed.nostroGlCodes.map(String) + : ['13010', '1000'], + externalNostroGlCode: String(parsed.externalNostroGlCode || '13010'), + headOfficeId: Number(parsed.headOfficeId ?? 1), + supplyNostroMatchMultiplier: Number(parsed.supplyNostroMatchMultiplier ?? 1.2), + supplyNostroMatchToleranceBps: Number(parsed.supplyNostroMatchToleranceBps ?? 100), + policyRef: path.replace(`${repoRoot()}/`, ''), + }; +} + +function glLegFromRow(row: SignedGlBalanceRow): CashInBankGlLeg | null { + const amountUsd = Number(row.amount); + if (!Number.isFinite(amountUsd) || amountUsd <= 0) return null; + const side = row.side === 'Dr' ? 'Dr' : 'Cr'; + return { glCode: row.glCode, name: row.name, side, amountUsd }; +} + +/** Sum Dr balances for one GL code (external nostro uses 13010 only). */ +export function externalNostroCashFromGlRows( + rows: SignedGlBalanceRow[], + externalNostroGlCode: string, +): { cashInBankUsd: number; legs: CashInBankGlLeg[] } { + const row = rows.find((r) => r.glCode === externalNostroGlCode); + if (!row) { + return { cashInBankUsd: 0, legs: [] }; + } + const leg = glLegFromRow(row); + if (!leg || leg.side !== 'Dr') { + return { cashInBankUsd: 0, legs: leg ? [leg] : [] }; + } + return { cashInBankUsd: leg.amountUsd, legs: [leg] }; +} + +/** @deprecated Prefer externalNostroCashFromGlRows — sums all listed GL Dr legs. */ +export function cashInBankUsdFromGlRows( + rows: SignedGlBalanceRow[], + nostroGlCodes: string[], +): { cashInBankUsd: number; legs: CashInBankGlLeg[] } { + const legs: CashInBankGlLeg[] = []; + let cashInBankUsd = 0; + for (const code of nostroGlCodes) { + const row = rows.find((r) => r.glCode === code); + if (!row) continue; + const leg = glLegFromRow(row); + if (!leg) continue; + legs.push(leg); + if (leg.side === 'Dr') cashInBankUsd += leg.amountUsd; + } + return { cashInBankUsd, legs }; +} + +function supplyMatchMeta(policy = loadNostroPolicy()): Pick< + SupplyNostroMatchInfo, + 'multiplier' | 'toleranceBps' | 'headOfficeId' | 'externalNostroGlCode' | 'formula' +> { + return { + multiplier: policy.supplyNostroMatchMultiplier, + toleranceBps: policy.supplyNostroMatchToleranceBps, + headOfficeId: policy.headOfficeId, + externalNostroGlCode: policy.externalNostroGlCode, + formula: `totalSupplyUsd = ${policy.supplyNostroMatchMultiplier} × externalNostroCashUsd`, + }; +} + +/** totalSupplyUsd should equal multiplier × HO external nostro cash (GL 13010 Dr, office 1). */ +export function evaluateSupplyNostroMatch(input: { + totalSupplyUsd?: number; + externalNostroCashUsd?: number; + multiplier?: number; + toleranceBps?: number; + headOfficeId?: number; +}): SupplyNostroMatchInfo { + const policy = loadNostroPolicy(); + const meta = supplyMatchMeta(policy); + const multiplier = input.multiplier ?? meta.multiplier; + const toleranceBps = input.toleranceBps ?? meta.toleranceBps; + const headOfficeId = input.headOfficeId ?? meta.headOfficeId; + const totalSupplyUsd = input.totalSupplyUsd; + const externalNostroCashUsd = input.externalNostroCashUsd; + + if (totalSupplyUsd == null || !(totalSupplyUsd > 0)) { + return { + status: 'pending', + ...meta, + multiplier, + toleranceBps, + headOfficeId, + totalSupplyUsd, + externalNostroCashUsd, + }; + } + if (externalNostroCashUsd == null || !(externalNostroCashUsd > 0)) { + return { + status: 'unavailable', + ...meta, + multiplier, + toleranceBps, + headOfficeId, + totalSupplyUsd, + externalNostroCashUsd, + }; + } + + const expectedSupplyUsd = externalNostroCashUsd * multiplier; + const deltaUsd = totalSupplyUsd - expectedSupplyUsd; + const deltaBps = Math.abs(deltaUsd / expectedSupplyUsd) * 10_000; + const status: SupplyNostroMatchStatus = deltaBps <= toleranceBps ? 'matched' : 'break'; + + return { + status, + multiplier, + toleranceBps, + headOfficeId, + externalNostroGlCode: meta.externalNostroGlCode, + formula: meta.formula, + totalSupplyUsd, + externalNostroCashUsd, + expectedSupplyUsd, + deltaUsd, + deltaBps, + }; +} + +function resolveAuditStatus(input: { + fineractOk: boolean; + fineractError?: string; + cashInBankUsd: number; + tripleStateAligned?: boolean; + breaksCount: number; + tripleStateRan: boolean; + supplyNostroMatch?: SupplyNostroMatchStatus; +}): CashInBankAuditInfo { + const policy = loadNostroPolicy(); + + if (!input.fineractOk) { + return { + status: 'unavailable', + breaksCount: input.breaksCount, + tripleStateAligned: input.tripleStateAligned, + supplyNostroMatch: input.supplyNostroMatch, + policyId: policy.policyId, + policyRef: policy.policyRef, + error: input.fineractError || 'OMNL Fineract not configured', + }; + } + + if (input.supplyNostroMatch === 'break') { + return { + status: 'breaks', + breaksCount: input.breaksCount, + tripleStateAligned: input.tripleStateAligned, + supplyNostroMatch: input.supplyNostroMatch, + policyId: policy.policyId, + policyRef: policy.policyRef, + error: `Total supply USD != ${policy.supplyNostroMatchMultiplier}× external nostro (GL ${policy.externalNostroGlCode})`, + }; + } + + if (input.tripleStateRan) { + if (input.tripleStateAligned && input.cashInBankUsd > 0 && input.supplyNostroMatch === 'matched') { + return { + status: 'aligned', + breaksCount: input.breaksCount, + tripleStateAligned: true, + supplyNostroMatch: input.supplyNostroMatch, + policyId: policy.policyId, + policyRef: policy.policyRef, + }; + } + return { + status: 'breaks', + breaksCount: input.breaksCount, + tripleStateAligned: input.tripleStateAligned, + supplyNostroMatch: input.supplyNostroMatch, + policyId: policy.policyId, + policyRef: policy.policyRef, + }; + } + + if (input.cashInBankUsd > 0) { + return { + status: input.supplyNostroMatch === 'matched' ? 'aligned' : 'pending', + breaksCount: input.breaksCount, + supplyNostroMatch: input.supplyNostroMatch, + policyId: policy.policyId, + policyRef: policy.policyRef, + error: input.fineractError, + }; + } + + return { + status: 'breaks', + breaksCount: input.breaksCount, + tripleStateAligned: input.tripleStateAligned, + supplyNostroMatch: input.supplyNostroMatch, + policyId: policy.policyId, + policyRef: policy.policyRef, + error: input.fineractError || `No external nostro Dr balance on GL ${policy.externalNostroGlCode}`, + }; +} + +export function mergeCashInBankAuditWithSupplyMatch( + audit: CashInBankAuditInfo, + supplyNostroMatch: SupplyNostroMatchInfo, +): CashInBankAuditInfo { + return resolveAuditStatus({ + fineractOk: audit.status !== 'unavailable', + fineractError: audit.error, + cashInBankUsd: supplyNostroMatch.externalNostroCashUsd ?? 0, + tripleStateAligned: audit.tripleStateAligned, + breaksCount: audit.breaksCount, + tripleStateRan: audit.tripleStateAligned != null, + supplyNostroMatch: supplyNostroMatch.status, + }); +} + +export async function buildCashInBankAuditSnapshot(options?: { + officeId?: number; + skipTripleState?: boolean; + lineId?: string; + totalSupplyUsd?: number; + forceRefreshGl?: boolean; +}): Promise { + const policy = loadNostroPolicy(); + const officeId = options?.officeId + ?? Number(process.env.OMNL_CASH_IN_BANK_OFFICE_ID || process.env.OMNL_DEFAULT_OFFICE_ID || 1); + const generatedAt = new Date().toISOString(); + const configured = fineractConfigured(); + + if (!configured) { + return { + generatedAt, + officeId, + cashInBankUsd: 0, + externalNostroGlCode: policy.externalNostroGlCode, + nostroGlCodes: policy.nostroGlCodes, + legs: [], + fineractConfigured: false, + audit: resolveAuditStatus({ + fineractOk: false, + cashInBankUsd: 0, + breaksCount: 0, + tripleStateRan: false, + }), + }; + } + + let glRows: SignedGlBalanceRow[] = []; + let fineractError: string | undefined; + try { + glRows = await getCachedOfficeSignedGlBalances(officeId, { forceRefresh: options?.forceRefreshGl }); + } catch (e) { + fineractError = e instanceof Error ? e.message : String(e); + } + + const { cashInBankUsd, legs } = externalNostroCashFromGlRows(glRows, policy.externalNostroGlCode); + + const skipTripleState = + options?.skipTripleState === true || process.env.OMNL_CASH_IN_BANK_SKIP_TRIPLE_STATE === '1'; + let breaksCount = 0; + let tripleStateAligned: boolean | undefined; + let tripleStateRan = false; + + if (!skipTripleState && !fineractError) { + try { + const report = await runTripleStateReconcile(options?.lineId); + tripleStateRan = true; + breaksCount = report.breaks.length; + tripleStateAligned = report.aligned; + } catch (e) { + fineractError = fineractError || (e instanceof Error ? e.message : String(e)); + } + } + + const supplyNostroMatch = options?.totalSupplyUsd != null + ? evaluateSupplyNostroMatch({ + totalSupplyUsd: options.totalSupplyUsd, + externalNostroCashUsd: cashInBankUsd, + headOfficeId: officeId, + }) + : undefined; + + const baseAudit = resolveAuditStatus({ + fineractOk: !fineractError, + fineractError, + cashInBankUsd, + tripleStateAligned, + breaksCount, + tripleStateRan, + supplyNostroMatch: supplyNostroMatch?.status, + }); + + return { + generatedAt, + officeId, + cashInBankUsd, + externalNostroGlCode: policy.externalNostroGlCode, + nostroGlCodes: policy.nostroGlCodes, + legs, + fineractConfigured: true, + supplyNostroMatch, + audit: supplyNostroMatch + ? mergeCashInBankAuditWithSupplyMatch(baseAudit, supplyNostroMatch) + : baseAudit, + }; +} + +/** Cached HO external-nostro snapshot with OMNL/HYBX triple-state audit (default TTL 120s). */ +export async function getCachedCashInBankAuditSnapshot(options?: { + forceRefresh?: boolean; + officeId?: number; + lineId?: string; + totalSupplyUsd?: number; + skipTripleState?: boolean; +}): Promise { + const ttlMs = Number(process.env.OMNL_CASH_IN_BANK_CACHE_MS || hoLiquidityCacheMs()); + const now = Date.now(); + if ( + !options?.forceRefresh + && cache + && cache.expiresAt > now + && options?.totalSupplyUsd == null + ) { + return cache.snapshot; + } + const snapshot = await buildCashInBankAuditSnapshot({ + officeId: options?.officeId, + lineId: options?.lineId, + totalSupplyUsd: options?.totalSupplyUsd, + skipTripleState: options?.skipTripleState, + forceRefreshGl: options?.forceRefresh, + }); + if (options?.totalSupplyUsd == null) { + cache = { expiresAt: now + ttlMs, snapshot }; + } + return snapshot; +} + +/** Test helper — clears module cache between unit tests. */ +export function resetCashInBankAuditCache(): void { + cache = null; +} diff --git a/services/token-aggregation/src/services/omnl-fineract-office-gl.test.ts b/services/token-aggregation/src/services/omnl-fineract-office-gl.test.ts new file mode 100644 index 0000000..78ac3e7 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-fineract-office-gl.test.ts @@ -0,0 +1,19 @@ +import { signedGlBalances } from './omnl-fineract-office-gl'; + +describe('signedGlBalances', () => { + it('aggregates flat Fineract journal lines (Hybx format)', () => { + const glById = new Map([ + [1, { glCode: '1000', name: 'Reserve assets' }], + [14, { glCode: '13010', name: 'Nostro' }], + ]); + const rows = signedGlBalances( + [ + { glAccountId: 1, amount: 900000000000, entryType: 'DEBIT' }, + { glAccountId: 14, amount: 999998500, entryType: 'DEBIT' }, + ], + glById, + ); + expect(rows.find((r) => r.glCode === '1000')).toMatchObject({ side: 'Dr', amount: '900000000000.00' }); + expect(rows.find((r) => r.glCode === '13010')).toMatchObject({ side: 'Dr', amount: '999998500.00' }); + }); +}); diff --git a/services/token-aggregation/src/services/omnl-fineract-office-gl.ts b/services/token-aggregation/src/services/omnl-fineract-office-gl.ts new file mode 100644 index 0000000..243862c --- /dev/null +++ b/services/token-aggregation/src/services/omnl-fineract-office-gl.ts @@ -0,0 +1,228 @@ +import axios, { type AxiosRequestConfig } from 'axios'; +import { fetchFineractGlAccounts, fetchFineractGlAccountsByCodes } from './omnl-ipsas-gl'; +import { hoLiquidityCacheMs } from '../config/omnl-ho-liquidity-cache'; + +export type SignedGlBalanceRow = { + glCode: string; + name: string; + side: string; + amount: string; +}; + +export function fineractHeaders(): { base: string; headers: Record } { + const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); + const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl'; + const user = + process.env.OMNL_FINERACT_USER?.trim() || + process.env.OMNL_FINERACT_USERNAME?.trim() || + 'app.omnl'; + const pass = process.env.OMNL_FINERACT_PASSWORD || ''; + if (!base || !pass) { + throw new Error('OMNL_FINERACT_BASE_URL and OMNL_FINERACT_PASSWORD required'); + } + const auth = Buffer.from(`${user}:${pass}`).toString('base64'); + return { + base, + headers: { + Authorization: `Basic ${auth}`, + 'Fineract-Platform-TenantId': tenant, + Accept: 'application/json', + }, + }; +} + +export function fineractConfigured(): boolean { + return Boolean(process.env.OMNL_FINERACT_BASE_URL?.trim() && process.env.OMNL_FINERACT_PASSWORD); +} + +export async function fetchOfficeJournalLines(officeId: number): Promise< + Array<{ glAccountId: number; amount: number; entryType: string; transactionDate?: unknown }> +> { + const { base, headers } = fineractHeaders(); + const out: Array<{ glAccountId: number; amount: number; entryType: string; transactionDate?: unknown }> = []; + let offset = 0; + const limit = 200; + for (;;) { + const cfg: AxiosRequestConfig = { + headers, + timeout: 25000, + params: { officeId, limit, offset }, + }; + const { data } = await axios.get(`${base}/journalentries`, cfg); + const batch = Array.isArray(data) ? data : (data as { pageItems?: unknown[] })?.pageItems ?? []; + if (!Array.isArray(batch) || batch.length === 0) break; + for (const je of batch) { + const row = je as { + glAccountId?: number; + amount?: number; + entryType?: string | { value?: string; code?: string }; + transactionDate?: unknown; + debits?: Array<{ glAccountId?: number; amount?: number }>; + credits?: Array<{ glAccountId?: number; amount?: number }>; + }; + + const flatEntryType = + typeof row.entryType === 'string' + ? row.entryType + : row.entryType?.value || row.entryType?.code || ''; + + if (row.glAccountId != null && row.amount != null && flatEntryType) { + const normalized = flatEntryType.toUpperCase(); + out.push({ + glAccountId: row.glAccountId, + amount: Number(row.amount || 0), + entryType: normalized.startsWith('DEB') ? 'DEBIT' : 'CREDIT', + transactionDate: row.transactionDate, + }); + continue; + } + + for (const d of row.debits ?? []) { + if (d.glAccountId != null) { + out.push({ + glAccountId: d.glAccountId, + amount: Number(d.amount || 0), + entryType: 'DEBIT', + transactionDate: row.transactionDate, + }); + } + } + for (const c of row.credits ?? []) { + if (c.glAccountId != null) { + out.push({ + glAccountId: c.glAccountId, + amount: Number(c.amount || 0), + entryType: 'CREDIT', + transactionDate: row.transactionDate, + }); + } + } + } + if (batch.length < limit) break; + offset += limit; + } + return out; +} + +export function signedGlBalances( + lines: Array<{ glAccountId: number; amount: number; entryType: string }>, + glById: Map, +): SignedGlBalanceRow[] { + const net = new Map(); + for (const l of lines) { + const cur = net.get(l.glAccountId) ?? 0; + net.set(l.glAccountId, cur + (l.entryType === 'DEBIT' ? l.amount : -l.amount)); + } + const rows: SignedGlBalanceRow[] = []; + for (const [id, val] of net.entries()) { + if (Math.abs(val) < 0.005) continue; + const g = glById.get(id) ?? { glCode: String(id), name: '' }; + rows.push({ + glCode: g.glCode, + name: g.name, + side: val > 0 ? 'Dr' : 'Cr', + amount: Math.abs(val).toFixed(2), + }); + } + rows.sort((a, b) => a.glCode.localeCompare(b.glCode)); + return rows; +} + +/** Signed GL balances for one Fineract office (journal-derived, same method as settlement terminal). */ +export async function fetchOfficeSignedGlBalances( + officeId: number, + glCodesHint: string[] = ['1000', '1050', '13010', '2100', '2000'], +): Promise { + const lines = await fetchOfficeJournalLines(officeId); + const journalIds = new Set(lines.map((l) => l.glAccountId)); + let glRows = await fetchFineractGlAccountsByCodes(glCodesHint); + const mappedIds = new Set(glRows.map((g) => g.id)); + const missingJournalIds = [...journalIds].filter((id) => !mappedIds.has(id)); + if (missingJournalIds.length > 0) { + const fullChart = await fetchFineractGlAccounts(); + const seen = new Set(glRows.map((g) => g.id)); + for (const row of fullChart) { + if (row.id != null && !seen.has(row.id) && journalIds.has(row.id)) { + glRows.push(row); + seen.add(row.id); + } + } + } + const glById = new Map(); + for (const g of glRows) { + if (g.id != null && g.glCode) { + glById.set(g.id, { glCode: String(g.glCode), name: String(g.name || '') }); + } + } + return signedGlBalances(lines, glById); +} + +let officeGlCache: { officeId: number; expiresAt: number; rows: SignedGlBalanceRow[] } | null = null; +let officeGlInflight: Promise | null = null; + +function officeGlFetchTimeoutMs(): number { + return Number(process.env.OMNL_OFFICE_GL_FETCH_TIMEOUT_MS || 25_000); +} + +async function withOfficeGlTimeout(promise: Promise): Promise { + const timeoutMs = officeGlFetchTimeoutMs(); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Fineract office GL fetch timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** Shared office GL snapshot — dedupes concurrent Fineract journal replays (market-batch hot path). */ +export async function getCachedOfficeSignedGlBalances( + officeId: number, + options?: { forceRefresh?: boolean }, +): Promise { + const ttlMs = Number(process.env.OMNL_OFFICE_GL_CACHE_MS || hoLiquidityCacheMs()); + const now = Date.now(); + const stale = officeGlCache && officeGlCache.officeId === officeId ? officeGlCache.rows : null; + if ( + !options?.forceRefresh + && officeGlCache + && officeGlCache.officeId === officeId + && officeGlCache.expiresAt > now + ) { + return officeGlCache.rows; + } + if (officeGlInflight) { + try { + return await withOfficeGlTimeout(officeGlInflight); + } catch { + if (stale) return stale; + throw new Error('Fineract office GL balances unavailable'); + } + } + officeGlInflight = withOfficeGlTimeout(fetchOfficeSignedGlBalances(officeId)) + .then((rows) => { + officeGlCache = { officeId, expiresAt: Date.now() + ttlMs, rows }; + return rows; + }) + .finally(() => { + officeGlInflight = null; + }); + try { + return await officeGlInflight; + } catch (e) { + if (stale) return stale; + throw e instanceof Error ? e : new Error(String(e)); + } +} + +export function resetOfficeSignedGlCache(): void { + officeGlCache = null; + officeGlInflight = null; +} diff --git a/services/token-aggregation/src/services/omnl-ho-liquidity-snapshot.test.ts b/services/token-aggregation/src/services/omnl-ho-liquidity-snapshot.test.ts new file mode 100644 index 0000000..65c4fdc --- /dev/null +++ b/services/token-aggregation/src/services/omnl-ho-liquidity-snapshot.test.ts @@ -0,0 +1,58 @@ +import { evaluateHoLiquidityAlerts } from './omnl-ho-liquidity-snapshot'; + +jest.mock('./omnl-fineract-office-gl', () => ({ + fineractConfigured: jest.fn(() => true), +})); + +describe('evaluateHoLiquidityAlerts', () => { + const baseCash = { + generatedAt: new Date().toISOString(), + officeId: 1, + cashInBankUsd: 1_000_000_000, + externalNostroGlCode: '13010', + nostroGlCodes: ['13010'], + legs: [], + fineractConfigured: true, + audit: { + status: 'breaks' as const, + breaksCount: 0, + supplyNostroMatch: 'break' as const, + policyId: 'OMNL-NOSTRO-BACKED-NETTING-V1', + policyRef: 'config/compliance/omnl-nostro-backed-netting.v1.json', + }, + }; + + const baseReserve = { + status: 'sufficient' as const, + multiplier: 1.2, + headOfficeId: 1, + reserveAssetGlCodes: ['1000', '1050'], + reserveAssetsUsd: 1_150_000_000_000, + issuedCStarUsd: 380_000_000_000, + requiredReserveUsd: 456_000_000_000, + coverageRatio: 2.52, + formula: 'coverageRatio = reserveAssetsUsd / (1.2 × issuedCStarUsd)', + policyId: 'OMNL-NOSTRO-BACKED-NETTING-V1', + policyRef: 'config/compliance/omnl-nostro-backed-netting.v1.json', + generatedAt: new Date().toISOString(), + }; + + it('flags nostro supply break as info while reserve sufficient', () => { + const alerts = evaluateHoLiquidityAlerts({ + cashInBank: baseCash, + reserveCoverage: baseReserve, + generatedAt: new Date().toISOString(), + }); + expect(alerts.some((a) => a.code === 'NOSTRO_SUPPLY_BREAK' && a.severity === 'info')).toBe(true); + expect(alerts.some((a) => a.severity === 'critical')).toBe(false); + }); + + it('flags insufficient reserve coverage as critical', () => { + const alerts = evaluateHoLiquidityAlerts({ + cashInBank: baseCash, + reserveCoverage: { ...baseReserve, status: 'insufficient', coverageRatio: 0.8 }, + generatedAt: new Date().toISOString(), + }); + expect(alerts.some((a) => a.code === 'RESERVE_COVERAGE_INSUFFICIENT')).toBe(true); + }); +}); diff --git a/services/token-aggregation/src/services/omnl-ho-liquidity-snapshot.ts b/services/token-aggregation/src/services/omnl-ho-liquidity-snapshot.ts new file mode 100644 index 0000000..44078a6 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-ho-liquidity-snapshot.ts @@ -0,0 +1,133 @@ +import { getCachedHoReserveCoverageSnapshot, type HoReserveCoverageInfo } from './chain138-ho-reserve-coverage'; +import { + getCachedCashInBankAuditSnapshot, + loadNostroPolicy, + type CashInBankAuditSnapshot, +} from './omnl-cash-in-bank-audit'; +import { fineractConfigured } from './omnl-fineract-office-gl'; + +export type HoLiquidityAlertSeverity = 'critical' | 'warning' | 'info'; + +export interface HoLiquidityAlert { + code: string; + severity: HoLiquidityAlertSeverity; + message: string; +} + +export interface HoLiquiditySnapshot { + generatedAt: string; + officeId: number; + policyId: string; + policyRef: string; + fineractConfigured: boolean; + cashInBank: CashInBankAuditSnapshot; + reserveCoverage: HoReserveCoverageInfo; + alerts: HoLiquidityAlert[]; + auditMaxAgeMinutes: number; +} + +function auditMaxAgeMinutes(): number { + return Number(process.env.OMNL_HO_NOSTRO_AUDIT_MAX_AGE_MINUTES || 45); +} + +export function evaluateHoLiquidityAlerts(input: { + cashInBank: CashInBankAuditSnapshot; + reserveCoverage: HoReserveCoverageInfo; + generatedAt: string; +}): HoLiquidityAlert[] { + const alerts: HoLiquidityAlert[] = []; + const { cashInBank, reserveCoverage, generatedAt } = input; + + if (!fineractConfigured()) { + alerts.push({ + code: 'FINERACT_NOT_CONFIGURED', + severity: 'critical', + message: 'OMNL_FINERACT_BASE_URL / OMNL_FINERACT_PASSWORD not set on token-aggregation', + }); + } + + if (cashInBank.audit.status === 'unavailable' || reserveCoverage.status === 'unavailable') { + alerts.push({ + code: 'FINERACT_FETCH_UNAVAILABLE', + severity: 'critical', + message: cashInBank.audit.error || reserveCoverage.error || 'Fineract HO GL snapshot unavailable', + }); + } + + if (reserveCoverage.status === 'insufficient') { + alerts.push({ + code: 'RESERVE_COVERAGE_INSUFFICIENT', + severity: 'critical', + message: `Reserve coverage ${reserveCoverage.coverageRatio.toFixed(2)}× below 1.0× required`, + }); + } + + const ageMin = (Date.now() - new Date(generatedAt).getTime()) / 60_000; + if (ageMin > auditMaxAgeMinutes()) { + alerts.push({ + code: 'AUDIT_STALE', + severity: 'warning', + message: `Snapshot age ${ageMin.toFixed(0)}m exceeds ${auditMaxAgeMinutes()}m watchdog threshold`, + }); + } + + if (cashInBank.audit.supplyNostroMatch === 'break') { + alerts.push({ + code: 'NOSTRO_SUPPLY_BREAK', + severity: 'info', + message: 'Per-token supply vs 1.2× nostro settlement mismatch (expected for large cUSDC until nostro funded)', + }); + } + + return alerts; +} + +export async function buildHoLiquiditySnapshot(options?: { + officeId?: number; + forceRefresh?: boolean; + lineId?: string; +}): Promise { + const policy = loadNostroPolicy(); + const officeId = options?.officeId + ?? Number(process.env.OMNL_CASH_IN_BANK_OFFICE_ID || process.env.OMNL_DEFAULT_OFFICE_ID || policy.headOfficeId); + const forceRefresh = options?.forceRefresh === true; + + const [cashInBank, reserveCoverage] = await Promise.all([ + getCachedCashInBankAuditSnapshot({ + officeId, + forceRefresh, + skipTripleState: true, + lineId: options?.lineId, + }), + getCachedHoReserveCoverageSnapshot({ officeId, forceRefresh }), + ]); + + const generatedAt = new Date( + Math.max( + new Date(cashInBank.generatedAt).getTime(), + new Date(reserveCoverage.generatedAt).getTime(), + ), + ).toISOString(); + + return { + generatedAt, + officeId, + policyId: policy.policyId, + policyRef: policy.policyRef, + fineractConfigured: fineractConfigured(), + cashInBank, + reserveCoverage, + alerts: evaluateHoLiquidityAlerts({ cashInBank, reserveCoverage, generatedAt }), + auditMaxAgeMinutes: auditMaxAgeMinutes(), + }; +} + +let fileSnapshot: HoLiquiditySnapshot | null = null; + +export function loadPersistedHoLiquiditySnapshot(): HoLiquiditySnapshot | null { + return fileSnapshot; +} + +export function persistHoLiquiditySnapshot(snapshot: HoLiquiditySnapshot): void { + fileSnapshot = snapshot; +} diff --git a/services/token-aggregation/src/services/omnl-ipsas-gl.test.ts b/services/token-aggregation/src/services/omnl-ipsas-gl.test.ts new file mode 100644 index 0000000..865b177 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-ipsas-gl.test.ts @@ -0,0 +1,40 @@ +/** @jest-environment node */ +import axios from 'axios'; +import { fetchFineractGlAccounts, fetchFineractGlAccountsByCodes } from './omnl-ipsas-gl'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('fetchFineractGlAccounts', () => { + beforeEach(() => { + process.env.OMNL_FINERACT_BASE_URL = 'https://example.test/fineract-provider/api/v1'; + process.env.OMNL_FINERACT_PASSWORD = 'secret'; + process.env.OMNL_FINERACT_USER = 'app.omnl'; + mockedAxios.get.mockReset(); + }); + + it('stops after first page when Hybx returns top-level array (> limit)', async () => { + const rows = Array.from({ length: 262 }, (_, i) => ({ id: i + 1, glCode: String(1000 + i) })); + mockedAxios.get.mockResolvedValueOnce({ data: rows }); + const out = await fetchFineractGlAccounts(); + expect(out).toHaveLength(262); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + }); +}); + +describe('fetchFineractGlAccountsByCodes', () => { + beforeEach(() => { + process.env.OMNL_FINERACT_BASE_URL = 'https://example.test/fineract-provider/api/v1'; + process.env.OMNL_FINERACT_PASSWORD = 'secret'; + mockedAxios.get.mockReset(); + }); + + it('fetches each GL code separately', async () => { + mockedAxios.get + .mockResolvedValueOnce({ data: [{ id: 1, glCode: '1000', name: 'Reserve' }] }) + .mockResolvedValueOnce({ data: [{ id: 14, glCode: '13010', name: 'Nostro' }] }); + const out = await fetchFineractGlAccountsByCodes(['1000', '13010']); + expect(out).toHaveLength(2); + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + }); +}); diff --git a/services/token-aggregation/src/services/omnl-ipsas-gl.ts b/services/token-aggregation/src/services/omnl-ipsas-gl.ts index 8358292..909f550 100644 --- a/services/token-aggregation/src/services/omnl-ipsas-gl.ts +++ b/services/token-aggregation/src/services/omnl-ipsas-gl.ts @@ -118,22 +118,36 @@ export async function fetchFineractGlAccounts(): Promise 'Content-Type': 'application/json', }; const limit = parseInt(process.env.OMNL_FINERACT_GL_PAGE_LIMIT || '200', 10); + const maxPages = parseInt(process.env.OMNL_FINERACT_GL_MAX_PAGES || '25', 10); const out: FineractGlAccountRow[] = []; let offset = 0; + let pages = 0; + let previousFirstId: number | undefined; for (;;) { const url = `${base}/glaccounts?limit=${limit}&offset=${offset}`; - const cfg: AxiosRequestConfig = { headers, timeout: 120000 }; + const cfg: AxiosRequestConfig = { headers, timeout: 25000 }; const { data } = await axios.get< FineractGlAccountRow[] | { pageItems?: FineractGlAccountRow[]; totalFilteredRecords?: number } >(url, cfg); let batch: FineractGlAccountRow[] = []; if (Array.isArray(data)) { + // Hybx tenant returns the full chart as a top-level array; offset/limit are ignored. batch = data; - } else { - batch = data.pageItems ?? []; + out.push(...batch); + break; } + batch = data.pageItems ?? []; + if (batch.length === 0) { + break; + } + const firstId = batch[0]?.id; + if (offset > 0 && firstId != null && firstId === previousFirstId) { + break; + } + previousFirstId = firstId; out.push(...batch); - if (batch.length < limit) { + pages += 1; + if (batch.length < limit || pages >= maxPages) { break; } offset += limit; @@ -141,6 +155,61 @@ export async function fetchFineractGlAccounts(): Promise return out; } +function fineractAuthHeaders(): { base: string; headers: Record } { + const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); + const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl'; + const user = + process.env.OMNL_FINERACT_USER?.trim() || + process.env.OMNL_FINERACT_USERNAME?.trim() || + 'app.omnl'; + const pass = process.env.OMNL_FINERACT_PASSWORD || ''; + if (!base || !pass) { + throw new Error('OMNL_FINERACT_BASE_URL and OMNL_FINERACT_PASSWORD required for Fineract GL sync'); + } + const auth = Buffer.from(`${user}:${pass}`).toString('base64'); + return { + base, + headers: { + Authorization: `Basic ${auth}`, + 'Fineract-Platform-TenantId': tenant, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }; +} + +function parseGlAccountBatch( + data: FineractGlAccountRow[] | { pageItems?: FineractGlAccountRow[] }, +): FineractGlAccountRow[] { + if (Array.isArray(data)) return data; + return data.pageItems ?? []; +} + +/** Fetch GL accounts by code (fast path for HO nostro/reserve audit). */ +export async function fetchFineractGlAccountsByCodes(glCodes: string[]): Promise { + const unique = [...new Set(glCodes.map((c) => c.trim()).filter(Boolean))]; + if (unique.length === 0) return []; + const { base, headers } = fineractAuthHeaders(); + const out: FineractGlAccountRow[] = []; + const seen = new Set(); + for (const glCode of unique) { + const { data } = await axios.get< + FineractGlAccountRow[] | { pageItems?: FineractGlAccountRow[] } + >(`${base}/glaccounts`, { + headers, + timeout: 25000, + params: { glCode, limit: 50 }, + }); + for (const row of parseGlAccountBatch(data)) { + if (row.id != null && !seen.has(row.id)) { + out.push(row); + seen.add(row.id); + } + } + } + return out; +} + export function compareRegistryToFineract( registry: IpsasGlRegistry, fineractRows: FineractGlAccountRow[] diff --git a/services/token-aggregation/src/services/omnl-settlement-terminal.ts b/services/token-aggregation/src/services/omnl-settlement-terminal.ts index 4115c7b..d84b919 100644 --- a/services/token-aggregation/src/services/omnl-settlement-terminal.ts +++ b/services/token-aggregation/src/services/omnl-settlement-terminal.ts @@ -1,8 +1,13 @@ import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { createHash, randomUUID } from 'crypto'; -import axios, { type AxiosRequestConfig } from 'axios'; +import axios from 'axios'; import { fetchFineractGlAccounts } from './omnl-ipsas-gl'; +import { + fineractHeaders, + fetchOfficeJournalLines, + signedGlBalances, +} from './omnl-fineract-office-gl'; import { alchemyConfigured } from './omnl-alchemy-client'; import { loadTerminalConnection, @@ -107,28 +112,6 @@ export function loadTerminalConfig(): TerminalConfig { return JSON.parse(readFileSync(p, 'utf8')) as TerminalConfig; } -function fineractHeaders(): { base: string; headers: Record } { - const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); - const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl'; - const user = - process.env.OMNL_FINERACT_USER?.trim() || - process.env.OMNL_FINERACT_USERNAME?.trim() || - 'app.omnl'; - const pass = process.env.OMNL_FINERACT_PASSWORD || ''; - if (!base || !pass) { - throw new Error('OMNL_FINERACT_BASE_URL and OMNL_FINERACT_PASSWORD required'); - } - const auth = Buffer.from(`${user}:${pass}`).toString('base64'); - return { - base, - headers: { - Authorization: `Basic ${auth}`, - 'Fineract-Platform-TenantId': tenant, - Accept: 'application/json', - }, - }; -} - async function fetchOffices(): Promise> { const { base, headers } = fineractHeaders(); const { data } = await axios.get(`${base}/offices`, { headers, timeout: 60000 }); @@ -139,79 +122,6 @@ async function fetchOffices(): Promise> { })); } -async function fetchOfficeJournalLines(officeId: number): Promise< - Array<{ glAccountId: number; amount: number; entryType: string; transactionDate?: unknown }> -> { - const { base, headers } = fineractHeaders(); - const out: Array<{ glAccountId: number; amount: number; entryType: string; transactionDate?: unknown }> = []; - let offset = 0; - const limit = 200; - for (;;) { - const cfg: AxiosRequestConfig = { - headers, - timeout: 120000, - params: { officeId, limit, offset }, - }; - const { data } = await axios.get(`${base}/journalentries`, cfg); - const batch = Array.isArray(data) ? data : (data as { pageItems?: unknown[] })?.pageItems ?? []; - if (!Array.isArray(batch) || batch.length === 0) break; - for (const je of batch) { - const row = je as { - debits?: Array<{ glAccountId?: number; amount?: number }>; - credits?: Array<{ glAccountId?: number; amount?: number }>; - transactionDate?: unknown; - }; - for (const d of row.debits ?? []) { - if (d.glAccountId != null) { - out.push({ - glAccountId: d.glAccountId, - amount: Number(d.amount || 0), - entryType: 'DEBIT', - transactionDate: row.transactionDate, - }); - } - } - for (const c of row.credits ?? []) { - if (c.glAccountId != null) { - out.push({ - glAccountId: c.glAccountId, - amount: Number(c.amount || 0), - entryType: 'CREDIT', - transactionDate: row.transactionDate, - }); - } - } - } - if (batch.length < limit) break; - offset += limit; - } - return out; -} - -function signedGlBalances( - lines: Array<{ glAccountId: number; amount: number; entryType: string }>, - glById: Map -): Array<{ glCode: string; name: string; side: string; amount: string }> { - const net = new Map(); - for (const l of lines) { - const cur = net.get(l.glAccountId) ?? 0; - net.set(l.glAccountId, cur + (l.entryType === 'DEBIT' ? l.amount : -l.amount)); - } - const rows: Array<{ glCode: string; name: string; side: string; amount: string }> = []; - for (const [id, val] of net.entries()) { - if (Math.abs(val) < 0.005) continue; - const g = glById.get(id) ?? { glCode: String(id), name: '' }; - rows.push({ - glCode: g.glCode, - name: g.name, - side: val > 0 ? 'Dr' : 'Cr', - amount: Math.abs(val).toFixed(2), - }); - } - rows.sort((a, b) => a.glCode.localeCompare(b.glCode)); - return rows; -} - function glCreditAmount(rows: Array<{ glCode: string; side: string; amount: string }>, code: string): string | null { for (const r of rows) { if (r.glCode === code) { diff --git a/services/token-aggregation/src/services/token-cash-liquidity.test.ts b/services/token-aggregation/src/services/token-cash-liquidity.test.ts new file mode 100644 index 0000000..d4028b2 --- /dev/null +++ b/services/token-aggregation/src/services/token-cash-liquidity.test.ts @@ -0,0 +1,60 @@ +import { cashLiquidityUsdFromPool } from './token-cash-liquidity'; + +describe('cashLiquidityUsdFromPool', () => { + it('values only USD cash legs in a stable-stable canonical pool', () => { + const total = cashLiquidityUsdFromPool({ + chainId: 138, + poolAddress: '0x9e89bae009adf128782e19e8341996c596ac40dc', + token0Address: '0x93e66202a11b1772e55407b32b44e5cd8eda7f22', + token1Address: '0xf22258f57794cc8e06237084b353ab30fffa640b', + dexType: 'dodo', + reserve0: '1783887197998', + reserve1: '216112797998', + reserve0Usd: 0, + reserve1Usd: 0, + totalLiquidityUsd: 0, + volume24h: 0, + lastUpdated: new Date(), + }); + + expect(total).toBeCloseTo(2_000_000, 0); + }); + + it('excludes non-cash legs such as cBTC from cash liquidity', () => { + const total = cashLiquidityUsdFromPool({ + chainId: 138, + poolAddress: '0x72f1a0794153c3b8a1e8a731f1d8e1a52cb10dc5', + token0Address: '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e', + token1Address: '0xf22258f57794cc8e06237084b353ab30fffa640b', + dexType: 'dodo', + reserve0: '10000000000', + reserve1: '9000000000000', + reserve0Usd: 0, + reserve1Usd: 0, + totalLiquidityUsd: 0, + volume24h: 0, + lastUpdated: new Date(), + }); + + expect(total).toBeCloseTo(9_000_000, 0); + }); + + it('returns zero for non-Chain-138 pools', () => { + expect( + cashLiquidityUsdFromPool({ + chainId: 1, + poolAddress: '0x0000000000000000000000000000000000000001', + token0Address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + token1Address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + dexType: 'dodo', + reserve0: '1000000', + reserve1: '1000000', + reserve0Usd: 1, + reserve1Usd: 1, + totalLiquidityUsd: 2, + volume24h: 0, + lastUpdated: new Date(), + }), + ).toBe(0); + }); +}); diff --git a/services/token-aggregation/src/services/token-cash-liquidity.ts b/services/token-aggregation/src/services/token-cash-liquidity.ts new file mode 100644 index 0000000..6ddc260 --- /dev/null +++ b/services/token-aggregation/src/services/token-cash-liquidity.ts @@ -0,0 +1,67 @@ +import { formatUnits } from 'ethers'; +import { isChain138CanonicalLivePool } from '../config/chain138-live-dodo-pools'; +import { isChain138CashUsdTokenAddress } from '../config/chain138-cash-tokens'; +import { getCanonicalTokenByAddress } from '../config/canonical-tokens'; +import type { LiquidityPool } from '../database/repositories/pool-repo'; +import { getCanonicalPriceUsd } from './canonical-price-oracle'; +import { collectVisiblePools } from './token-visible-liquidity'; + +const CHAIN_138 = 138; + +function normalizeAddress(value: string): string { + return value.trim().toLowerCase(); +} + +function decimalsForAddress(address: string): number { + const spec = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address)); + return Number(spec?.decimals ?? 18); +} + +function parseReserveAmountForToken(raw: string | undefined, tokenAddress: string): number { + if (!raw || raw.trim() === '') return 0; + try { + const value = BigInt(raw); + if (value <= 0n) return 0; + const parsed = Number(formatUnits(value, decimalsForAddress(tokenAddress))); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + } catch { + return 0; + } +} + +/** USD value of cash-token legs only (policy peg), excluding cBTC/WETH/XAU pool sides. */ +export function cashLiquidityUsdFromPool(pool: LiquidityPool): number { + if (pool.chainId !== CHAIN_138) return 0; + + let total = 0; + const legs = [ + { address: pool.token0Address, reserve: pool.reserve0 }, + { address: pool.token1Address, reserve: pool.reserve1 }, + ]; + + for (const leg of legs) { + const address = normalizeAddress(leg.address); + if (!isChain138CashUsdTokenAddress(address)) continue; + const amount = parseReserveAmountForToken(leg.reserve, address); + if (amount <= 0) continue; + const priceUsd = getCanonicalPriceUsd(CHAIN_138, address) ?? 1; + total += amount * priceUsd; + } + + return total; +} + +/** + * Total USD cash sitting in canonical Stack A PMM pools that include this token. + * Counts only cUSDC/cUSDT/USDC/USDT mirror legs at the GRU cash peg — not full DEX pool TVL. + */ +export async function resolveTokenCashLiquidityUsd(chainId: number, address: string): Promise { + if (chainId !== CHAIN_138) return 0; + + const normalized = normalizeAddress(address); + const pools = await collectVisiblePools(chainId, normalized); + const canonicalPools = pools.filter((pool) => isChain138CanonicalLivePool(pool.poolAddress)); + const scopedPools = canonicalPools.length > 0 ? canonicalPools : pools; + + return scopedPools.reduce((sum, pool) => sum + cashLiquidityUsdFromPool(pool), 0); +} diff --git a/services/token-aggregation/src/services/token-visible-liquidity.ts b/services/token-aggregation/src/services/token-visible-liquidity.ts index 5783e2a..ad009a7 100644 --- a/services/token-aggregation/src/services/token-visible-liquidity.ts +++ b/services/token-aggregation/src/services/token-visible-liquidity.ts @@ -81,7 +81,7 @@ function mergePoolsByAddress(existing: Map, pools: Liquid } } -async function collectVisiblePools(chainId: number, address: string): Promise { +export async function collectVisiblePools(chainId: number, address: string): Promise { const normalized = address.toLowerCase(); const resolution = resolveCanonicalQuoteAddress(chainId, normalized); const merged = new Map();