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
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:
@@ -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;
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user