feat(settlement): production BTC L1 rails, hot cBTC liquidity, Dfns/Cobo smoke
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m3s
CI/CD Pipeline / Security Scanning (push) Successful in 2m58s
CI/CD Pipeline / Lint and Format (push) Failing after 46s
CI/CD Pipeline / Terraform Validation (push) Failing after 26s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 29s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 41s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 22s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 24s
Validation / validate-genesis (push) Successful in 32s
Validation / validate-terraform (push) Failing after 28s
Validation / validate-kubernetes (push) Failing after 13s
Validation / validate-smart-contracts (push) Failing after 12s
Validation / validate-security (push) Failing after 1m31s
Validation / validate-documentation (push) Failing after 23s
Verify Deployment / Verify Deployment (push) Failing after 1m4s
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m3s
CI/CD Pipeline / Security Scanning (push) Successful in 2m58s
CI/CD Pipeline / Lint and Format (push) Failing after 46s
CI/CD Pipeline / Terraform Validation (push) Failing after 26s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 29s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 41s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 22s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 24s
Validation / validate-genesis (push) Successful in 32s
Validation / validate-terraform (push) Failing after 28s
Validation / validate-kubernetes (push) Failing after 13s
Validation / validate-smart-contracts (push) Failing after 12s
Validation / validate-security (push) Failing after 1m31s
Validation / validate-documentation (push) Failing after 23s
Verify Deployment / Verify Deployment (push) Failing after 1m4s
Add BTC L1 settle/reconcile/ledger APIs, bitcoind intake, cBTC PMM hot-LP scripts, and custody credential smoke tests (secrets stay gitignored). Enables full-prod local green health and server pull-deploy for secure.omdnl.org /btc/*. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,11 @@ import {
|
||||
executeM2TokenMint,
|
||||
chainRailConfigured,
|
||||
} from '../../services/omnl-settlement-chain';
|
||||
import {
|
||||
convertFiatLpToLiquidity,
|
||||
readFiatLpPositions,
|
||||
} from '../../services/omnl-fiat-lp-liquidity';
|
||||
import { loadFiatLpParityConfig } from '../../config/fiat-lp-liquidity';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -522,4 +527,72 @@ router.post('/omnl/settlement/token-transfer', async (req: Request, res: Respons
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /omnl/settlement/fiat-lp-positions — read c* LP balances + quoted real-fund value.
|
||||
*/
|
||||
router.get('/omnl/settlement/fiat-lp-positions', async (req: Request, res: Response) => {
|
||||
const holder = String(req.query.holder ?? '').trim();
|
||||
if (!holder.startsWith('0x')) {
|
||||
res.status(400).json({ error: 'holder query param (0x address) required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const positions = await readFiatLpPositions(holder);
|
||||
const cfg = loadFiatLpParityConfig();
|
||||
res.json({
|
||||
chainId: cfg.chainId,
|
||||
holder,
|
||||
contracts: cfg.contracts,
|
||||
positions,
|
||||
chainRailConfigured: chainRailConfigured(),
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /omnl/settlement/fiat-lp-to-liquidity — swap c* LP tokens → real USDT/USDC/WETH on Chain 138.
|
||||
*/
|
||||
router.post('/omnl/settlement/fiat-lp-to-liquidity', async (req: Request, res: Response) => {
|
||||
const body = req.body as {
|
||||
holderAddress?: string;
|
||||
recipientAddress?: string;
|
||||
symbol?: string;
|
||||
amount?: string;
|
||||
convertAll?: boolean;
|
||||
maxSlippageBps?: number;
|
||||
dryRun?: boolean;
|
||||
settlementRef?: string;
|
||||
};
|
||||
if (!body.holderAddress?.startsWith('0x')) {
|
||||
res.status(400).json({ error: 'holderAddress (0x) required' });
|
||||
return;
|
||||
}
|
||||
const execute =
|
||||
body.dryRun === false &&
|
||||
(process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1' ||
|
||||
process.env.OMNL_ALLOW_CHAIN_MINT_EXECUTE === '1');
|
||||
const dryRun = body.dryRun !== false && !execute;
|
||||
|
||||
try {
|
||||
const result = await convertFiatLpToLiquidity({
|
||||
holderAddress: body.holderAddress,
|
||||
recipientAddress: body.recipientAddress,
|
||||
symbol: body.symbol,
|
||||
amount: body.amount,
|
||||
convertAll: Boolean(body.convertAll),
|
||||
maxSlippageBps: body.maxSlippageBps,
|
||||
dryRun,
|
||||
settlementRef: body.settlementRef ?? `FLP-${Date.now()}`,
|
||||
});
|
||||
const failed = result.swaps.some((s) => s.status === 'FAILED');
|
||||
res.status(failed && !dryRun ? 422 : 200).json(result);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(502).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -195,6 +195,7 @@ export class ApiServer {
|
||||
const reserveDashboardPath = path.join(__dirname, '../../public/reserve-dashboard.html');
|
||||
const reserveInstitutionalPath = path.join(__dirname, '../../public/reserve-institutional.html');
|
||||
const gruVaultControlsPath = path.join(__dirname, '../../public/gru-vault-controls.html');
|
||||
const cbtcLandingPath = path.join(__dirname, '../../public/cbtc-landing.html');
|
||||
|
||||
const authorizeOmnlHtml = (req: Request, res: Response): boolean => {
|
||||
const tok = process.env.OMNL_DASHBOARD_TOKEN?.trim();
|
||||
@@ -263,6 +264,17 @@ export class ApiServer {
|
||||
res.type('html').send(readFileSync(gruVaultControlsPath, 'utf8'));
|
||||
});
|
||||
|
||||
/** Where the 1000 cBTC mint landed (wallets + PMM pools). */
|
||||
const sendCbtcLanding = (_req: Request, res: Response) => {
|
||||
if (!existsSync(cbtcLandingPath)) {
|
||||
res.status(404).type('text/plain').send('cbtc-landing.html missing');
|
||||
return;
|
||||
}
|
||||
res.type('html').send(readFileSync(cbtcLandingPath, 'utf8'));
|
||||
};
|
||||
this.app.get('/cbtc', sendCbtcLanding);
|
||||
this.app.get('/cbtc/landing', sendCbtcLanding);
|
||||
|
||||
// Public API catalog (register before routers so GET /api/v1 is not swallowed by middleware-only mounts)
|
||||
const sendApiV1Catalog = (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
|
||||
53
services/token-aggregation/src/config/fiat-lp-liquidity.ts
Normal file
53
services/token-aggregation/src/config/fiat-lp-liquidity.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export type FiatLpParityConfig = {
|
||||
version: string;
|
||||
chainId: number;
|
||||
mainnetParityChainId: number;
|
||||
contracts: {
|
||||
enhancedSwapRouterV2: string;
|
||||
officialUsdt: string;
|
||||
officialUsdc: string;
|
||||
officialWeth: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
defaultSlippageBps: number;
|
||||
minSwapAmountRaw: string;
|
||||
lpPrefix: string;
|
||||
realTargets: Record<string, string>;
|
||||
explicitLpToReal: Array<{ lpSymbol: string; realSymbol: string }>;
|
||||
};
|
||||
|
||||
let cached: FiatLpParityConfig | null = null;
|
||||
|
||||
export function loadFiatLpParityConfig(): FiatLpParityConfig {
|
||||
if (cached) return cached;
|
||||
const configPath =
|
||||
process.env.CHAIN138_FIAT_LP_CONFIG ||
|
||||
path.resolve(__dirname, '../../../../config/chain138-fiat-lp-liquidity.v1.json');
|
||||
cached = JSON.parse(fs.readFileSync(configPath, 'utf8')) as FiatLpParityConfig;
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function realTokenAddress(realSymbol: string, cfg = loadFiatLpParityConfig()): string {
|
||||
const sym = realSymbol.toUpperCase();
|
||||
if (sym === 'USDT') return cfg.contracts.officialUsdt;
|
||||
if (sym === 'USDC') return cfg.contracts.officialUsdc;
|
||||
if (sym === 'WETH' || sym === 'ETH') return cfg.contracts.officialWeth;
|
||||
return cfg.contracts.officialUsdt;
|
||||
}
|
||||
|
||||
export function resolveRealSymbolForLp(lpSymbol: string, currencyCode?: string): string {
|
||||
const cfg = loadFiatLpParityConfig();
|
||||
const explicit = cfg.explicitLpToReal.find((e) => e.lpSymbol === lpSymbol);
|
||||
if (explicit) return explicit.realSymbol;
|
||||
if (currencyCode && cfg.realTargets[currencyCode]) {
|
||||
return cfg.realTargets[currencyCode];
|
||||
}
|
||||
if (lpSymbol.toUpperCase().includes('USDC')) return 'USDC';
|
||||
if (lpSymbol.toUpperCase().includes('ETH') || lpSymbol.toUpperCase().includes('BTC')) {
|
||||
return 'WETH';
|
||||
}
|
||||
return 'USDT';
|
||||
}
|
||||
@@ -219,6 +219,17 @@ export function resolveCanonicalPriceUsdForSpec(spec: CanonicalTokenSpec): Canon
|
||||
};
|
||||
}
|
||||
|
||||
const requireLiveBtc =
|
||||
referenceSymbol === 'BTC' &&
|
||||
(process.env.OMNL_REQUIRE_LIVE_BTC_PRICE === '1' ||
|
||||
(process.env.NODE_ENV === 'production' && process.env.OMNL_REQUIRE_LIVE_BTC_PRICE !== '0'));
|
||||
if (requireLiveBtc) {
|
||||
return {
|
||||
referenceSymbol,
|
||||
source: 'unresolved',
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = REPO_FALLBACK_PRICE_USD[referenceSymbol];
|
||||
if (fallback !== undefined) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { formatUnits, parseUnits } from 'ethers';
|
||||
import {
|
||||
loadFiatLpParityConfig,
|
||||
realTokenAddress,
|
||||
resolveRealSymbolForLp,
|
||||
} from '../config/fiat-lp-liquidity';
|
||||
import { BestExecutionPlanner } from './best-execution-planner';
|
||||
import { InternalExecutionPlanV2Builder } from './internal-execution-plan-v2';
|
||||
import {
|
||||
chainRailConfigured,
|
||||
executeRouterSwap,
|
||||
readErc20Balance,
|
||||
} from './omnl-settlement-chain';
|
||||
|
||||
const CHAIN_ID = 138;
|
||||
|
||||
type M2RegistryToken = {
|
||||
symbol: string;
|
||||
address: string;
|
||||
decimals?: number;
|
||||
currencyCode?: string;
|
||||
omnlLine?: string;
|
||||
swappable?: boolean;
|
||||
};
|
||||
|
||||
type M2TokenRegistryFile = {
|
||||
tokens: M2RegistryToken[];
|
||||
};
|
||||
|
||||
export type LpPosition = {
|
||||
symbol: string;
|
||||
tokenAddress: string;
|
||||
decimals: number;
|
||||
balanceRaw: string;
|
||||
balanceFormatted: string;
|
||||
realSymbol: string;
|
||||
realTokenAddress: string;
|
||||
amountOutQuoted?: string;
|
||||
swappable: boolean;
|
||||
};
|
||||
|
||||
export type FiatLpConversionResult = {
|
||||
settlementRef: string;
|
||||
holder: string;
|
||||
recipient: string;
|
||||
dryRun: boolean;
|
||||
positions: LpPosition[];
|
||||
swaps: Array<{
|
||||
lpSymbol: string;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
realSymbol: string;
|
||||
amountIn: string;
|
||||
amountOutQuoted?: string;
|
||||
txHash?: string;
|
||||
status: 'DRY_RUN' | 'QUOTED' | 'SUBMITTED' | 'SETTLED' | 'SKIPPED' | 'FAILED';
|
||||
error?: string;
|
||||
}>;
|
||||
chainRailConfigured: boolean;
|
||||
};
|
||||
|
||||
function loadM2Registry(): M2TokenRegistryFile {
|
||||
const registryPath =
|
||||
process.env.OMNL_M2_TOKEN_REGISTRY ||
|
||||
path.resolve(__dirname, '../../../../config/omnl-m2-token-registry.v1.json');
|
||||
return JSON.parse(fs.readFileSync(registryPath, 'utf8')) as M2TokenRegistryFile;
|
||||
}
|
||||
|
||||
function isLpToken(symbol: string, cfg = loadFiatLpParityConfig()): boolean {
|
||||
return symbol.startsWith(cfg.lpPrefix) && symbol.length > cfg.lpPrefix.length;
|
||||
}
|
||||
|
||||
async function quoteAmountOut(
|
||||
tokenIn: string,
|
||||
tokenOut: string,
|
||||
amountInRaw: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const planner = new BestExecutionPlanner();
|
||||
const result = await planner.plan({
|
||||
sourceChainId: CHAIN_ID,
|
||||
destinationChainId: CHAIN_ID,
|
||||
tokenIn,
|
||||
tokenOut,
|
||||
amountIn: amountInRaw,
|
||||
recipient: '0x0000000000000000000000000000000000000001',
|
||||
constraints: { allowBridge: false, maxSlippageBps: 50 },
|
||||
});
|
||||
const raw = result.estimatedAmountOut ?? result.minAmountOut;
|
||||
return raw ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read all fiat LP (c*) balances for a holder with quoted real-fund value. */
|
||||
export async function readFiatLpPositions(holderAddress: string): Promise<LpPosition[]> {
|
||||
const registry = loadM2Registry();
|
||||
const cfg = loadFiatLpParityConfig();
|
||||
const minRaw = BigInt(cfg.minSwapAmountRaw);
|
||||
const positions: LpPosition[] = [];
|
||||
|
||||
for (const token of registry.tokens) {
|
||||
if (!isLpToken(token.symbol)) continue;
|
||||
if (!token.address.startsWith('0x') || token.address === '0x0000000000000000000000000000000000000000') {
|
||||
continue;
|
||||
}
|
||||
let balanceRaw = 0n;
|
||||
try {
|
||||
balanceRaw = await readErc20Balance({ tokenAddress: token.address, holder: holderAddress });
|
||||
} catch {
|
||||
balanceRaw = 0n;
|
||||
}
|
||||
|
||||
const realSymbol = resolveRealSymbolForLp(token.symbol, token.currencyCode);
|
||||
const realAddr = realTokenAddress(realSymbol);
|
||||
const balanceFormatted = formatUnits(balanceRaw, token.decimals ?? 18);
|
||||
|
||||
let amountOutQuoted: string | undefined;
|
||||
if (balanceRaw >= minRaw && token.address.toLowerCase() !== realAddr.toLowerCase()) {
|
||||
const quoted = await quoteAmountOut(token.address, realAddr, balanceRaw.toString());
|
||||
if (quoted) {
|
||||
const realDecimals = realSymbol === 'WETH' ? 18 : 6;
|
||||
amountOutQuoted = formatUnits(quoted, realDecimals);
|
||||
}
|
||||
}
|
||||
|
||||
positions.push({
|
||||
symbol: token.symbol,
|
||||
tokenAddress: token.address,
|
||||
decimals: token.decimals ?? 18,
|
||||
balanceRaw: balanceRaw.toString(),
|
||||
balanceFormatted,
|
||||
realSymbol,
|
||||
realTokenAddress: realAddr,
|
||||
amountOutQuoted,
|
||||
swappable: Boolean(token.swappable && balanceRaw >= minRaw),
|
||||
});
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
/** Convert one or all LP tokens to real USDT/USDC/WETH on Chain 138. */
|
||||
export async function convertFiatLpToLiquidity(params: {
|
||||
holderAddress: string;
|
||||
recipientAddress?: string;
|
||||
symbol?: string;
|
||||
amount?: string;
|
||||
convertAll?: boolean;
|
||||
maxSlippageBps?: number;
|
||||
dryRun?: boolean;
|
||||
settlementRef: string;
|
||||
}): Promise<FiatLpConversionResult> {
|
||||
const cfg = loadFiatLpParityConfig();
|
||||
const registry = loadM2Registry();
|
||||
const holder = params.holderAddress.trim();
|
||||
const recipient = (params.recipientAddress ?? holder).trim();
|
||||
const dryRun = params.dryRun !== false && params.dryRun !== undefined ? params.dryRun : !chainRailConfigured();
|
||||
const slippage = params.maxSlippageBps ?? cfg.defaultSlippageBps;
|
||||
const minRaw = BigInt(cfg.minSwapAmountRaw);
|
||||
const planner = new InternalExecutionPlanV2Builder();
|
||||
|
||||
const positions = await readFiatLpPositions(holder);
|
||||
const swaps: FiatLpConversionResult['swaps'] = [];
|
||||
|
||||
const targets = params.convertAll
|
||||
? positions.filter((p) => p.swappable)
|
||||
: positions.filter((p) => {
|
||||
if (!params.symbol) return false;
|
||||
return p.symbol.toLowerCase() === params.symbol.toLowerCase() && p.swappable;
|
||||
});
|
||||
|
||||
if (!params.convertAll && params.symbol && targets.length === 0) {
|
||||
const bySym = registry.tokens.find((t) => t.symbol.toLowerCase() === params.symbol!.toLowerCase());
|
||||
if (!bySym) {
|
||||
swaps.push({
|
||||
lpSymbol: params.symbol,
|
||||
tokenIn: '',
|
||||
tokenOut: '',
|
||||
realSymbol: '',
|
||||
amountIn: params.amount ?? '0',
|
||||
status: 'FAILED',
|
||||
error: `Unknown LP symbol ${params.symbol}`,
|
||||
});
|
||||
} else {
|
||||
swaps.push({
|
||||
lpSymbol: bySym.symbol,
|
||||
tokenIn: bySym.address,
|
||||
tokenOut: realTokenAddress(resolveRealSymbolForLp(bySym.symbol, bySym.currencyCode)),
|
||||
realSymbol: resolveRealSymbolForLp(bySym.symbol, bySym.currencyCode),
|
||||
amountIn: params.amount ?? '0',
|
||||
status: 'SKIPPED',
|
||||
error: 'Balance below minimum or token not swappable',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const pos of targets) {
|
||||
let amountInRaw = BigInt(pos.balanceRaw);
|
||||
if (params.amount && !params.convertAll) {
|
||||
try {
|
||||
amountInRaw = parseUnits(params.amount, pos.decimals);
|
||||
} catch {
|
||||
swaps.push({
|
||||
lpSymbol: pos.symbol,
|
||||
tokenIn: pos.tokenAddress,
|
||||
tokenOut: pos.realTokenAddress,
|
||||
realSymbol: pos.realSymbol,
|
||||
amountIn: params.amount,
|
||||
status: 'FAILED',
|
||||
error: 'Invalid amount',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (amountInRaw < minRaw) {
|
||||
swaps.push({
|
||||
lpSymbol: pos.symbol,
|
||||
tokenIn: pos.tokenAddress,
|
||||
tokenOut: pos.realTokenAddress,
|
||||
realSymbol: pos.realSymbol,
|
||||
amountIn: amountInRaw.toString(),
|
||||
status: 'SKIPPED',
|
||||
error: 'Below minimum swap amount',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pos.tokenAddress.toLowerCase() === pos.realTokenAddress.toLowerCase()) {
|
||||
swaps.push({
|
||||
lpSymbol: pos.symbol,
|
||||
tokenIn: pos.tokenAddress,
|
||||
tokenOut: pos.realTokenAddress,
|
||||
realSymbol: pos.realSymbol,
|
||||
amountIn: amountInRaw.toString(),
|
||||
amountOutQuoted: pos.balanceFormatted,
|
||||
status: 'SKIPPED',
|
||||
error: 'Already real fund token',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const amountOutQuoted = await quoteAmountOut(
|
||||
pos.tokenAddress,
|
||||
pos.realTokenAddress,
|
||||
amountInRaw.toString(),
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
swaps.push({
|
||||
lpSymbol: pos.symbol,
|
||||
tokenIn: pos.tokenAddress,
|
||||
tokenOut: pos.realTokenAddress,
|
||||
realSymbol: pos.realSymbol,
|
||||
amountIn: amountInRaw.toString(),
|
||||
amountOutQuoted: amountOutQuoted
|
||||
? formatUnits(amountOutQuoted, pos.realSymbol === 'WETH' ? 18 : 6)
|
||||
: undefined,
|
||||
status: 'DRY_RUN',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const plan = await planner.build({
|
||||
sourceChainId: CHAIN_ID,
|
||||
destinationChainId: CHAIN_ID,
|
||||
tokenIn: pos.tokenAddress,
|
||||
tokenOut: pos.realTokenAddress,
|
||||
amountIn: amountInRaw.toString(),
|
||||
recipient,
|
||||
constraints: { maxSlippageBps: slippage, allowBridge: false },
|
||||
});
|
||||
|
||||
if (!plan.execution?.encodedCalldata) {
|
||||
swaps.push({
|
||||
lpSymbol: pos.symbol,
|
||||
tokenIn: pos.tokenAddress,
|
||||
tokenOut: pos.realTokenAddress,
|
||||
realSymbol: pos.realSymbol,
|
||||
amountIn: amountInRaw.toString(),
|
||||
amountOutQuoted: amountOutQuoted
|
||||
? formatUnits(amountOutQuoted, pos.realSymbol === 'WETH' ? 18 : 6)
|
||||
: undefined,
|
||||
status: 'FAILED',
|
||||
error: plan.error ?? 'No executable swap route',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { txHash } = await executeRouterSwap({
|
||||
routerAddress: plan.execution.contractAddress,
|
||||
calldata: plan.execution.encodedCalldata,
|
||||
tokenIn: pos.tokenAddress,
|
||||
amountInRaw: amountInRaw.toString(),
|
||||
});
|
||||
|
||||
swaps.push({
|
||||
lpSymbol: pos.symbol,
|
||||
tokenIn: pos.tokenAddress,
|
||||
tokenOut: pos.realTokenAddress,
|
||||
realSymbol: pos.realSymbol,
|
||||
amountIn: amountInRaw.toString(),
|
||||
amountOutQuoted: amountOutQuoted
|
||||
? formatUnits(amountOutQuoted, pos.realSymbol === 'WETH' ? 18 : 6)
|
||||
: undefined,
|
||||
txHash,
|
||||
status: 'SETTLED',
|
||||
});
|
||||
} catch (err) {
|
||||
swaps.push({
|
||||
lpSymbol: pos.symbol,
|
||||
tokenIn: pos.tokenAddress,
|
||||
tokenOut: pos.realTokenAddress,
|
||||
realSymbol: pos.realSymbol,
|
||||
amountIn: amountInRaw.toString(),
|
||||
status: 'FAILED',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settlementRef: params.settlementRef,
|
||||
holder,
|
||||
recipient,
|
||||
dryRun,
|
||||
positions,
|
||||
swaps,
|
||||
chainRailConfigured: chainRailConfigured(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve LP token by omnl line id (e.g. USD-M2 → cUSDT). */
|
||||
export function resolveLpTokenByLineId(lineId: string): { symbol: string; address: string } | undefined {
|
||||
const registry = loadM2Registry();
|
||||
const entry =
|
||||
registry.tokens.find((t) => t.omnlLine === lineId) ??
|
||||
registry.tokens.find((t) => t.symbol.toLowerCase() === 'cusdt' && lineId.includes('USD'));
|
||||
if (!entry?.address.startsWith('0x')) return undefined;
|
||||
return { symbol: entry.symbol, address: entry.address };
|
||||
}
|
||||
@@ -4,9 +4,15 @@ import { fetchBlockscoutTokenMeta } from './blockscout-token-meta';
|
||||
import { evaluateSupplyNostroMatch, type SupplyNostroMatchInfo } from './omnl-cash-in-bank-audit';
|
||||
import { runTripleStateReconcile, type TripleReconcileReport } from './omnl-triple-reconcile';
|
||||
import { resolveCanonicalPriceUsd } from './canonical-price-oracle';
|
||||
import {
|
||||
fineractConfigured,
|
||||
fetchOfficeSignedGlBalances,
|
||||
} from './omnl-fineract-office-gl';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const DEFAULT_CUSDC_ADDRESS = '0xf22258f57794CC8E06237084b353Ab30fFfa640b';
|
||||
const DEFAULT_CBTC_ADDRESS = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
|
||||
const BTC_L1_CUSTODY_GL = '12015';
|
||||
|
||||
export interface HoLiquidityTripleStateSummary {
|
||||
aligned?: boolean;
|
||||
@@ -19,6 +25,10 @@ export interface HoLiquidityPerTokenSample {
|
||||
totalSupplyUsd?: number;
|
||||
cashInBankUsd?: number;
|
||||
supplyNostroMatch?: SupplyNostroMatchInfo;
|
||||
/** Optional GL / pool enrichment for digital-asset reserves */
|
||||
glCode?: string;
|
||||
glBalanceUsd?: number;
|
||||
pmmPoolTvlUsd?: number;
|
||||
}
|
||||
|
||||
function decimalStringToNumber(value?: string, decimals?: number): number | null {
|
||||
@@ -104,3 +114,75 @@ export async function loadCusdcPerTokenSample(input: {
|
||||
supplyNostroMatch,
|
||||
};
|
||||
}
|
||||
|
||||
/** cBTC + GL 12015 L1 BTC custody sample for HO liquidity snapshot. */
|
||||
export async function loadCbtcBtcReserveSample(input: {
|
||||
cashInBankUsd: number;
|
||||
officeId: number;
|
||||
}): Promise<HoLiquidityPerTokenSample | undefined> {
|
||||
const address = (process.env.CBTC_ADDRESS_138 || DEFAULT_CBTC_ADDRESS).toLowerCase();
|
||||
const meta = await fetchBlockscoutTokenMeta(CHAIN_138, address);
|
||||
const decimals = meta?.decimals ?? 8;
|
||||
const supplyUnits = decimalStringToNumber(meta?.totalSupply, decimals);
|
||||
const { priceUsd } = resolveCanonicalPriceUsd(CHAIN_138, address);
|
||||
const pegPriceUsd = priceUsd != null && priceUsd > 0 ? priceUsd : undefined;
|
||||
const totalSupplyUsd =
|
||||
supplyUnits != null && supplyUnits > 0 && pegPriceUsd != null
|
||||
? supplyUnits * pegPriceUsd
|
||||
: undefined;
|
||||
|
||||
let glBalanceUsd: number | undefined;
|
||||
try {
|
||||
if (fineractConfigured()) {
|
||||
const rows = await fetchOfficeSignedGlBalances(1, [BTC_L1_CUSTODY_GL]);
|
||||
const row = rows.find((r) => r.glCode === BTC_L1_CUSTODY_GL);
|
||||
if (row) {
|
||||
glBalanceUsd = Math.abs(parseFloat(row.amount) || 0);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
glBalanceUsd = undefined;
|
||||
}
|
||||
|
||||
let pmmPoolTvlUsd: number | undefined;
|
||||
try {
|
||||
const poolEnv =
|
||||
process.env.CHAIN138_POOL_CBTC_CUSDT ||
|
||||
process.env.CHAIN138_POOL_CBTC_CUSDC ||
|
||||
process.env.CHAIN138_POOL_CBTC_CXAUC;
|
||||
if (poolEnv) {
|
||||
const { getLiveDodoPools } = await import('./live-dodo-fallback');
|
||||
const pools = await getLiveDodoPools(CHAIN_138);
|
||||
const cbtcPools = pools.filter(
|
||||
(p) =>
|
||||
String((p as { token0Address?: string }).token0Address || '').toLowerCase() === address ||
|
||||
String((p as { token1Address?: string }).token1Address || '').toLowerCase() === address ||
|
||||
String((p as { address?: string }).address || '').toLowerCase() === poolEnv.toLowerCase(),
|
||||
);
|
||||
if (cbtcPools.length) {
|
||||
pmmPoolTvlUsd = cbtcPools.reduce(
|
||||
(s, p) => s + (Number((p as { totalLiquidityUsd?: number }).totalLiquidityUsd) || 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
pmmPoolTvlUsd = undefined;
|
||||
}
|
||||
|
||||
const supplyNostroMatch = evaluateSupplyNostroMatch({
|
||||
totalSupplyUsd,
|
||||
externalNostroCashUsd: glBalanceUsd ?? input.cashInBankUsd,
|
||||
headOfficeId: input.officeId,
|
||||
});
|
||||
|
||||
return {
|
||||
address,
|
||||
totalSupplyUsd,
|
||||
cashInBankUsd: glBalanceUsd ?? (input.cashInBankUsd > 0 ? input.cashInBankUsd : undefined),
|
||||
supplyNostroMatch,
|
||||
glCode: BTC_L1_CUSTODY_GL,
|
||||
glBalanceUsd,
|
||||
pmmPoolTvlUsd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { fineractConfigured } from './omnl-fineract-office-gl';
|
||||
import {
|
||||
loadCusdcPerTokenSample,
|
||||
loadCbtcBtcReserveSample,
|
||||
loadHoLiquidityTripleStateSummary,
|
||||
type HoLiquidityPerTokenSample,
|
||||
type HoLiquidityTripleStateSummary,
|
||||
@@ -33,6 +34,7 @@ export interface HoLiquiditySnapshot {
|
||||
aggregateSupplyNostroMatch?: SupplyNostroMatchInfo;
|
||||
perTokenSamples?: {
|
||||
cUSDC?: HoLiquidityPerTokenSample;
|
||||
cBTC?: HoLiquidityPerTokenSample;
|
||||
};
|
||||
tripleState?: HoLiquidityTripleStateSummary;
|
||||
alerts: HoLiquidityAlert[];
|
||||
@@ -159,14 +161,26 @@ export async function buildHoLiquiditySnapshot(options?: {
|
||||
),
|
||||
).toISOString();
|
||||
|
||||
const [tripleState, cusdcSample] = await Promise.all([
|
||||
const [tripleState, cusdcSample, cbtcSample] = await Promise.all([
|
||||
loadHoLiquidityTripleStateSummary().catch(() => undefined),
|
||||
loadCusdcPerTokenSample({
|
||||
cashInBankUsd: cashInBankEnriched.cashInBankUsd,
|
||||
officeId,
|
||||
}).catch(() => undefined),
|
||||
loadCbtcBtcReserveSample({
|
||||
cashInBankUsd: cashInBankEnriched.cashInBankUsd,
|
||||
officeId,
|
||||
}).catch(() => undefined),
|
||||
]);
|
||||
|
||||
const perTokenSamples =
|
||||
cusdcSample || cbtcSample
|
||||
? {
|
||||
...(cusdcSample ? { cUSDC: cusdcSample } : {}),
|
||||
...(cbtcSample ? { cBTC: cbtcSample } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
officeId,
|
||||
@@ -176,7 +190,7 @@ export async function buildHoLiquiditySnapshot(options?: {
|
||||
cashInBank: cashInBankEnriched,
|
||||
reserveCoverage,
|
||||
aggregateSupplyNostroMatch,
|
||||
perTokenSamples: cusdcSample ? { cUSDC: cusdcSample } : undefined,
|
||||
perTokenSamples,
|
||||
tripleState,
|
||||
alerts: evaluateHoLiquidityAlerts({ cashInBank: cashInBankEnriched, reserveCoverage, generatedAt }),
|
||||
auditMaxAgeMinutes: auditMaxAgeMinutes(),
|
||||
|
||||
@@ -92,3 +92,60 @@ export async function executeErc20Transfer(params: {
|
||||
export function chainRailConfigured(): boolean {
|
||||
return Boolean(operatorKey() && rpcUrl() && chainExecuteEnabled());
|
||||
}
|
||||
|
||||
/** Read ERC-20 balance (raw units) for holder. */
|
||||
export async function readErc20Balance(params: {
|
||||
tokenAddress: string;
|
||||
holder: string;
|
||||
}): Promise<bigint> {
|
||||
const rpc = rpcUrl();
|
||||
if (!rpc) throw new Error('RPC_URL_138 required');
|
||||
const provider = new JsonRpcProvider(rpc);
|
||||
const abi = ['function balanceOf(address) view returns (uint256)'];
|
||||
const token = new Contract(params.tokenAddress, abi, provider);
|
||||
return token.balanceOf(params.holder) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/** Approve router and submit swap calldata from EnhancedSwapRouterV2 planner. */
|
||||
export async function executeRouterSwap(params: {
|
||||
routerAddress: string;
|
||||
calldata: string;
|
||||
tokenIn: string;
|
||||
amountInRaw: string;
|
||||
}): Promise<{ txHash: string }> {
|
||||
const pk = operatorKey();
|
||||
const rpc = rpcUrl();
|
||||
if (!chainExecuteEnabled()) {
|
||||
throw new Error('Chain execute disabled — set SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1');
|
||||
}
|
||||
if (!pk || !rpc) {
|
||||
throw new Error('OMNL_MINT_OPERATOR_PRIVATE_KEY and RPC_URL_138 required');
|
||||
}
|
||||
|
||||
const provider = new JsonRpcProvider(rpc);
|
||||
const wallet = new Wallet(pk, provider);
|
||||
const amountIn = BigInt(params.amountInRaw);
|
||||
|
||||
if (params.tokenIn.startsWith('0x') && amountIn > 0n) {
|
||||
const erc20 = new Contract(
|
||||
params.tokenIn,
|
||||
[
|
||||
'function allowance(address owner, address spender) view returns (uint256)',
|
||||
'function approve(address spender, uint256 amount) returns (bool)',
|
||||
],
|
||||
wallet,
|
||||
);
|
||||
const allowance = (await erc20.allowance(wallet.address, params.routerAddress)) as bigint;
|
||||
if (allowance < amountIn) {
|
||||
const approveTx = await erc20.approve(params.routerAddress, amountIn);
|
||||
await approveTx.wait(1);
|
||||
}
|
||||
}
|
||||
|
||||
const tx = await wallet.sendTransaction({
|
||||
to: params.routerAddress,
|
||||
data: params.calldata,
|
||||
});
|
||||
const receipt = await tx.wait(1);
|
||||
return { txHash: receipt?.hash ?? tx.hash };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user