Add HO nostro cash-in-bank and reserve coverage to token-aggregation.
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m59s
CI/CD Pipeline / Security Scanning (push) Successful in 2m54s
CI/CD Pipeline / Lint and Format (push) Failing after 45s
CI/CD Pipeline / Terraform Validation (push) Failing after 28s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 28s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 31s
Validation / validate-genesis (push) Successful in 34s
Validation / validate-terraform (push) Failing after 26s
Validation / validate-kubernetes (push) Failing after 12s
Validation / validate-smart-contracts (push) Failing after 11s
Validation / validate-security (push) Failing after 1m25s
Validation / validate-documentation (push) Failing after 19s
Verify Deployment / Verify Deployment (push) Failing after 1m30s

Expose Fineract GL 13010 nostro cash, aggregate c* reserve coverage, PMM cash liquidity, and a public ho-liquidity-snapshot API for the explorer three-line stack and 30-minute audit collector.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-06-24 01:24:38 -07:00
parent 6ba33a4d3f
commit 73d785cdfc
22 changed files with 1795 additions and 106 deletions

View File

@@ -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;

View File

@@ -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));

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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<string> {
const addresses = new Set<string>();
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());
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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<HoReserveCoverageInfo> | 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<HoReserveCoverageInfo> {
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<HoReserveCoverageInfo> {
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;
}

View File

@@ -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();
});
});

View File

@@ -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<CashInBankAuditSnapshot> {
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<CashInBankAuditSnapshot> {
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;
}

View File

@@ -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' });
});
});

View File

@@ -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<string, string> } {
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<number, { glCode: string; name: string }>,
): SignedGlBalanceRow[] {
const net = new Map<number, number>();
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<SignedGlBalanceRow[]> {
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<number, { glCode: string; name: string }>();
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<SignedGlBalanceRow[]> | null = null;
function officeGlFetchTimeoutMs(): number {
return Number(process.env.OMNL_OFFICE_GL_FETCH_TIMEOUT_MS || 25_000);
}
async function withOfficeGlTimeout<T>(promise: Promise<T>): Promise<T> {
const timeoutMs = officeGlFetchTimeoutMs();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, 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<SignedGlBalanceRow[]> {
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;
}

View File

@@ -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);
});
});

View File

@@ -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<HoLiquiditySnapshot> {
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;
}

View File

@@ -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<typeof axios>;
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);
});
});

View File

@@ -118,22 +118,36 @@ export async function fetchFineractGlAccounts(): Promise<FineractGlAccountRow[]>
'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<FineractGlAccountRow[]>
return out;
}
function fineractAuthHeaders(): { base: string; headers: Record<string, string> } {
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<FineractGlAccountRow[]> {
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<number>();
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[]

View File

@@ -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<string, string> } {
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<Array<{ id: number; name: string }>> {
const { base, headers } = fineractHeaders();
const { data } = await axios.get(`${base}/offices`, { headers, timeout: 60000 });
@@ -139,79 +122,6 @@ async function fetchOffices(): Promise<Array<{ id: number; name: string }>> {
}));
}
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<number, { glCode: string; name: string }>
): Array<{ glCode: string; name: string; side: string; amount: string }> {
const net = new Map<number, number>();
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) {

View File

@@ -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);
});
});

View File

@@ -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<number> {
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);
}

View File

@@ -81,7 +81,7 @@ function mergePoolsByAddress(existing: Map<string, LiquidityPool>, pools: Liquid
}
}
async function collectVisiblePools(chainId: number, address: string): Promise<LiquidityPool[]> {
export async function collectVisiblePools(chainId: number, address: string): Promise<LiquidityPool[]> {
const normalized = address.toLowerCase();
const resolution = resolveCanonicalQuoteAddress(chainId, normalized);
const merged = new Map<string, LiquidityPool>();