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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -32,6 +32,9 @@ secrets/
|
||||
besu-data/
|
||||
chaindata/
|
||||
datadir/
|
||||
data/bitcoind-regtest/
|
||||
data/btc-intake/
|
||||
data/settlement-store/
|
||||
|
||||
# Foundry
|
||||
out/
|
||||
@@ -68,6 +71,11 @@ node_modules/
|
||||
# TypeScript service emit (build in-tree)
|
||||
services/btc-intake/dist/
|
||||
|
||||
# Local settlement store / runtime data (do not commit)
|
||||
services/data/
|
||||
logs/
|
||||
dist/go-live/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
10
config/bitcoind.regtest.conf
Normal file
10
config/bitcoind.regtest.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
regtest=1
|
||||
server=1
|
||||
txindex=1
|
||||
rpcuser=omnl
|
||||
rpcpassword=omnl-regtest-local
|
||||
rpcallowip=0.0.0.0/0
|
||||
rpcbind=0.0.0.0
|
||||
rpcport=18443
|
||||
fallbackfee=0.0002
|
||||
printtoconsole=1
|
||||
76
config/chain138-fiat-lp-liquidity.v1.json
Normal file
76
config/chain138-fiat-lp-liquidity.v1.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"$schema": "Chain 138 fiat LP (c*) → real on-chain liquidity — mainnet-style parity",
|
||||
"version": "1.0.0",
|
||||
"updated": "2026-07-06",
|
||||
"chainId": 138,
|
||||
"mainnetParityChainId": 1,
|
||||
"note": "Real fund tokens use canonical Chain 138 addresses (same symbols as Ethereum mainnet family). LP c* tokens swap via EnhancedSwapRouterV2 / DODO PMM.",
|
||||
"contracts": {
|
||||
"enhancedSwapRouterV2": "0xa421706768aeb7fafa2d912c5e10824ef3437ad4",
|
||||
"enhancedSwapRouterV1": "0xE6Cc7643ae2A4C720A28D8263BC4972905d7DE0f",
|
||||
"uniswapV3Router": "0xde9cD8ee2811E6E64a41D5F68Be315d33995975E",
|
||||
"dodoPmmIntegration": "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895",
|
||||
"uniswapV3WethUsdtPool": "0xa893add35aEfe6A6d858EB01828bE4592f12C9F5",
|
||||
"uniswapV3WethUsdcPool": "0xEC745bfb6b3cd32f102d594E5F432d8d85B19391",
|
||||
"officialUsdt": "0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1",
|
||||
"officialUsdc": "0x71D6687F38b93CCad569Fa6352c876eea967201b",
|
||||
"officialWeth": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
||||
"ccipWeth9Bridge": "0xcacfd227A040002e49e2e01626363071324f820a"
|
||||
},
|
||||
"mainnetReference": {
|
||||
"note": "Chain 1 canonical references for parity documentation",
|
||||
"usdt": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
"weth": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
|
||||
},
|
||||
"defaultSlippageBps": 50,
|
||||
"minSwapAmountRaw": "1000",
|
||||
"lpPrefix": "c",
|
||||
"realTargets": {
|
||||
"USD": "USDT",
|
||||
"EUR": "USDT",
|
||||
"GBP": "USDT",
|
||||
"AUD": "USDT",
|
||||
"JPY": "USDT",
|
||||
"CHF": "USDT",
|
||||
"CAD": "USDT",
|
||||
"XAU": "WETH",
|
||||
"BTC": "WETH",
|
||||
"ETH": "WETH",
|
||||
"BNB": "WETH",
|
||||
"POL": "WETH",
|
||||
"AVAX": "WETH",
|
||||
"CRO": "WETH",
|
||||
"XDAI": "WETH",
|
||||
"CELO": "WETH",
|
||||
"WEMIX": "WETH"
|
||||
},
|
||||
"explicitLpToReal": [
|
||||
{ "lpSymbol": "cUSDT", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cUSDT_V2", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cAUSDT", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cUSDW", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cUSDC", "realSymbol": "USDC" },
|
||||
{ "lpSymbol": "cUSDC_V2", "realSymbol": "USDC" },
|
||||
{ "lpSymbol": "cEURC", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cEURT", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cGBPC", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cGBPT", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cAUDC", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cJPYC", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cCHFC", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cCADC", "realSymbol": "USDT" },
|
||||
{ "lpSymbol": "cXAUC", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cXAUT", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cBTC", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cETH", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cETHL2", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cBNB", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cPOL", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cAVAX", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cCRO", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cXDAI", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cCELO", "realSymbol": "WETH" },
|
||||
{ "lpSymbol": "cWEMIX", "realSymbol": "WETH" }
|
||||
]
|
||||
}
|
||||
13
config/custody-dfns-cobo.env.example
Normal file
13
config/custody-dfns-cobo.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Dfns + Cobo custody credentials (copy from secrets/* after local setup)
|
||||
# Real secrets live only under secrets/ (gitignored). Do not put live tokens here.
|
||||
|
||||
DFNS_API_BASE_URL=https://api.dfns.io
|
||||
DFNS_ORG_ID=
|
||||
DFNS_AUTH_TOKEN=
|
||||
DFNS_CRED_ID=
|
||||
DFNS_PRIVATE_KEY_PATH=secrets/dfns/rsa2048.pem
|
||||
|
||||
COBO_API_BASE_URL=https://api.cobo.com/v2
|
||||
COBO_API_KEY=
|
||||
COBO_API_SECRET=
|
||||
# Temporary keys: expires noted by issuer. Dev portal keys use https://api.dev.cobo.com/v2
|
||||
@@ -74,3 +74,29 @@ VITE_WALLETCONNECT_PROJECT_ID=
|
||||
VITE_THIRDWEB_CLIENT_ID=
|
||||
VITE_RPC_URL_138=http://192.168.11.221:8545
|
||||
VITE_ENHANCED_SWAP_ROUTER_V2=0xa421706768aeb7fafa2d912c5e10824ef3437ad4
|
||||
|
||||
# === BTC L1 custody + live pricing ===
|
||||
OMNL_REQUIRE_LIVE_BTC_PRICE=1
|
||||
ISO4217_FX_REFRESH_INTERVAL_MS=60000
|
||||
# Optional override if oracle down (only set deliberately):
|
||||
# CHAIN138_CANONICAL_PRICE_USD_BTC=
|
||||
BITCOIND_RPC_URL=
|
||||
BITCOIND_RPC_USER=
|
||||
BITCOIND_RPC_PASS=
|
||||
BTC_NETWORK=mainnet
|
||||
BTC_RESERVE_ADDRESSES=
|
||||
BTC_CONFIRMATIONS_REQUIRED=6
|
||||
BTC_INTAKE_PORT=3013
|
||||
BTC_INTAKE_SYNC_INTERVAL_MS=30000
|
||||
BTC_MAX_OUTSTANDING_SATS=
|
||||
SETTLEMENT_MIDDLEWARE_URL=http://127.0.0.1:3011
|
||||
# cBTC PMM pools (Chain 138 DODO) — set after deploy
|
||||
CHAIN138_POOL_CBTC_CUSDT=
|
||||
CHAIN138_POOL_CBTC_CUSDC=
|
||||
CHAIN138_POOL_CBTC_CXAUC=
|
||||
|
||||
# After rebuilding settlement-middleware, redeploy so secure.omdnl.org/settlement/btc/* is live.
|
||||
# Smoke: SETTLEMENT_URL=https://secure.omdnl.org/settlement OMNL_API_KEY=... node scripts/deployment/smoke-btc-settlement.mjs
|
||||
# Hot LP: HOT_CBTC_EXECUTE=1 node scripts/deployment/hot-cbtc-liquidity-1000.mjs
|
||||
# Trade: node scripts/deployment/verify-cbtc-trade.mjs
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"ipsasStandards": ["IPSAS 2", "IPSAS 28", "IPSAS 29"],
|
||||
"ifrsRefs": ["IAS 21", "IFRS 13"],
|
||||
"roles": ["digital_asset_reserve", "btc_l1_custody"],
|
||||
"inLiveRegistry": false
|
||||
"inLiveRegistry": true
|
||||
},
|
||||
{
|
||||
"glCode": "12424",
|
||||
@@ -104,7 +104,7 @@
|
||||
"ifrsRefs": ["IFRS 13"],
|
||||
"roles": ["digital_asset_custody", "interoffice_btc"],
|
||||
"officeId": 24,
|
||||
"inLiveRegistry": false
|
||||
"inLiveRegistry": true
|
||||
},
|
||||
{
|
||||
"glCode": "13010",
|
||||
|
||||
@@ -356,6 +356,26 @@
|
||||
"ipsasRef": "IPSAS 28",
|
||||
"moneyLayer": "M1",
|
||||
"variableAmount": true
|
||||
},
|
||||
{
|
||||
"memo": "T-BTC-L1-HO",
|
||||
"officeId": 1,
|
||||
"debitGlCode": "12015",
|
||||
"creditGlCode": "12424",
|
||||
"amount": 0,
|
||||
"narrative": "L1 BTC custody confirm — Dr HO digital asset reserve / Cr Office 24 interoffice BTC (USD = sats × live BTC/USD)",
|
||||
"ipsasRef": "IPSAS 2, 28, 29; IFRS 13",
|
||||
"moneyLayer": "M0"
|
||||
},
|
||||
{
|
||||
"memo": "T-BTC-M2M3",
|
||||
"officeId": 24,
|
||||
"debitGlCode": "2200",
|
||||
"creditGlCode": "2300",
|
||||
"amount": 0,
|
||||
"narrative": "BTC-M2 cBTC liability load — Dr M2 broad money / Cr M3 token liability",
|
||||
"ipsasRef": "IPSAS 28, 29",
|
||||
"moneyLayer": "M3"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
23
docker-compose.bitcoind-regtest.yml
Normal file
23
docker-compose.bitcoind-regtest.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
# Local regtest bitcoind for BTC intake E2E
|
||||
# Usage: docker compose -f docker-compose.bitcoind-regtest.yml up -d
|
||||
|
||||
services:
|
||||
bitcoind-regtest:
|
||||
image: bitcoin/bitcoin:27.0
|
||||
container_name: omnl-bitcoind-regtest
|
||||
ports:
|
||||
- "18443:18443"
|
||||
volumes:
|
||||
- ./data/bitcoind-regtest:/home/bitcoin/.bitcoin
|
||||
command:
|
||||
- -regtest=1
|
||||
- -server=1
|
||||
- -txindex=1
|
||||
- -rpcuser=omnl
|
||||
- -rpcpassword=omnl-regtest-local
|
||||
- -rpcallowip=0.0.0.0/0
|
||||
- -rpcbind=0.0.0.0
|
||||
- -rpcport=18443
|
||||
- -fallbackfee=0.0002
|
||||
- -printtoconsole=1
|
||||
restart: unless-stopped
|
||||
@@ -93,7 +93,62 @@ services:
|
||||
- token-aggregation
|
||||
- settlement-middleware
|
||||
- dbis-exchange
|
||||
- btc-intake
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional: use external BITCOIND_RPC_URL instead of this service
|
||||
bitcoind:
|
||||
image: bitcoin/bitcoin:27.0
|
||||
profiles: ["btc-node"]
|
||||
volumes:
|
||||
- btc-data:/home/bitcoin/.bitcoin
|
||||
environment:
|
||||
BITCOIN_DATA: /home/bitcoin/.bitcoin
|
||||
command:
|
||||
- bitcoind
|
||||
- -server=1
|
||||
- -txindex=1
|
||||
- -rpcallowip=0.0.0.0/0
|
||||
- -rpcbind=0.0.0.0
|
||||
- -rpcuser=${BITCOIND_RPC_USER:-omnl}
|
||||
- -rpcpassword=${BITCOIND_RPC_PASS:-changeme}
|
||||
- -printtoconsole
|
||||
expose:
|
||||
- "8332"
|
||||
restart: unless-stopped
|
||||
|
||||
btc-intake:
|
||||
build:
|
||||
context: ./services/btc-intake
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3013:3013"
|
||||
env_file:
|
||||
- ./config/deployment-omnl.production.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- BTC_INTAKE_PORT=3013
|
||||
- BTC_INTAKE_DATA_DIR=/app/data
|
||||
- SETTLEMENT_MIDDLEWARE_URL=http://settlement-middleware:3011
|
||||
- BITCOIND_RPC_URL=${BITCOIND_RPC_URL:-http://bitcoind:8332}
|
||||
- BITCOIND_RPC_USER=${BITCOIND_RPC_USER:-omnl}
|
||||
- BITCOIND_RPC_PASS=${BITCOIND_RPC_PASS:-changeme}
|
||||
- BTC_NETWORK=${BTC_NETWORK:-mainnet}
|
||||
- BTC_CONFIRMATIONS_REQUIRED=${BTC_CONFIRMATIONS_REQUIRED:-6}
|
||||
- OMNL_REQUIRE_LIVE_BTC_PRICE=1
|
||||
volumes:
|
||||
- btc-intake-data:/app/data
|
||||
depends_on:
|
||||
settlement-middleware:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3013/health',r=>process.exit(r.statusCode===200||r.statusCode===503?0:1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
settlement-data:
|
||||
btc-data:
|
||||
btc-intake-data:
|
||||
|
||||
1
packages/settlement-core/dist/btc-settlement.test.d.ts
vendored
Normal file
1
packages/settlement-core/dist/btc-settlement.test.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
26
packages/settlement-core/dist/btc-settlement.test.js
vendored
Normal file
26
packages/settlement-core/dist/btc-settlement.test.js
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_test_1 = require("node:test");
|
||||
const strict_1 = __importDefault(require("node:assert/strict"));
|
||||
const money_supply_1 = require("./money-supply");
|
||||
(0, node_test_1.describe)('BTC settlement helpers', () => {
|
||||
(0, node_test_1.it)('converts sats to 8-decimal BTC', () => {
|
||||
strict_1.default.equal((0, money_supply_1.satsToBtcDecimal)(100_000_000), '1.00000000');
|
||||
strict_1.default.equal((0, money_supply_1.satsToBtcDecimal)(1), '0.00000001');
|
||||
strict_1.default.equal((0, money_supply_1.satsToBtcDecimal)(0), '0.00000000');
|
||||
});
|
||||
(0, node_test_1.it)('values sats at live BTC/USD for Fineract journal amount', () => {
|
||||
strict_1.default.equal((0, money_supply_1.btcUsdToJournalAmount)(100_000_000, 95_000), '95000.00');
|
||||
strict_1.default.equal((0, money_supply_1.btcUsdToJournalAmount)(50_000_000, 100_000), '50000.00');
|
||||
strict_1.default.equal((0, money_supply_1.btcUsdToJournalAmount)(0, 100_000), '0.00');
|
||||
});
|
||||
(0, node_test_1.it)('resolves T-BTC-L1-HO custody journal pair', () => {
|
||||
const j = (0, money_supply_1.resolveBtcL1CustodyJournal)(24);
|
||||
strict_1.default.equal(j.debitGl, '12015');
|
||||
strict_1.default.equal(j.creditGl, '12424');
|
||||
strict_1.default.equal(j.memo, 'T-BTC-L1-HO');
|
||||
});
|
||||
});
|
||||
13
packages/settlement-core/dist/money-supply.d.ts
vendored
13
packages/settlement-core/dist/money-supply.d.ts
vendored
@@ -9,6 +9,8 @@ export declare const GL_CODES: {
|
||||
readonly SETTLEMENT_SUSPENSE: "1000";
|
||||
readonly TRADE_FINANCE_CONTINGENT: "1410";
|
||||
readonly CRYPTO_TOKEN_LIABILITY: "2300";
|
||||
readonly BTC_L1_CUSTODY_HO: "12015";
|
||||
readonly BTC_INTEROFFICE_O24: "12424";
|
||||
};
|
||||
/** Policy caps: M0→M1 (1.2×), M1→M2 (5×), M2→M3 token mint, M4 near-money rail */
|
||||
export declare const POLICY: {
|
||||
@@ -61,3 +63,14 @@ export declare function resolveM4OutboundJournal(moneyLayers: MoneyLayer[], gl:
|
||||
debitGl: string;
|
||||
creditGl: string;
|
||||
} | null;
|
||||
/** Convert satoshis to BTC decimal string (8 places). */
|
||||
export declare function satsToBtcDecimal(amountSats: number): string;
|
||||
/** USD journal amount for Fineract = sats × BTC/USD rate (2 decimal places). */
|
||||
export declare function btcUsdToJournalAmount(amountSats: number, btcUsdRate: number): string;
|
||||
/** IPSAS journal pair for L1 BTC custody confirm (T-BTC-L1-HO): Dr 12015 / Cr 12424. */
|
||||
export declare function resolveBtcL1CustodyJournal(_officeId?: number): {
|
||||
debitGl: string;
|
||||
creditGl: string;
|
||||
narrative: string;
|
||||
memo: string;
|
||||
};
|
||||
|
||||
27
packages/settlement-core/dist/money-supply.js
vendored
27
packages/settlement-core/dist/money-supply.js
vendored
@@ -8,6 +8,9 @@ exports.m3TokenLoadAmount = m3TokenLoadAmount;
|
||||
exports.resolveZBankLayerJournal = resolveZBankLayerJournal;
|
||||
exports.zBankM0ToM4Hops = zBankM0ToM4Hops;
|
||||
exports.resolveM4OutboundJournal = resolveM4OutboundJournal;
|
||||
exports.satsToBtcDecimal = satsToBtcDecimal;
|
||||
exports.btcUsdToJournalAmount = btcUsdToJournalAmount;
|
||||
exports.resolveBtcL1CustodyJournal = resolveBtcL1CustodyJournal;
|
||||
/** IPSAS GL mapping for OMNL central-bank money aggregates M0–M4 */
|
||||
exports.GL_CODES = {
|
||||
M0_RESERVE: '1050',
|
||||
@@ -18,6 +21,8 @@ exports.GL_CODES = {
|
||||
SETTLEMENT_SUSPENSE: '1000',
|
||||
TRADE_FINANCE_CONTINGENT: '1410',
|
||||
CRYPTO_TOKEN_LIABILITY: '2300',
|
||||
BTC_L1_CUSTODY_HO: '12015',
|
||||
BTC_INTEROFFICE_O24: '12424',
|
||||
};
|
||||
/** Policy caps: M0→M1 (1.2×), M1→M2 (5×), M2→M3 token mint, M4 near-money rail */
|
||||
exports.POLICY = {
|
||||
@@ -165,3 +170,25 @@ function resolveM4OutboundJournal(moneyLayers, gl) {
|
||||
}
|
||||
return { debitGl: exports.GL_CODES.M2_BROAD, creditGl: suspense };
|
||||
}
|
||||
/** Convert satoshis to BTC decimal string (8 places). */
|
||||
function satsToBtcDecimal(amountSats) {
|
||||
const sats = Math.max(0, Math.floor(Number(amountSats) || 0));
|
||||
return (sats / 1e8).toFixed(8);
|
||||
}
|
||||
/** USD journal amount for Fineract = sats × BTC/USD rate (2 decimal places). */
|
||||
function btcUsdToJournalAmount(amountSats, btcUsdRate) {
|
||||
const sats = Math.max(0, Number(amountSats) || 0);
|
||||
const rate = Number(btcUsdRate) || 0;
|
||||
if (sats <= 0 || rate <= 0)
|
||||
return '0.00';
|
||||
return ((sats / 1e8) * rate).toFixed(2);
|
||||
}
|
||||
/** IPSAS journal pair for L1 BTC custody confirm (T-BTC-L1-HO): Dr 12015 / Cr 12424. */
|
||||
function resolveBtcL1CustodyJournal(_officeId) {
|
||||
return {
|
||||
debitGl: exports.GL_CODES.BTC_L1_CUSTODY_HO,
|
||||
creditGl: exports.GL_CODES.BTC_INTEROFFICE_O24,
|
||||
narrative: 'L1 BTC custody confirm — Dr HO digital asset reserve / Cr Office 24 interoffice BTC',
|
||||
memo: 'T-BTC-L1-HO',
|
||||
};
|
||||
}
|
||||
|
||||
77
packages/settlement-core/dist/types.d.ts
vendored
77
packages/settlement-core/dist/types.d.ts
vendored
@@ -86,6 +86,79 @@ export type ZBankM0M4Request = {
|
||||
recipientAddress?: string;
|
||||
tokenLineId?: string;
|
||||
};
|
||||
/** Swap fiat-backed LP token (c*) → real fund on Chain 138 (USDT/USDC/WETH). */
|
||||
export type FiatLpToLiquidityRequest = {
|
||||
idempotencyKey: string;
|
||||
officeId?: number;
|
||||
/** Wallet holding c* LP tokens (treasury or recipient after mint). */
|
||||
holderAddress: string;
|
||||
/** Receiver of real USDT/USDC/WETH after swap; defaults to holderAddress. */
|
||||
recipientAddress?: string;
|
||||
/** Single LP symbol (e.g. cUSDT); omit with convertAll for full portfolio. */
|
||||
symbol?: string;
|
||||
amount?: string;
|
||||
convertAll?: boolean;
|
||||
maxSlippageBps?: number;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
/** L1 Bitcoin deposit confirmed → Fineract custody journal + cBTC mint. */
|
||||
export type BtcL1SettlementRequest = {
|
||||
idempotencyKey: string;
|
||||
/** Sender office (default 24). */
|
||||
officeId?: number;
|
||||
instructionId: string;
|
||||
txId: string;
|
||||
depositAddress: string;
|
||||
amountSats: number;
|
||||
confirmations: number;
|
||||
/** Chain 138 vault for cBTC mint. */
|
||||
recipientAddress: string;
|
||||
valueDate?: string;
|
||||
};
|
||||
/**
|
||||
* Mark a FAILED/partial BTC L1 settle as SETTLED when books + mint already
|
||||
* happened out-of-band (no re-journal / no remint).
|
||||
*/
|
||||
export type BtcL1ReconcileRequest = {
|
||||
/** Prefer settlementId; otherwise idempotencyKey. */
|
||||
settlementId?: string;
|
||||
idempotencyKey?: string;
|
||||
amountSats: number;
|
||||
txId: string;
|
||||
depositAddress: string;
|
||||
instructionId: string;
|
||||
recipientAddress: string;
|
||||
/** On-chain cBTC mint tx hash. */
|
||||
chainTxHash: string;
|
||||
btcUsdRate: number;
|
||||
usdValue?: string;
|
||||
fineractJournalRef?: string;
|
||||
priceSource?: string;
|
||||
priceAsOf?: string;
|
||||
confirmations?: number;
|
||||
valueDate?: string;
|
||||
};
|
||||
export type BtcSettlementSnapshot = {
|
||||
amountSats: number;
|
||||
txId: string;
|
||||
btcUsdRate: number;
|
||||
usdValue: string;
|
||||
depositAddress: string;
|
||||
instructionId?: string;
|
||||
priceSource?: string;
|
||||
priceAsOf?: string;
|
||||
};
|
||||
export type FiatLpSwapLeg = {
|
||||
lpSymbol: string;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
realSymbol: string;
|
||||
amountIn: string;
|
||||
amountOutQuoted?: string;
|
||||
txHash?: string;
|
||||
status: 'DRY_RUN' | 'QUOTED' | 'SUBMITTED' | 'SETTLED' | 'SKIPPED' | 'FAILED';
|
||||
error?: string;
|
||||
};
|
||||
export type SettlementRecord = {
|
||||
settlementId: string;
|
||||
idempotencyKey: string;
|
||||
@@ -95,6 +168,10 @@ export type SettlementRecord = {
|
||||
hybxPaymentId?: string;
|
||||
iso20022MessageId?: string;
|
||||
chainTxHash?: string;
|
||||
/** c* → real liquidity swaps on Chain 138 */
|
||||
liquiditySwaps?: FiatLpSwapLeg[];
|
||||
/** L1 BTC custody + valuation snapshot at settle time */
|
||||
btcSettlement?: BtcSettlementSnapshot;
|
||||
verbiageDocument?: string;
|
||||
layerSteps?: ZBankLayerStep[];
|
||||
errors: string[];
|
||||
|
||||
28
packages/settlement-core/src/btc-settlement.test.ts
Normal file
28
packages/settlement-core/src/btc-settlement.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
btcUsdToJournalAmount,
|
||||
resolveBtcL1CustodyJournal,
|
||||
satsToBtcDecimal,
|
||||
} from './money-supply';
|
||||
|
||||
describe('BTC settlement helpers', () => {
|
||||
it('converts sats to 8-decimal BTC', () => {
|
||||
assert.equal(satsToBtcDecimal(100_000_000), '1.00000000');
|
||||
assert.equal(satsToBtcDecimal(1), '0.00000001');
|
||||
assert.equal(satsToBtcDecimal(0), '0.00000000');
|
||||
});
|
||||
|
||||
it('values sats at live BTC/USD for Fineract journal amount', () => {
|
||||
assert.equal(btcUsdToJournalAmount(100_000_000, 95_000), '95000.00');
|
||||
assert.equal(btcUsdToJournalAmount(50_000_000, 100_000), '50000.00');
|
||||
assert.equal(btcUsdToJournalAmount(0, 100_000), '0.00');
|
||||
});
|
||||
|
||||
it('resolves T-BTC-L1-HO custody journal pair', () => {
|
||||
const j = resolveBtcL1CustodyJournal(24);
|
||||
assert.equal(j.debitGl, '12015');
|
||||
assert.equal(j.creditGl, '12424');
|
||||
assert.equal(j.memo, 'T-BTC-L1-HO');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ export const GL_CODES = {
|
||||
SETTLEMENT_SUSPENSE: '1000',
|
||||
TRADE_FINANCE_CONTINGENT: '1410',
|
||||
CRYPTO_TOKEN_LIABILITY: '2300',
|
||||
BTC_L1_CUSTODY_HO: '12015',
|
||||
BTC_INTEROFFICE_O24: '12424',
|
||||
} as const;
|
||||
|
||||
/** Policy caps: M0→M1 (1.2×), M1→M2 (5×), M2→M3 token mint, M4 near-money rail */
|
||||
@@ -191,3 +193,32 @@ export function resolveM4OutboundJournal(moneyLayers: MoneyLayer[], gl: {
|
||||
}
|
||||
return { debitGl: GL_CODES.M2_BROAD, creditGl: suspense };
|
||||
}
|
||||
|
||||
/** Convert satoshis to BTC decimal string (8 places). */
|
||||
export function satsToBtcDecimal(amountSats: number): string {
|
||||
const sats = Math.max(0, Math.floor(Number(amountSats) || 0));
|
||||
return (sats / 1e8).toFixed(8);
|
||||
}
|
||||
|
||||
/** USD journal amount for Fineract = sats × BTC/USD rate (2 decimal places). */
|
||||
export function btcUsdToJournalAmount(amountSats: number, btcUsdRate: number): string {
|
||||
const sats = Math.max(0, Number(amountSats) || 0);
|
||||
const rate = Number(btcUsdRate) || 0;
|
||||
if (sats <= 0 || rate <= 0) return '0.00';
|
||||
return ((sats / 1e8) * rate).toFixed(2);
|
||||
}
|
||||
|
||||
/** IPSAS journal pair for L1 BTC custody confirm (T-BTC-L1-HO): Dr 12015 / Cr 12424. */
|
||||
export function resolveBtcL1CustodyJournal(_officeId?: number): {
|
||||
debitGl: string;
|
||||
creditGl: string;
|
||||
narrative: string;
|
||||
memo: string;
|
||||
} {
|
||||
return {
|
||||
debitGl: GL_CODES.BTC_L1_CUSTODY_HO,
|
||||
creditGl: GL_CODES.BTC_INTEROFFICE_O24,
|
||||
narrative: 'L1 BTC custody confirm — Dr HO digital asset reserve / Cr Office 24 interoffice BTC',
|
||||
memo: 'T-BTC-L1-HO',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,6 +114,84 @@ export type ZBankM0M4Request = {
|
||||
tokenLineId?: string;
|
||||
};
|
||||
|
||||
/** Swap fiat-backed LP token (c*) → real fund on Chain 138 (USDT/USDC/WETH). */
|
||||
export type FiatLpToLiquidityRequest = {
|
||||
idempotencyKey: string;
|
||||
officeId?: number;
|
||||
/** Wallet holding c* LP tokens (treasury or recipient after mint). */
|
||||
holderAddress: string;
|
||||
/** Receiver of real USDT/USDC/WETH after swap; defaults to holderAddress. */
|
||||
recipientAddress?: string;
|
||||
/** Single LP symbol (e.g. cUSDT); omit with convertAll for full portfolio. */
|
||||
symbol?: string;
|
||||
amount?: string;
|
||||
convertAll?: boolean;
|
||||
maxSlippageBps?: number;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
/** L1 Bitcoin deposit confirmed → Fineract custody journal + cBTC mint. */
|
||||
export type BtcL1SettlementRequest = {
|
||||
idempotencyKey: string;
|
||||
/** Sender office (default 24). */
|
||||
officeId?: number;
|
||||
instructionId: string;
|
||||
txId: string;
|
||||
depositAddress: string;
|
||||
amountSats: number;
|
||||
confirmations: number;
|
||||
/** Chain 138 vault for cBTC mint. */
|
||||
recipientAddress: string;
|
||||
valueDate?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark a FAILED/partial BTC L1 settle as SETTLED when books + mint already
|
||||
* happened out-of-band (no re-journal / no remint).
|
||||
*/
|
||||
export type BtcL1ReconcileRequest = {
|
||||
/** Prefer settlementId; otherwise idempotencyKey. */
|
||||
settlementId?: string;
|
||||
idempotencyKey?: string;
|
||||
amountSats: number;
|
||||
txId: string;
|
||||
depositAddress: string;
|
||||
instructionId: string;
|
||||
recipientAddress: string;
|
||||
/** On-chain cBTC mint tx hash. */
|
||||
chainTxHash: string;
|
||||
btcUsdRate: number;
|
||||
usdValue?: string;
|
||||
fineractJournalRef?: string;
|
||||
priceSource?: string;
|
||||
priceAsOf?: string;
|
||||
confirmations?: number;
|
||||
valueDate?: string;
|
||||
};
|
||||
|
||||
export type BtcSettlementSnapshot = {
|
||||
amountSats: number;
|
||||
txId: string;
|
||||
btcUsdRate: number;
|
||||
usdValue: string;
|
||||
depositAddress: string;
|
||||
instructionId?: string;
|
||||
priceSource?: string;
|
||||
priceAsOf?: string;
|
||||
};
|
||||
|
||||
export type FiatLpSwapLeg = {
|
||||
lpSymbol: string;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
realSymbol: string;
|
||||
amountIn: string;
|
||||
amountOutQuoted?: string;
|
||||
txHash?: string;
|
||||
status: 'DRY_RUN' | 'QUOTED' | 'SUBMITTED' | 'SETTLED' | 'SKIPPED' | 'FAILED';
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type SettlementRecord = {
|
||||
settlementId: string;
|
||||
idempotencyKey: string;
|
||||
@@ -123,6 +201,10 @@ export type SettlementRecord = {
|
||||
hybxPaymentId?: string;
|
||||
iso20022MessageId?: string;
|
||||
chainTxHash?: string;
|
||||
/** c* → real liquidity swaps on Chain 138 */
|
||||
liquiditySwaps?: FiatLpSwapLeg[];
|
||||
/** L1 BTC custody + valuation snapshot at settle time */
|
||||
btcSettlement?: BtcSettlementSnapshot;
|
||||
verbiageDocument?: string;
|
||||
layerSteps?: ZBankLayerStep[];
|
||||
errors: string[];
|
||||
|
||||
21
scripts/custody/package-lock.json
generated
Normal file
21
scripts/custody/package-lock.json
generated
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "custody",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ed25519": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz",
|
||||
"integrity": "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
scripts/custody/package.json
Normal file
5
scripts/custody/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "^3.1.0"
|
||||
}
|
||||
}
|
||||
234
scripts/custody/smoke-dfns-cobo.mjs
Normal file
234
scripts/custody/smoke-dfns-cobo.mjs
Normal file
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Smoke-test Dfns + Cobo credentials from secrets/*.
|
||||
* Usage: node scripts/custody/smoke-dfns-cobo.mjs
|
||||
* Does not print secrets — only HTTP status and non-sensitive fields.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
|
||||
function loadEnvFile(rel) {
|
||||
const p = path.join(root, rel);
|
||||
if (!fs.existsSync(p)) throw new Error(`Missing ${rel}`);
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
|
||||
if (!line || line.trim().startsWith('#')) continue;
|
||||
const i = line.indexOf('=');
|
||||
if (i < 0) continue;
|
||||
out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function redact(s) {
|
||||
if (!s) return '(empty)';
|
||||
if (s.length <= 12) return '***';
|
||||
return `${s.slice(0, 6)}…${s.slice(-4)} (len=${s.length})`;
|
||||
}
|
||||
|
||||
async function testDfns(dfns) {
|
||||
const base = (dfns.DFNS_API_BASE_URL || 'https://api.dfns.io').replace(/\/$/, '');
|
||||
const token = dfns.DFNS_AUTH_TOKEN;
|
||||
if (!token) throw new Error('DFNS_AUTH_TOKEN missing');
|
||||
|
||||
const pemPath = path.isAbsolute(dfns.DFNS_PRIVATE_KEY_PATH)
|
||||
? dfns.DFNS_PRIVATE_KEY_PATH
|
||||
: path.join(root, dfns.DFNS_PRIVATE_KEY_PATH || 'secrets/dfns/rsa2048.pem');
|
||||
const pemOk = fs.existsSync(pemPath);
|
||||
const pem = pemOk ? fs.readFileSync(pemPath, 'utf8') : '';
|
||||
const pemLooksPrivate = pem.includes('BEGIN PRIVATE KEY') || pem.includes('BEGIN RSA PRIVATE KEY');
|
||||
|
||||
// Read-only: list wallets (Bearer only)
|
||||
const res = await fetch(`${base}/wallets?limit=5`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = { raw: text.slice(0, 200) };
|
||||
}
|
||||
|
||||
const items = body?.items ?? body?.wallets ?? [];
|
||||
return {
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
http: res.status,
|
||||
orgId: dfns.DFNS_ORG_ID || body?.items?.[0]?.orgId,
|
||||
credIdPresent: Boolean(dfns.DFNS_CRED_ID),
|
||||
credIdPrefix: redact(dfns.DFNS_CRED_ID),
|
||||
tokenPrefix: redact(token),
|
||||
privateKeyPem: pemOk && pemLooksPrivate ? 'present' : 'MISSING_OR_INVALID',
|
||||
walletCount: Array.isArray(items) ? items.length : null,
|
||||
sampleNetworks: Array.isArray(items)
|
||||
? items.slice(0, 3).map((w) => w.network || w.chain || w.id).filter(Boolean)
|
||||
: [],
|
||||
error: res.ok ? undefined : body?.error?.message || body?.message || text.slice(0, 180),
|
||||
};
|
||||
}
|
||||
|
||||
async function testCobo(cobo) {
|
||||
const base = (cobo.COBO_API_BASE_URL || 'https://api.dev.cobo.com/v2').replace(/\/$/, '');
|
||||
const apiKey = cobo.COBO_API_KEY;
|
||||
const apiSecret = cobo.COBO_API_SECRET;
|
||||
if (!apiKey || !apiSecret) throw new Error('COBO_API_KEY / COBO_API_SECRET missing');
|
||||
|
||||
// Prefer @noble/ed25519 if available; else tweetnacl; else pure crypto createPrivateKey if possible
|
||||
let signEd25519;
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const noblePath = path.join(root, 'services/token-aggregation/node_modules/@noble/ed25519');
|
||||
if (fs.existsSync(noblePath)) {
|
||||
const ed = require(noblePath);
|
||||
signEd25519 = async (msg, sk) => {
|
||||
if (ed.signAsync) return Buffer.from(await ed.signAsync(msg, sk));
|
||||
return Buffer.from(ed.sign(msg, sk));
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
if (!signEd25519) {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const nacl = require(path.join(root, 'services/token-aggregation/node_modules/tweetnacl'));
|
||||
signEd25519 = async (msg, sk) => Buffer.from(nacl.sign.detached(msg, sk));
|
||||
} catch {
|
||||
/* use openssl-free path via dynamic import of noble from npm if needed */
|
||||
}
|
||||
}
|
||||
|
||||
// Install-free: use Node crypto for Ed25519 if secret is 32-byte seed
|
||||
if (!signEd25519) {
|
||||
signEd25519 = async (msg, skHex) => {
|
||||
// Node 20+: createPrivateKey from raw JWK-like — use sodium via webcrypto experimental
|
||||
// Fallback: spawn openssl not available for raw hex easily.
|
||||
// Use @noble via dynamic CDN-less: implement with crypto.createPrivateKey PKCS8
|
||||
throw new Error('Need @noble/ed25519 or tweetnacl — run npm install in scripts/custody');
|
||||
};
|
||||
}
|
||||
|
||||
const method = 'GET';
|
||||
const pathPart = '/v2/wallets';
|
||||
const params = 'limit=5';
|
||||
const body = '';
|
||||
const nonce = String(Date.now());
|
||||
const strToSign = `${method}|${pathPart}|${nonce}|${params}|${body}`;
|
||||
const contentHash = crypto.createHash('sha256').update(
|
||||
crypto.createHash('sha256').update(strToSign, 'utf8').digest(),
|
||||
).digest();
|
||||
|
||||
const sk = Buffer.from(apiSecret, 'hex');
|
||||
if (sk.length !== 32) {
|
||||
return {
|
||||
ok: false,
|
||||
http: 0,
|
||||
error: `API secret hex length ${sk.length} (expected 32 bytes / 64 hex)`,
|
||||
apiKeyPrefix: redact(apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
// tweetnacl needs 64-byte secret key (seed+pub); @noble accepts 32-byte seed
|
||||
let signatureHex;
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
let ed;
|
||||
try {
|
||||
ed = require('@noble/ed25519');
|
||||
} catch {
|
||||
ed = require(path.join(root, 'node_modules/@noble/ed25519'));
|
||||
}
|
||||
// noble v2 uses hashes.sha512
|
||||
if (ed.etc?.sha512Async == null && ed.hashes == null) {
|
||||
const { createHash } = await import('crypto');
|
||||
ed.etc = ed.etc || {};
|
||||
ed.etc.sha512Sync = (...m) => createHash('sha512').update(Buffer.concat(m.map((x) => Buffer.from(x)))).digest();
|
||||
}
|
||||
const sig = await (ed.signAsync ? ed.signAsync(contentHash, sk) : Promise.resolve(ed.sign(contentHash, sk)));
|
||||
signatureHex = Buffer.from(sig).toString('hex');
|
||||
} catch (e1) {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const nacl = require('tweetnacl');
|
||||
// Derive full secret key from seed
|
||||
const kp = nacl.sign.keyPair.fromSeed(sk);
|
||||
signatureHex = Buffer.from(nacl.sign.detached(contentHash, kp.secretKey)).toString('hex');
|
||||
} catch (e2) {
|
||||
return {
|
||||
ok: false,
|
||||
http: 0,
|
||||
error: `Ed25519 sign unavailable: ${e1.message}; ${e2.message}`,
|
||||
apiKeyPrefix: redact(apiKey),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Host already includes /v2 — request URL is base + /wallets
|
||||
const url = `${base}/wallets?${params}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Biz-Api-Key': apiKey,
|
||||
'Biz-Api-Nonce': nonce,
|
||||
'Biz-Api-Signature': signatureHex,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let bodyJson;
|
||||
try {
|
||||
bodyJson = JSON.parse(text);
|
||||
} catch {
|
||||
bodyJson = { raw: text.slice(0, 200) };
|
||||
}
|
||||
|
||||
const data = bodyJson?.data ?? bodyJson;
|
||||
const list = data?.data ?? data?.wallets ?? (Array.isArray(data) ? data : []);
|
||||
|
||||
return {
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
http: res.status,
|
||||
base,
|
||||
apiKeyPrefix: redact(apiKey),
|
||||
expires: cobo.COBO_EXPIRES,
|
||||
walletCount: Array.isArray(list) ? list.length : data?.total ?? null,
|
||||
error: res.ok
|
||||
? undefined
|
||||
: bodyJson?.error_message || bodyJson?.errorMessage || bodyJson?.message || text.slice(0, 220),
|
||||
};
|
||||
}
|
||||
|
||||
const dfns = loadEnvFile('secrets/dfns/credentials.env');
|
||||
const cobo = loadEnvFile('secrets/cobo/credentials.env');
|
||||
|
||||
const dfnsResult = await testDfns(dfns);
|
||||
const coboResult = await testCobo(cobo);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
saved: {
|
||||
dfnsEnv: 'secrets/dfns/credentials.env',
|
||||
dfnsPem: 'secrets/dfns/rsa2048.pem',
|
||||
coboEnv: 'secrets/cobo/credentials.env',
|
||||
gitignored: true,
|
||||
},
|
||||
dfns: dfnsResult,
|
||||
cobo: coboResult,
|
||||
bothOk: dfnsResult.ok && coboResult.ok,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
process.exit(dfnsResult.ok && coboResult.ok ? 0 : 1);
|
||||
120
scripts/deployment/btc-l1-settlement-e2e.mjs
Normal file
120
scripts/deployment/btc-l1-settlement-e2e.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* BTC L1 settlement E2E (regtest or simulated confirmed deposit).
|
||||
*
|
||||
* Modes:
|
||||
* --simulate Skip bitcoind; post a synthetic confirmed deposit to local settlement
|
||||
* (default) Prefer bitcoind RPC if BITCOIND_RPC_URL reachable
|
||||
*
|
||||
* Prerequisites: settlement-middleware on SETTLEMENT_MIDDLEWARE_URL (default :3011)
|
||||
* live BTC/USD via TOKEN_AGGREGATION_URL
|
||||
*/
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const settleBase = (process.env.SETTLEMENT_MIDDLEWARE_URL || 'http://127.0.0.1:3011').replace(/\/$/, '');
|
||||
const apiKey = process.env.OMNL_API_KEY || '';
|
||||
const simulate = process.argv.includes('--simulate');
|
||||
const amountSats = Number(process.env.BTC_E2E_AMOUNT_SATS || 100_000); // 0.001 BTC
|
||||
const recipient =
|
||||
process.env.BTC_E2E_RECIPIENT ||
|
||||
process.env.ALI_GENESIS_ADDRESS ||
|
||||
'0xa55A4B57A91561e9df5a883D4883Bd4b1a7C4882';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
};
|
||||
|
||||
async function settlePath(path) {
|
||||
// Local middleware mounts at /api/v1/settlement; reverse proxies may strip prefix.
|
||||
const candidates = [
|
||||
`${settleBase}/api/v1/settlement${path}`,
|
||||
`${settleBase}${path}`,
|
||||
];
|
||||
let lastErr;
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const res = await fetch(url, { headers });
|
||||
if (res.status !== 404) return { url, res };
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error(`No settlement route for ${path}`);
|
||||
}
|
||||
|
||||
async function postSettle(body) {
|
||||
const candidates = [
|
||||
`${settleBase}/api/v1/settlement/btc/l1-settle`,
|
||||
`${settleBase}/btc/l1-settle`,
|
||||
];
|
||||
let last;
|
||||
for (const url of candidates) {
|
||||
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = { raw: text };
|
||||
}
|
||||
last = { url, status: res.status, json };
|
||||
if (res.status !== 404) return last;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await settlePath('/health');
|
||||
const healthBody = await health.res.json();
|
||||
console.log('health', health.url, healthBody.status || healthBody);
|
||||
|
||||
const instructionId = randomUUID();
|
||||
const txId = simulate
|
||||
? `sim-${randomUUID().replace(/-/g, '').slice(0, 32)}`
|
||||
: process.env.BTC_E2E_TXID || `regtest-${Date.now().toString(16)}`;
|
||||
const depositAddress =
|
||||
process.env.BTC_E2E_DEPOSIT_ADDRESS || `bcrt1q${instructionId.replace(/-/g, '').slice(0, 38)}`;
|
||||
|
||||
const body = {
|
||||
idempotencyKey: `btc-e2e-${txId}`,
|
||||
instructionId,
|
||||
txId,
|
||||
depositAddress,
|
||||
amountSats,
|
||||
confirmations: Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6),
|
||||
recipientAddress: recipient,
|
||||
officeId: 24,
|
||||
};
|
||||
|
||||
console.log('posting', { amountSats, recipient, simulate, txId });
|
||||
const result = await postSettle(body);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
const ledger = await settlePath('/btc/ledger');
|
||||
if (ledger.res.ok) {
|
||||
console.log('ledger', await ledger.res.json());
|
||||
} else {
|
||||
console.log('ledger_status', ledger.res.status);
|
||||
}
|
||||
|
||||
if (result.status >= 400 && result.json?.phase !== 'SETTLED') {
|
||||
// FAILED phase returns 422 — still useful if journals posted partially
|
||||
if (result.json?.phase === 'FAILED') {
|
||||
console.error('Settlement FAILED:', result.json.errors);
|
||||
process.exit(2);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.json?.phase && result.json.phase !== 'SETTLED') {
|
||||
console.error('Unexpected phase', result.json.phase);
|
||||
process.exit(2);
|
||||
}
|
||||
console.log('E2E_OK', result.json?.settlementId, result.json?.phase);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
93
scripts/deployment/btc-regtest-bootstrap.mjs
Normal file
93
scripts/deployment/btc-regtest-bootstrap.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const RPC_USER = process.env.BITCOIND_RPC_USER || 'omnl';
|
||||
const RPC_PASS = process.env.BITCOIND_RPC_PASS || 'omnl-regtest-local';
|
||||
const RPC_PORT = Number(process.env.BITCOIND_RPC_PORT || 18443);
|
||||
|
||||
function rpc(method, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({ jsonrpc: '1.0', id: 'e2e', method, params });
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: RPC_PORT,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Basic ' + Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString('base64'),
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let d = '';
|
||||
res.on('data', (c) => (d += c));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const j = JSON.parse(d);
|
||||
if (j.error) reject(new Error(JSON.stringify(j.error)));
|
||||
else resolve(j.result);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function upsertEnv(file, key, value) {
|
||||
if (!fs.existsSync(file)) return;
|
||||
let txt = fs.readFileSync(file, 'utf8');
|
||||
const line = `${key}=${value}`;
|
||||
if (new RegExp(`^${key}=`, 'm').test(txt)) {
|
||||
txt = txt.replace(new RegExp(`^${key}=.*$`, 'm'), line);
|
||||
} else {
|
||||
txt += `\n${line}\n`;
|
||||
}
|
||||
fs.writeFileSync(file, txt);
|
||||
}
|
||||
|
||||
const info = await rpc('getblockchaininfo');
|
||||
console.log(`chain=${info.chain} blocks=${info.blocks}`);
|
||||
try {
|
||||
await rpc('createwallet', ['omnl']);
|
||||
} catch {
|
||||
/* exists */
|
||||
}
|
||||
try {
|
||||
await rpc('loadwallet', ['omnl']);
|
||||
} catch {
|
||||
/* loaded */
|
||||
}
|
||||
|
||||
const addr = await rpc('getnewaddress', ['reserve', 'bech32']);
|
||||
if (info.blocks < 101) {
|
||||
await rpc('generatetoaddress', [101 - info.blocks, addr]);
|
||||
}
|
||||
const bal = await rpc('getbalance');
|
||||
console.log(`REGTEST_READY addr=${addr} bal=${bal}`);
|
||||
|
||||
const dep = await rpc('getnewaddress', ['omnl-e2e-deposit', 'bech32']);
|
||||
const txid = await rpc('sendtoaddress', [dep, 0.001]);
|
||||
await rpc('generatetoaddress', [6, addr]);
|
||||
const tx = await rpc('gettransaction', [txid]);
|
||||
console.log(`DEPOSIT txid=${txid} conf=${tx.confirmations} to=${dep}`);
|
||||
|
||||
upsertEnv(path.resolve('.env'), 'BTC_RESERVE_ADDRESSES', addr);
|
||||
upsertEnv(path.resolve('config/deployment-omnl.production.env'), 'BTC_RESERVE_ADDRESSES', addr);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
reserve: addr,
|
||||
deposit: dep,
|
||||
txid,
|
||||
confirmations: tx.confirmations,
|
||||
amountSats: 100_000,
|
||||
}),
|
||||
);
|
||||
@@ -99,7 +99,7 @@ build_pkg() {
|
||||
|
||||
for dir in packages/integration-foundation packages/settlement-core \
|
||||
services/token-aggregation services/settlement-middleware services/dbis-exchange \
|
||||
services/swift-listener; do
|
||||
services/btc-intake services/swift-listener; do
|
||||
if [[ "$dir" == "services/swift-listener" ]]; then
|
||||
build_pkg "$dir" || log "WARN: swift-listener build skipped — SWIFT inbound may use existing dist"
|
||||
else
|
||||
@@ -170,6 +170,15 @@ start_svc() {
|
||||
AZURE_OPENAI_DEPLOYMENT="${AZURE_OPENAI_DEPLOYMENT:-}" \
|
||||
AZURE_OPENAI_API_VERSION="${AZURE_OPENAI_API_VERSION:-2024-02-15-preview}" \
|
||||
OMNL_MINT_OPERATOR_PRIVATE_KEY="${OMNL_MINT_OPERATOR_PRIVATE_KEY:-}" \
|
||||
OMNL_REQUIRE_LIVE_BTC_PRICE="${OMNL_REQUIRE_LIVE_BTC_PRICE:-1}" \
|
||||
BITCOIND_RPC_URL="${BITCOIND_RPC_URL:-}" \
|
||||
BITCOIND_RPC_USER="${BITCOIND_RPC_USER:-}" \
|
||||
BITCOIND_RPC_PASS="${BITCOIND_RPC_PASS:-}" \
|
||||
BTC_NETWORK="${BTC_NETWORK:-mainnet}" \
|
||||
BTC_RESERVE_ADDRESSES="${BTC_RESERVE_ADDRESSES:-}" \
|
||||
BTC_CONFIRMATIONS_REQUIRED="${BTC_CONFIRMATIONS_REQUIRED:-6}" \
|
||||
BTC_MAX_OUTSTANDING_SATS="${BTC_MAX_OUTSTANDING_SATS:-}" \
|
||||
SETTLEMENT_MIDDLEWARE_URL="${SETTLEMENT_MIDDLEWARE_URL:-http://127.0.0.1:3011}" \
|
||||
bash -c "$cmd" >"$LOG_DIR/$name.log" 2>&1 &
|
||||
echo $! >"$LOG_DIR/$name.pid"
|
||||
}
|
||||
@@ -181,6 +190,12 @@ sleep 2
|
||||
start_svc dbis-exchange services/dbis-exchange 3012 "npm run start"
|
||||
sleep 2
|
||||
|
||||
if [[ "${OMNL_BTC_INTAKE_START:-1}" == "1" ]] && [[ -n "${BITCOIND_RPC_URL:-}" ]]; then
|
||||
log "Starting btc-intake..."
|
||||
start_svc btc-intake services/btc-intake 3013 "npm run start"
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
if [[ "${OMNL_SWIFT_LISTENER_START:-1}" == "1" ]]; then
|
||||
log "Starting SWIFT listener..."
|
||||
bash "$REPO_DIR/scripts/deployment/start-swift-listener-production.sh" || log "WARN: SWIFT listener start failed"
|
||||
@@ -200,6 +215,10 @@ curl -sf "http://127.0.0.1:3011/api/v1/settlement/money-supply/public" | head -c
|
||||
echo
|
||||
curl -sf "http://127.0.0.1:3012/api/v1/exchange/health" | head -c 200 || true
|
||||
echo
|
||||
if [[ "${OMNL_BTC_INTAKE_START:-1}" == "1" ]] && [[ -n "${BITCOIND_RPC_URL:-}" ]]; then
|
||||
curl -sf "http://127.0.0.1:3013/health" | head -c 200 || log "WARN: btc-intake :3013 not reachable"
|
||||
echo
|
||||
fi
|
||||
curl -sf -o /dev/null -w "frontend=%{http_code}\n" "http://127.0.0.1:$FRONTEND_PORT/central-bank"
|
||||
curl -sf -o /dev/null -w "hub=%{http_code}\n" "http://127.0.0.1:$FRONTEND_PORT/hub"
|
||||
curl -sf "http://127.0.0.1:8788/health" 2>/dev/null | head -c 80 || log "WARN: SWIFT listener :8788 not reachable"
|
||||
|
||||
90
scripts/deployment/ensure-cbtc-pmm-pools.mjs
Normal file
90
scripts/deployment/ensure-cbtc-pmm-pools.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Ensure cBTC DODO PMM pools exist on Chain 138 and print CHAIN138_POOL_CBTC_* env lines.
|
||||
* Requires POOL_MANAGER_ROLE on DODOPMMIntegration (PRIVATE_KEY / OMNL_MINT_OPERATOR_PRIVATE_KEY).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Contract, JsonRpcProvider, Wallet, ZeroAddress } from 'ethers';
|
||||
|
||||
const rpc = process.env.RPC_URL_138 || process.env.CHAIN_138_RPC_URL || 'https://rpc.d-bis.org';
|
||||
const pk =
|
||||
process.env.PRIVATE_KEY?.trim() ||
|
||||
process.env.OMNL_MINT_OPERATOR_PRIVATE_KEY?.trim();
|
||||
const integrationAddr =
|
||||
process.env.DODO_PMM_INTEGRATION_ADDRESS ||
|
||||
process.env.CHAIN_138_DODO_PMM_INTEGRATION ||
|
||||
'0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
|
||||
|
||||
const CBTC = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
|
||||
const PAIRS = [
|
||||
{ env: 'CHAIN138_POOL_CBTC_CUSDT', quote: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22', sym: 'cUSDT' },
|
||||
{ env: 'CHAIN138_POOL_CBTC_CUSDC', quote: '0xf22258f57794CC8E06237084b353Ab30fFfa640b', sym: 'cUSDC' },
|
||||
{ env: 'CHAIN138_POOL_CBTC_CXAUC', quote: '0x290E52a8819A4fbD0714E517225429aA2B70EC6b', sym: 'cXAUC' },
|
||||
];
|
||||
|
||||
const LP_FEE = 3n;
|
||||
const INITIAL_PRICE = 10n ** 18n;
|
||||
const K_FACTOR = 5n * 10n ** 17n;
|
||||
const ENABLE_TWAP = false;
|
||||
|
||||
if (!pk) {
|
||||
console.error('PRIVATE_KEY or OMNL_MINT_OPERATOR_PRIVATE_KEY required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = new JsonRpcProvider(rpc);
|
||||
const wallet = new Wallet(pk, provider);
|
||||
const abi = [
|
||||
'function pools(address,address) view returns (address)',
|
||||
'function createPool(address,address,uint256,uint256,uint256,bool) returns (address)',
|
||||
'function POOL_MANAGER_ROLE() view returns (bytes32)',
|
||||
'function hasRole(bytes32,address) view returns (bool)',
|
||||
];
|
||||
const integration = new Contract(integrationAddr, abi, wallet);
|
||||
|
||||
function upsertEnv(file, key, value) {
|
||||
if (!fs.existsSync(file)) return;
|
||||
let txt = fs.readFileSync(file, 'utf8');
|
||||
const line = `${key}=${value}`;
|
||||
if (new RegExp(`^${key}=`, 'm').test(txt)) txt = txt.replace(new RegExp(`^${key}=.*$`, 'm'), line);
|
||||
else txt += `\n${line}\n`;
|
||||
fs.writeFileSync(file, txt);
|
||||
}
|
||||
|
||||
const role = await integration.POOL_MANAGER_ROLE();
|
||||
const canManage = await integration.hasRole(role, wallet.address);
|
||||
console.log(JSON.stringify({ operator: wallet.address, canManage, integration: integrationAddr }, null, 2));
|
||||
|
||||
const results = {};
|
||||
for (const pair of PAIRS) {
|
||||
let pool = await integration.pools(CBTC, pair.quote);
|
||||
if (!pool || pool === ZeroAddress) {
|
||||
pool = await integration.pools(pair.quote, CBTC);
|
||||
}
|
||||
if (pool && pool !== ZeroAddress) {
|
||||
console.log(`EXISTS ${pair.sym} -> ${pool}`);
|
||||
results[pair.env] = pool;
|
||||
continue;
|
||||
}
|
||||
if (!canManage) {
|
||||
console.error(`MISSING ${pair.sym} and operator lacks POOL_MANAGER_ROLE`);
|
||||
results[pair.env] = '';
|
||||
continue;
|
||||
}
|
||||
console.log(`CREATE ${pair.sym}...`);
|
||||
const tx = await integration.createPool(CBTC, pair.quote, LP_FEE, INITIAL_PRICE, K_FACTOR, ENABLE_TWAP);
|
||||
const receipt = await tx.wait(1);
|
||||
pool = await integration.pools(CBTC, pair.quote);
|
||||
console.log(`CREATED ${pair.sym} -> ${pool} tx=${receipt.hash}`);
|
||||
results[pair.env] = pool;
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(results)) {
|
||||
if (!v) continue;
|
||||
upsertEnv(path.resolve('.env'), k, v);
|
||||
upsertEnv(path.resolve('config/deployment-omnl.production.env'), k, v);
|
||||
console.log(`${k}=${v}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ results }, null, 2));
|
||||
151
scripts/deployment/hot-cbtc-liquidity-1000.mjs
Normal file
151
scripts/deployment/hot-cbtc-liquidity-1000.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bring cBTC/cUSDT PMM pool base reserve to >= TARGET_CBTC (default 1000).
|
||||
*
|
||||
* Env:
|
||||
* OMNL_MINT_OPERATOR_PRIVATE_KEY (required)
|
||||
* RPC_URL_138 (default https://rpc.d-bis.org)
|
||||
* HOT_CBTC_TARGET (default 1000)
|
||||
* HOT_CBTC_EXECUTE=1 (required to mint + addLiquidity)
|
||||
* BTC_USD_PRICE (optional override for quote sizing)
|
||||
*
|
||||
* Usage:
|
||||
* HOT_CBTC_EXECUTE=1 node scripts/deployment/hot-cbtc-liquidity-1000.mjs
|
||||
*/
|
||||
import { Contract, JsonRpcProvider, Wallet, MaxUint256, parseUnits, formatUnits } from 'ethers';
|
||||
|
||||
const CBTC = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
|
||||
const CUSDT = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22';
|
||||
const INTEGRATION = '0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
|
||||
const POOL_USDT = process.env.CHAIN138_POOL_CBTC_CUSDT || '0x481CcE1046e1F1ab13BC37f0f374bb5ff75F3a8d';
|
||||
const TARGET = Number(process.env.HOT_CBTC_TARGET || '1000');
|
||||
const EXECUTE = process.env.HOT_CBTC_EXECUTE === '1';
|
||||
|
||||
const ercAbi = [
|
||||
'function balanceOf(address) view returns (uint256)',
|
||||
'function approve(address,uint256) returns (bool)',
|
||||
'function allowance(address,address) view returns (uint256)',
|
||||
'function mint(address,uint256)',
|
||||
'function decimals() view returns (uint8)',
|
||||
];
|
||||
const poolAbi = [
|
||||
'function _BASE_RESERVE_() view returns (uint256)',
|
||||
'function _QUOTE_RESERVE_() view returns (uint256)',
|
||||
];
|
||||
const integAbi = ['function addLiquidity(address,uint256,uint256) returns (uint256,uint256,uint256)'];
|
||||
|
||||
async function main() {
|
||||
const pk = process.env.OMNL_MINT_OPERATOR_PRIVATE_KEY?.trim();
|
||||
if (!pk) throw new Error('OMNL_MINT_OPERATOR_PRIVATE_KEY required');
|
||||
const rpc = process.env.RPC_URL_138 || process.env.CHAIN_138_RPC_URL || 'https://rpc.d-bis.org';
|
||||
const provider = new JsonRpcProvider(rpc);
|
||||
const wallet = new Wallet(pk, provider);
|
||||
|
||||
const cbtc = new Contract(CBTC, ercAbi, wallet);
|
||||
const cusdt = new Contract(CUSDT, ercAbi, wallet);
|
||||
const integ = new Contract(INTEGRATION, integAbi, wallet);
|
||||
const pool = new Contract(POOL_USDT, poolAbi, provider);
|
||||
|
||||
const targetWei = parseUnits(String(TARGET), 8);
|
||||
const base = await pool._BASE_RESERVE_();
|
||||
const quote = await pool._QUOTE_RESERVE_();
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
pool: POOL_USDT,
|
||||
base: formatUnits(base, 8),
|
||||
quote: formatUnits(quote, 6),
|
||||
target: TARGET,
|
||||
execute: EXECUTE,
|
||||
operator: wallet.address,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
if (base >= targetWei) {
|
||||
console.log('OK pool already >= target');
|
||||
return;
|
||||
}
|
||||
|
||||
const needBase = targetWei - base;
|
||||
// Match current pool ratio; fallback to env BTC_USD_PRICE or ~65000
|
||||
const price =
|
||||
Number(process.env.BTC_USD_PRICE || '') ||
|
||||
(quote > 0n && base > 0n
|
||||
? Number(formatUnits(quote, 6)) / Number(formatUnits(base, 8))
|
||||
: 65000);
|
||||
const needQuote = parseUnits((Number(formatUnits(needBase, 8)) * price).toFixed(6), 6);
|
||||
|
||||
let opCbtc = await cbtc.balanceOf(wallet.address);
|
||||
const opUsdt = await cusdt.balanceOf(wallet.address);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
needBase: formatUnits(needBase, 8),
|
||||
needQuote: formatUnits(needQuote, 6),
|
||||
price,
|
||||
opCbtc: formatUnits(opCbtc, 8),
|
||||
opUsdt: formatUnits(opUsdt, 6),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
if (!EXECUTE) {
|
||||
console.log('DRY_RUN — set HOT_CBTC_EXECUTE=1 to mint + addLiquidity');
|
||||
return;
|
||||
}
|
||||
|
||||
if (opCbtc < needBase) {
|
||||
const mintAmt = needBase - opCbtc + parseUnits('0.01', 8);
|
||||
console.log('minting cBTC', formatUnits(mintAmt, 8));
|
||||
const tx = await cbtc.mint(wallet.address, mintAmt);
|
||||
console.log('mintTx', tx.hash);
|
||||
await tx.wait();
|
||||
opCbtc = await cbtc.balanceOf(wallet.address);
|
||||
}
|
||||
|
||||
if (opUsdt < needQuote) {
|
||||
throw new Error(
|
||||
`Insufficient cUSDT: have ${formatUnits(opUsdt, 6)} need ${formatUnits(needQuote, 6)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureAllow(token, spender, needed) {
|
||||
const a = await token.allowance(wallet.address, spender);
|
||||
if (a < needed) {
|
||||
const tx = await token.approve(spender, MaxUint256);
|
||||
await tx.wait();
|
||||
}
|
||||
}
|
||||
|
||||
await ensureAllow(cbtc, INTEGRATION, needBase);
|
||||
await ensureAllow(cusdt, INTEGRATION, needQuote);
|
||||
|
||||
console.log('addLiquidity', formatUnits(needBase, 8), 'cBTC +', formatUnits(needQuote, 6), 'cUSDT');
|
||||
const lpTx = await integ.addLiquidity(POOL_USDT, needBase, needQuote);
|
||||
console.log('lpTx', lpTx.hash);
|
||||
await lpTx.wait();
|
||||
|
||||
const baseAfter = await pool._BASE_RESERVE_();
|
||||
const quoteAfter = await pool._QUOTE_RESERVE_();
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
baseAfter: formatUnits(baseAfter, 8),
|
||||
quoteAfter: formatUnits(quoteAfter, 6),
|
||||
hot: baseAfter >= targetWei,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
109
scripts/deployment/seed-btc-custody-gl-accounts.mjs
Normal file
109
scripts/deployment/seed-btc-custody-gl-accounts.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Ensure Fineract GL accounts for L1 BTC custody exist:
|
||||
* 12015 — Digital asset reserves — BTC (L1 mainnet custody) [ASSET]
|
||||
* 12424 — Office 24 — digital asset custody — BTC [ASSET]
|
||||
*
|
||||
* Usage:
|
||||
* node --env-file=.env scripts/deployment/seed-btc-custody-gl-accounts.mjs
|
||||
* node --env-file=.env scripts/deployment/seed-btc-custody-gl-accounts.mjs --dry-run
|
||||
*/
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
const ACCOUNTS = [
|
||||
{
|
||||
glCode: '12015',
|
||||
name: 'Digital asset reserves — BTC (L1 mainnet custody)',
|
||||
description: 'HO native BTC L1 custody; debit on confirmed receipt to registered HO address',
|
||||
type: 1, // ASSET
|
||||
usage: 1, // DETAIL
|
||||
manualEntriesAllowed: true,
|
||||
},
|
||||
{
|
||||
glCode: '12424',
|
||||
name: 'Office 24 — digital asset custody — BTC (sent to HO)',
|
||||
description: 'Credit on sender-office leg when HO confirms L1 BTC from HOSPITALLERS-24',
|
||||
type: 1,
|
||||
usage: 1,
|
||||
manualEntriesAllowed: true,
|
||||
},
|
||||
];
|
||||
|
||||
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() ||
|
||||
'ali_hospitallers_tenant';
|
||||
const pass = process.env.OMNL_FINERACT_PASSWORD || '';
|
||||
|
||||
if (!base || !pass) {
|
||||
console.error('Set OMNL_FINERACT_BASE_URL and OMNL_FINERACT_PASSWORD');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const auth = Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
const headers = {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Fineract-Platform-TenantId': tenant,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
async function findByCode(glCode) {
|
||||
const res = await fetch(`${base}/glaccounts?glCode=${encodeURIComponent(glCode)}`, { headers });
|
||||
if (!res.ok) {
|
||||
// fallback list
|
||||
const all = await fetch(`${base}/glaccounts?limit=1000`, { headers });
|
||||
if (!all.ok) throw new Error(`GL list failed (${all.status})`);
|
||||
const data = await all.json();
|
||||
const list = Array.isArray(data) ? data : data.pageItems || [];
|
||||
return list.find((r) => String(r.glCode) === glCode) || null;
|
||||
}
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : data.pageItems || [];
|
||||
return list.find((r) => String(r.glCode) === glCode) || list[0] || null;
|
||||
}
|
||||
|
||||
async function ensureAccount(spec) {
|
||||
const existing = await findByCode(spec.glCode);
|
||||
if (existing?.id) {
|
||||
return { glCode: spec.glCode, status: 'exists', id: existing.id, name: existing.name };
|
||||
}
|
||||
if (dryRun) {
|
||||
return { glCode: spec.glCode, status: 'would_create', payload: spec };
|
||||
}
|
||||
const res = await fetch(`${base}/glaccounts`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(spec),
|
||||
});
|
||||
const body = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`Create ${spec.glCode} failed (${res.status}): ${body}`);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
parsed = { raw: body };
|
||||
}
|
||||
return {
|
||||
glCode: spec.glCode,
|
||||
status: 'created',
|
||||
id: parsed.resourceId ?? parsed.entityId,
|
||||
response: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const spec of ACCOUNTS) {
|
||||
results.push(await ensureAccount(spec));
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ dryRun, tenant, base, results }, null, 2));
|
||||
|
||||
const missing = results.filter((r) => r.status === 'would_create' || r.status === 'created' || r.status === 'exists');
|
||||
if (missing.length !== ACCOUNTS.length) {
|
||||
process.exit(1);
|
||||
}
|
||||
58
scripts/deployment/smoke-btc-settlement.mjs
Normal file
58
scripts/deployment/smoke-btc-settlement.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Smoke local settlement BTC rails after rebuild.
|
||||
* Usage: node scripts/deployment/smoke-btc-settlement.mjs
|
||||
* Env: SETTLEMENT_URL (default http://127.0.0.1:3011/api/v1/settlement)
|
||||
* OMNL_API_KEY or SETTLEMENT_API_KEY
|
||||
*/
|
||||
const base = (process.env.SETTLEMENT_URL || 'http://127.0.0.1:3011/api/v1/settlement').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const key = process.env.OMNL_API_KEY || process.env.SETTLEMENT_API_KEY || '';
|
||||
|
||||
async function get(path) {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
||||
});
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
const health = await get('/health');
|
||||
const ledger = await get('/btc/ledger');
|
||||
const settlements = await get('/btc/settlements?limit=10');
|
||||
|
||||
const settledSats = ledger.body?.mintQueue?.settledSats ?? 0;
|
||||
const ok =
|
||||
health.status === 200 &&
|
||||
ledger.status === 200 &&
|
||||
settlements.status === 200 &&
|
||||
Number(settledSats) >= 100_000_000_000;
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
base,
|
||||
health: health.status,
|
||||
ledger: ledger.status,
|
||||
settlements: settlements.status,
|
||||
custodyHo12015: ledger.body?.gl?.custodyHo12015,
|
||||
settledSats,
|
||||
settlementCount: ledger.body?.mintQueue?.settlementCount,
|
||||
hot1000Settled: Number(settledSats) >= 100_000_000_000,
|
||||
ok,
|
||||
prodHint:
|
||||
'After deploy, set SETTLEMENT_URL=https://secure.omdnl.org/settlement and re-run this smoke',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(ok ? 0 : 1);
|
||||
64
scripts/deployment/verify-cbtc-trade.mjs
Normal file
64
scripts/deployment/verify-cbtc-trade.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify cBTC is tradable on the hot cUSDT PMM via classic DODO DVM pattern:
|
||||
* transfer base → pool, then pool.sellBase(trader).
|
||||
*
|
||||
* Env: OMNL_MINT_OPERATOR_PRIVATE_KEY, RPC_URL_138, VERIFY_CBTC_SELL (default 0.0001)
|
||||
*/
|
||||
import { Contract, JsonRpcProvider, Wallet, parseUnits, formatUnits } from 'ethers';
|
||||
|
||||
const CBTC = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
|
||||
const POOL = process.env.CHAIN138_POOL_CBTC_CUSDT || '0x481CcE1046e1F1ab13BC37f0f374bb5ff75F3a8d';
|
||||
const pk = process.env.OMNL_MINT_OPERATOR_PRIVATE_KEY?.trim() || process.env.PRIVATE_KEY?.trim();
|
||||
if (!pk) {
|
||||
console.error('OMNL_MINT_OPERATOR_PRIVATE_KEY required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = new JsonRpcProvider(
|
||||
process.env.RPC_URL_138 || process.env.CHAIN_138_RPC_URL || 'https://rpc.d-bis.org',
|
||||
);
|
||||
const wallet = new Wallet(pk, provider);
|
||||
const sellHuman = process.env.VERIFY_CBTC_SELL || '0.0001';
|
||||
|
||||
const erc = [
|
||||
'function transfer(address,uint256) returns (bool)',
|
||||
'function balanceOf(address) view returns (uint256)',
|
||||
];
|
||||
const poolAbi = [
|
||||
'function _BASE_RESERVE_() view returns (uint256)',
|
||||
'function _QUOTE_RESERVE_() view returns (uint256)',
|
||||
'function querySellBase(address,uint256) view returns (uint256,uint256)',
|
||||
'function sellBase(address to) returns (uint256)',
|
||||
];
|
||||
|
||||
const cbtc = new Contract(CBTC, erc, wallet);
|
||||
const pool = new Contract(POOL, poolAbi, wallet);
|
||||
|
||||
const base = await pool._BASE_RESERVE_();
|
||||
const quote = await pool._QUOTE_RESERVE_();
|
||||
const sellAmt = parseUnits(sellHuman, 8);
|
||||
const bal = await cbtc.balanceOf(wallet.address);
|
||||
|
||||
const out = {
|
||||
pool: POOL,
|
||||
reserves: { base: formatUnits(base, 8), quote: formatUnits(quote, 6) },
|
||||
hot: base >= parseUnits('1000', 8),
|
||||
operatorBal: formatUnits(bal, 8),
|
||||
sell: sellHuman,
|
||||
};
|
||||
|
||||
if (bal < sellAmt) {
|
||||
console.log(JSON.stringify({ ...out, ok: false, error: 'insufficient operator cBTC' }, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const q = await pool.querySellBase(wallet.address, sellAmt);
|
||||
out.quotedUsdt = formatUnits(q[0], 6);
|
||||
|
||||
const t = await cbtc.transfer(POOL, sellAmt);
|
||||
await t.wait();
|
||||
const tx = await pool.sellBase(wallet.address);
|
||||
await tx.wait();
|
||||
|
||||
console.log(JSON.stringify({ ...out, ok: true, transferTx: t.hash, sellTx: tx.hash }, null, 2));
|
||||
22
services/btc-intake/Dockerfile
Normal file
22
services/btc-intake/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
RUN npm ci
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
RUN mkdir -p /app/data && addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 \
|
||||
&& chown -R nodejs:nodejs /app/data
|
||||
USER nodejs
|
||||
EXPOSE 3013
|
||||
ENV BTC_INTAKE_PORT=3013
|
||||
ENV BTC_INTAKE_DATA_DIR=/app/data
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3013/health',r=>process.exit(r.statusCode===200||r.statusCode===503?0:1))"
|
||||
CMD ["node", "dist/server.js"]
|
||||
3112
services/btc-intake/package-lock.json
generated
Normal file
3112
services/btc-intake/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,23 @@
|
||||
{
|
||||
"name": "btc-intake-service",
|
||||
"version": "1.0.0",
|
||||
"description": "BTC intake watcher for Chain 138 cBTC mint workflows",
|
||||
"description": "BTC intake watcher for Chain 138 cBTC mint workflows — bitcoind custody + settlement-middleware sink",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "ts-node src/server.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2",
|
||||
"express": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^20.19.33",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
|
||||
92
services/btc-intake/src/adapters/bitcoind-custody.test.ts
Normal file
92
services/btc-intake/src/adapters/bitcoind-custody.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { BitcoindCustodyAdapter } from './bitcoind-custody';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
describe('BitcoindCustodyAdapter', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'btc-intake-'));
|
||||
const posted: Array<{ method: string; params: unknown[] }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
posted.length = 0;
|
||||
process.env.BTC_INTAKE_DATA_DIR = tmp;
|
||||
process.env.BITCOIND_RPC_URL = 'http://127.0.0.1:18332';
|
||||
process.env.BITCOIND_RPC_USER = 'u';
|
||||
process.env.BITCOIND_RPC_PASS = 'p';
|
||||
process.env.BTC_CONFIRMATIONS_REQUIRED = '6';
|
||||
|
||||
vi.mocked(axios.create).mockReturnValue({
|
||||
post: vi.fn(async (_url: string, body: { method: string; params: unknown[] }) => {
|
||||
posted.push({ method: body.method, params: body.params || [] });
|
||||
if (body.method === 'getnewaddress') {
|
||||
return { data: { result: 'bcrt1qtestaddress0000000000000001' } };
|
||||
}
|
||||
if (body.method === 'listtransactions') {
|
||||
return {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
category: 'receive',
|
||||
address: 'bcrt1qtestaddress0000000000000001',
|
||||
txid: 'abc123',
|
||||
amount: 0.01,
|
||||
confirmations: 6,
|
||||
label: 'omnl-inst-1',
|
||||
time: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (body.method === 'getbalance') {
|
||||
return { data: { result: 0.01 } };
|
||||
}
|
||||
if (body.method === 'getblockchaininfo') {
|
||||
return { data: { result: { chain: 'regtest' } } };
|
||||
}
|
||||
return { data: { result: null } };
|
||||
}),
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('allocates bech32 deposit address and maps instruction', async () => {
|
||||
const adapter = new BitcoindCustodyAdapter();
|
||||
const address = await adapter.allocateDepositAddress({
|
||||
instructionId: 'inst-1',
|
||||
clientId: 'client-1',
|
||||
});
|
||||
expect(address).toBe('bcrt1qtestaddress0000000000000001');
|
||||
expect(posted.some((p) => p.method === 'getnewaddress')).toBe(true);
|
||||
const mapPath = path.join(tmp, 'address-map.json');
|
||||
expect(fs.existsSync(mapPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('lists deposit events for mapped addresses', async () => {
|
||||
const adapter = new BitcoindCustodyAdapter();
|
||||
await adapter.allocateDepositAddress({
|
||||
instructionId: 'inst-1',
|
||||
clientId: 'client-1',
|
||||
});
|
||||
const { events } = await adapter.listDepositEvents();
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].txId).toBe('abc123');
|
||||
expect(events[0].amountSats).toBe(1_000_000);
|
||||
expect(events[0].confirmations).toBe(6);
|
||||
expect(events[0].instructionId).toBe('inst-1');
|
||||
});
|
||||
|
||||
it('reports confirmed reserve balance in sats', async () => {
|
||||
const adapter = new BitcoindCustodyAdapter();
|
||||
const sats = await adapter.getConfirmedReserveBalanceSats();
|
||||
expect(sats).toBe(1_000_000);
|
||||
});
|
||||
});
|
||||
163
services/btc-intake/src/adapters/bitcoind-custody.ts
Normal file
163
services/btc-intake/src/adapters/bitcoind-custody.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { CustodyAdapter } from '../custody-adapter';
|
||||
import type { BitcoinDepositEvent } from '../types';
|
||||
|
||||
type AddressMap = Record<string, string>; // address → instructionId
|
||||
|
||||
type BitcoindTx = {
|
||||
address?: string;
|
||||
category?: string;
|
||||
amount?: number;
|
||||
confirmations?: number;
|
||||
txid?: string;
|
||||
label?: string;
|
||||
time?: number;
|
||||
};
|
||||
|
||||
function dataDir(): string {
|
||||
const dir =
|
||||
process.env.BTC_INTAKE_DATA_DIR ||
|
||||
path.resolve(process.cwd(), 'data/btc-intake');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function addressMapPath(): string {
|
||||
return path.join(dataDir(), 'address-map.json');
|
||||
}
|
||||
|
||||
function loadAddressMap(): AddressMap {
|
||||
const p = addressMapPath();
|
||||
if (!fs.existsSync(p)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8')) as AddressMap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveAddressMap(map: AddressMap): void {
|
||||
fs.writeFileSync(addressMapPath(), JSON.stringify(map, null, 2));
|
||||
}
|
||||
|
||||
function btcToSats(amountBtc: number): number {
|
||||
return Math.round(amountBtc * 1e8);
|
||||
}
|
||||
|
||||
export class BitcoindCustodyAdapter implements CustodyAdapter {
|
||||
private readonly client: AxiosInstance;
|
||||
private addressMap: AddressMap;
|
||||
private processedTxKeys = new Set<string>();
|
||||
|
||||
constructor(opts?: {
|
||||
rpcUrl?: string;
|
||||
rpcUser?: string;
|
||||
rpcPass?: string;
|
||||
}) {
|
||||
const rpcUrl = (opts?.rpcUrl || process.env.BITCOIND_RPC_URL || '').replace(/\/$/, '');
|
||||
const rpcUser = opts?.rpcUser || process.env.BITCOIND_RPC_USER || '';
|
||||
const rpcPass = opts?.rpcPass || process.env.BITCOIND_RPC_PASS || '';
|
||||
if (!rpcUrl) {
|
||||
throw new Error('BITCOIND_RPC_URL required');
|
||||
}
|
||||
this.client = axios.create({
|
||||
baseURL: rpcUrl,
|
||||
timeout: 60000,
|
||||
auth: rpcUser ? { username: rpcUser, password: rpcPass } : undefined,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
this.addressMap = loadAddressMap();
|
||||
}
|
||||
|
||||
private async rpc<T>(method: string, params: unknown[] = []): Promise<T> {
|
||||
const { data } = await this.client.post('', {
|
||||
jsonrpc: '1.0',
|
||||
id: `btc-intake-${Date.now()}`,
|
||||
method,
|
||||
params,
|
||||
});
|
||||
if (data?.error) {
|
||||
throw new Error(`bitcoind RPC ${method}: ${JSON.stringify(data.error)}`);
|
||||
}
|
||||
return data.result as T;
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
await this.rpc('getblockchaininfo');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async allocateDepositAddress(input: {
|
||||
instructionId: string;
|
||||
clientId: string;
|
||||
}): Promise<string> {
|
||||
const label = `omnl-${input.instructionId}`;
|
||||
const address = await this.rpc<string>('getnewaddress', [label, 'bech32']);
|
||||
this.addressMap[address] = input.instructionId;
|
||||
this.addressMap[address.toLowerCase()] = input.instructionId;
|
||||
saveAddressMap(this.addressMap);
|
||||
return address;
|
||||
}
|
||||
|
||||
async listDepositEvents(
|
||||
cursor?: string,
|
||||
): Promise<{ events: BitcoinDepositEvent[]; cursor?: string }> {
|
||||
const skip = cursor ? Number(cursor) || 0 : 0;
|
||||
const count = 100;
|
||||
const txs = await this.rpc<BitcoindTx[]>('listtransactions', ['*', count, skip, true]);
|
||||
const events: BitcoinDepositEvent[] = [];
|
||||
const nextCursor = String(skip + txs.length);
|
||||
|
||||
for (const tx of txs) {
|
||||
if (tx.category !== 'receive' || !tx.txid || !tx.address) continue;
|
||||
const instructionId =
|
||||
this.addressMap[tx.address] ||
|
||||
this.addressMap[tx.address.toLowerCase()] ||
|
||||
(tx.label?.startsWith('omnl-') ? tx.label.slice(5) : undefined);
|
||||
if (!instructionId) continue;
|
||||
|
||||
const key = `${tx.txid}:${tx.address}:${instructionId}`;
|
||||
if (this.processedTxKeys.has(key) && (tx.confirmations ?? 0) >= 6) {
|
||||
// Still emit updates for confirmation progression on first passes;
|
||||
// allow re-emit when confirmations change by not permanently skipping early.
|
||||
}
|
||||
this.processedTxKeys.add(key);
|
||||
|
||||
events.push({
|
||||
instructionId,
|
||||
txId: tx.txid,
|
||||
confirmations: Math.max(0, tx.confirmations ?? 0),
|
||||
amountSats: btcToSats(Number(tx.amount) || 0),
|
||||
type: (tx.confirmations ?? 0) < 0 ? 'reorg' : 'deposit',
|
||||
observedAt: tx.time
|
||||
? new Date(tx.time * 1000).toISOString()
|
||||
: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return { events, cursor: nextCursor };
|
||||
}
|
||||
|
||||
async getConfirmedReserveBalanceSats(): Promise<number> {
|
||||
const minConf = Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6);
|
||||
const reserveCsv = (process.env.BTC_RESERVE_ADDRESSES || '').trim();
|
||||
if (reserveCsv) {
|
||||
const addresses = reserveCsv.split(',').map((a) => a.trim()).filter(Boolean);
|
||||
let total = 0;
|
||||
for (const address of addresses) {
|
||||
const received = await this.rpc<number>('getreceivedbyaddress', [address, minConf]);
|
||||
total += btcToSats(Number(received) || 0);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
// Fall back to wallet confirmed balance
|
||||
const bal = await this.rpc<number>('getbalance', ['*', minConf]);
|
||||
return btcToSats(Number(bal) || 0);
|
||||
}
|
||||
}
|
||||
92
services/btc-intake/src/adapters/settlement-mint-sink.ts
Normal file
92
services/btc-intake/src/adapters/settlement-mint-sink.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import axios from 'axios';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { MintJobSink } from '../custody-adapter';
|
||||
import type { MintJob } from '../types';
|
||||
|
||||
type JobRecord = MintJob & {
|
||||
settlementId?: string;
|
||||
settlementPhase?: string;
|
||||
lastError?: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function dataDir(): string {
|
||||
const dir =
|
||||
process.env.BTC_INTAKE_DATA_DIR ||
|
||||
path.resolve(process.cwd(), 'data/btc-intake');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function jobPath(id: string): string {
|
||||
return path.join(dataDir(), `mint-job-${id}.json`);
|
||||
}
|
||||
|
||||
function saveJob(job: JobRecord): void {
|
||||
fs.writeFileSync(jobPath(job.id), JSON.stringify(job, null, 2));
|
||||
}
|
||||
|
||||
export class SettlementMintJobSink implements MintJobSink {
|
||||
private readonly settlementBase: string;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(opts?: { settlementUrl?: string; apiKey?: string }) {
|
||||
this.settlementBase = (
|
||||
opts?.settlementUrl ||
|
||||
process.env.SETTLEMENT_MIDDLEWARE_URL ||
|
||||
'http://localhost:3011'
|
||||
).replace(/\/$/, '');
|
||||
this.apiKey = opts?.apiKey || process.env.OMNL_API_KEY || '';
|
||||
}
|
||||
|
||||
async enqueue(job: MintJob): Promise<void> {
|
||||
const record: JobRecord = {
|
||||
...job,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
saveJob(record);
|
||||
|
||||
if (!job.txId || !job.depositAddress) {
|
||||
record.lastError = 'MintJob missing txId or depositAddress — cannot settle';
|
||||
saveJob(record);
|
||||
throw new Error(record.lastError);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
|
||||
|
||||
const body = {
|
||||
idempotencyKey: `btc-l1-${job.txId}-${job.instructionId}`,
|
||||
instructionId: job.instructionId,
|
||||
txId: job.txId,
|
||||
depositAddress: job.depositAddress,
|
||||
amountSats: job.amountSats,
|
||||
confirmations: job.confirmations ?? Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6),
|
||||
recipientAddress: job.chain138VaultAddress,
|
||||
};
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${this.settlementBase}/api/v1/settlement/btc/l1-settle`,
|
||||
body,
|
||||
{ headers, timeout: 180000 },
|
||||
);
|
||||
record.settlementId = data.settlementId;
|
||||
record.settlementPhase = data.phase;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
if (data.phase === 'FAILED') {
|
||||
record.lastError = Array.isArray(data.errors) ? data.errors.join('; ') : 'settlement FAILED';
|
||||
}
|
||||
saveJob(record);
|
||||
if (data.phase === 'FAILED') {
|
||||
throw new Error(record.lastError || 'btc l1 settle failed');
|
||||
}
|
||||
} catch (err) {
|
||||
record.lastError = err instanceof Error ? err.message : String(err);
|
||||
record.updatedAt = new Date().toISOString();
|
||||
saveJob(record);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ export interface MintJobSink {
|
||||
amountSats: number;
|
||||
status: 'queued';
|
||||
createdAt: string;
|
||||
txId?: string;
|
||||
depositAddress?: string;
|
||||
confirmations?: number;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './types';
|
||||
export * from './custody-adapter';
|
||||
export * from './native-bitcoin-watcher';
|
||||
export { BitcoindCustodyAdapter } from './adapters/bitcoind-custody';
|
||||
export { SettlementMintJobSink } from './adapters/settlement-mint-sink';
|
||||
|
||||
@@ -118,6 +118,9 @@ describe('NativeBitcoinWatcher', () => {
|
||||
instructionId: instruction.id,
|
||||
amountSats: 250_000_000,
|
||||
canonicalSymbol: 'cBTC',
|
||||
txId: 'tx-1',
|
||||
depositAddress: instruction.depositAddress,
|
||||
confirmations: 6,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -203,6 +203,9 @@ export class NativeBitcoinWatcher {
|
||||
amountSats,
|
||||
status: 'queued',
|
||||
createdAt: timestamp,
|
||||
txId: instruction.observedTxId,
|
||||
depositAddress: instruction.depositAddress,
|
||||
confirmations: instruction.currentConfirmations,
|
||||
};
|
||||
|
||||
await this.mintSink.enqueue(mintJob);
|
||||
|
||||
142
services/btc-intake/src/server.ts
Normal file
142
services/btc-intake/src/server.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import express from 'express';
|
||||
import type { AuditPublisher, OutstandingPolicy } from './custody-adapter';
|
||||
import { BitcoindCustodyAdapter } from './adapters/bitcoind-custody';
|
||||
import { SettlementMintJobSink } from './adapters/settlement-mint-sink';
|
||||
import { NativeBitcoinWatcher } from './native-bitcoin-watcher';
|
||||
import type { AuditRecord, BasketMandateSnapshot } from './types';
|
||||
|
||||
const PORT = Number(process.env.BTC_INTAKE_PORT || 3013);
|
||||
const SYNC_INTERVAL_MS = Number(process.env.BTC_INTAKE_SYNC_INTERVAL_MS || 30_000);
|
||||
|
||||
class ConsoleAuditPublisher implements AuditPublisher {
|
||||
async publish(record: AuditRecord): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
level: 'audit',
|
||||
service: 'btc-intake',
|
||||
...record,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EnvOutstandingPolicy implements OutstandingPolicy {
|
||||
async getCurrentOutstandingSats(): Promise<number> {
|
||||
return Number(process.env.BTC_CURRENT_OUTSTANDING_SATS || 0);
|
||||
}
|
||||
|
||||
async getMaxOutstandingSats(): Promise<number> {
|
||||
return Number(process.env.BTC_MAX_OUTSTANDING_SATS || 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const adapter = new BitcoindCustodyAdapter();
|
||||
const mintSink = new SettlementMintJobSink();
|
||||
const audit = new ConsoleAuditPublisher();
|
||||
const outstanding = new EnvOutstandingPolicy();
|
||||
const watcher = new NativeBitcoinWatcher(adapter, mintSink, audit, outstanding);
|
||||
|
||||
let lastSyncAt: string | null = null;
|
||||
let lastSyncError: string | null = null;
|
||||
let syncing = false;
|
||||
|
||||
const runSync = async () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
try {
|
||||
await watcher.sync();
|
||||
lastSyncAt = new Date().toISOString();
|
||||
lastSyncError = null;
|
||||
} catch (err) {
|
||||
lastSyncError = err instanceof Error ? err.message : String(err);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[btc-intake] sync error', lastSyncError);
|
||||
} finally {
|
||||
syncing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
app.get('/health', async (_req, res) => {
|
||||
const bitcoindOk = await adapter.ping();
|
||||
res.status(bitcoindOk ? 200 : 503).json({
|
||||
service: 'btc-intake',
|
||||
status: bitcoindOk ? 'ok' : 'degraded',
|
||||
bitcoind: bitcoindOk,
|
||||
lastSyncAt,
|
||||
lastSyncError,
|
||||
syncing,
|
||||
network: process.env.BTC_NETWORK || 'mainnet',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/deposit-instructions', async (req, res) => {
|
||||
try {
|
||||
const basketMandate = req.body?.basketMandate as BasketMandateSnapshot | undefined;
|
||||
if (
|
||||
!basketMandate?.id ||
|
||||
!basketMandate.clientId ||
|
||||
!basketMandate.chain138VaultAddress?.startsWith('0x')
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'basketMandate with id, clientId, chain138VaultAddress (0x) required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const expectedAmountSats =
|
||||
req.body?.expectedAmountSats != null
|
||||
? Number(req.body.expectedAmountSats)
|
||||
: undefined;
|
||||
const instruction = await watcher.createDepositInstruction({
|
||||
basketMandate,
|
||||
expectedAmountSats,
|
||||
});
|
||||
res.status(201).json(instruction);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/deposit-instructions/:id', (req, res) => {
|
||||
const instruction = watcher.getInstruction(req.params.id);
|
||||
if (!instruction) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
res.json(instruction);
|
||||
});
|
||||
|
||||
app.post('/sync', async (_req, res) => {
|
||||
await runSync();
|
||||
res.json({
|
||||
ok: !lastSyncError,
|
||||
lastSyncAt,
|
||||
lastSyncError,
|
||||
mintJobs: watcher.getMintJobs(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/mint-jobs', (_req, res) => {
|
||||
res.json({ jobs: watcher.getMintJobs() });
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
void runSync();
|
||||
}, SYNC_INTERVAL_MS);
|
||||
void runSync();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[btc-intake] listening on :${PORT} (sync every ${SYNC_INTERVAL_MS}ms)`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[btc-intake] fatal', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -47,6 +47,10 @@ export interface MintJob {
|
||||
amountSats: number;
|
||||
status: 'queued';
|
||||
createdAt: string;
|
||||
/** L1 tx id when known at enqueue time */
|
||||
txId?: string;
|
||||
depositAddress?: string;
|
||||
confirmations?: number;
|
||||
}
|
||||
|
||||
export interface AuditRecord {
|
||||
|
||||
@@ -6,10 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.fineractOfficeReachable = fineractOfficeReachable;
|
||||
exports.postJournalEntry = postJournalEntry;
|
||||
exports.resolveGlAccountId = resolveGlAccountId;
|
||||
exports.resolveBtcGlAccountIds = resolveBtcGlAccountIds;
|
||||
exports.fetchGlBalances = fetchGlBalances;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
const GL_HINTS = ['1000', '1050', '1410', '2000', '2100', '2200', '2300'];
|
||||
const GL_HINTS = ['1000', '1050', '12015', '12424', '1410', '2000', '2100', '2200', '2300'];
|
||||
function fineractBase() {
|
||||
return (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, '');
|
||||
}
|
||||
@@ -144,7 +145,13 @@ async function fetchGlBalancesFromJournals(officeId) {
|
||||
return out;
|
||||
}
|
||||
async function postJournalEntry(entry) {
|
||||
const { data } = await axios_1.default.post(`${fineractBase()}/journalentries`, entry, {
|
||||
const payload = {
|
||||
...entry,
|
||||
currencyCode: 'USD',
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
locale: 'en',
|
||||
};
|
||||
const { data } = await axios_1.default.post(`${fineractBase()}/journalentries`, payload, {
|
||||
headers: authHeaders(),
|
||||
timeout: 60000,
|
||||
});
|
||||
@@ -170,6 +177,16 @@ async function resolveGlAccountId(glCode) {
|
||||
glCodeCache.set(code, match.id);
|
||||
return match.id;
|
||||
}
|
||||
/** Resolve Fineract GL account ids for L1 BTC custody legs (HO 12015 / Office 24 12424). */
|
||||
async function resolveBtcGlAccountIds(_officeId) {
|
||||
const custodyHoGlCode = '12015';
|
||||
const interofficeBtcGlCode = '12424';
|
||||
const [custodyHoGlId, interofficeBtcGlId] = await Promise.all([
|
||||
resolveGlAccountId(custodyHoGlCode),
|
||||
resolveGlAccountId(interofficeBtcGlCode),
|
||||
]);
|
||||
return { custodyHoGlId, interofficeBtcGlId, custodyHoGlCode, interofficeBtcGlCode };
|
||||
}
|
||||
async function fetchGlBalances(officeId) {
|
||||
const base = fineractBase();
|
||||
if (!base || !process.env.OMNL_FINERACT_PASSWORD)
|
||||
|
||||
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.archiveIso20022 = archiveIso20022;
|
||||
exports.requestTokenMint = requestTokenMint;
|
||||
exports.requestTokenTransfer = requestTokenTransfer;
|
||||
exports.requestFiatLpToLiquidity = requestFiatLpToLiquidity;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
async function archiveIso20022(payload) {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
@@ -65,3 +66,16 @@ async function requestTokenTransfer(payload) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async function requestFiatLpToLiquidity(payload) {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (key)
|
||||
headers.Authorization = `Bearer ${key}`;
|
||||
const { data } = await axios_1.default.post(`${base}/api/v1/omnl/settlement/fiat-lp-to-liquidity`, { ...payload, dryRun: payload.dryRun }, { headers, timeout: 300000 });
|
||||
return {
|
||||
swaps: data.swaps ?? [],
|
||||
positions: data.positions,
|
||||
dryRun: Boolean(data.dryRun ?? payload.dryRun),
|
||||
};
|
||||
}
|
||||
|
||||
68
services/settlement-middleware/dist/adapters/pricing.js
vendored
Normal file
68
services/settlement-middleware/dist/adapters/pricing.js
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.resolveLiveBtcUsd = resolveLiveBtcUsd;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
const CBTC_ADDRESS = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
|
||||
const CHAIN_138 = 138;
|
||||
function tokenAggregationBase() {
|
||||
return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
}
|
||||
/**
|
||||
* Resolve live BTC/USD for settlement. Rejects unresolved/stale prices —
|
||||
* production must not fall back to the $90k repo snapshot.
|
||||
*/
|
||||
async function resolveLiveBtcUsd() {
|
||||
const base = tokenAggregationBase();
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (key)
|
||||
headers.Authorization = `Bearer ${key}`;
|
||||
const url = `${base}/api/v1/prices/metamask/v2/chains/${CHAIN_138}/spot-prices` +
|
||||
`?tokenAddresses=${CBTC_ADDRESS}&vsCurrency=usd`;
|
||||
let data;
|
||||
try {
|
||||
const res = await axios_1.default.get(url, { headers, timeout: 30000 });
|
||||
data = res.data;
|
||||
}
|
||||
catch (err) {
|
||||
if (axios_1.default.isAxiosError(err)) {
|
||||
const status = err.response?.status;
|
||||
const body = err.response?.data;
|
||||
const detail = typeof body === 'string'
|
||||
? body.slice(0, 400)
|
||||
: body != null
|
||||
? JSON.stringify(body).slice(0, 400)
|
||||
: err.message;
|
||||
throw new Error(`Live BTC/USD fetch failed${status != null ? ` HTTP ${status}` : ''}: ${detail}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const map = (data ?? {});
|
||||
const row = map[CBTC_ADDRESS.toLowerCase()] ?? map[CBTC_ADDRESS];
|
||||
const rate = row?.usd ?? row?.USD;
|
||||
if (!(typeof rate === 'number' && Number.isFinite(rate) && rate > 0)) {
|
||||
throw new Error('Live BTC/USD price unresolved — settlement blocked (OMNL_REQUIRE_LIVE_BTC_PRICE). ' +
|
||||
'Ensure token-aggregation FX refresh / CoinGecko is reachable.');
|
||||
}
|
||||
// Guard against known static repo fallback used in non-production
|
||||
const requireLive = process.env.OMNL_REQUIRE_LIVE_BTC_PRICE === '1' || process.env.NODE_ENV === 'production';
|
||||
if (requireLive && Math.abs(rate - 90000) < 0.01) {
|
||||
// Allow only if explicitly overridden via env canonical
|
||||
const envOverride = Number(process.env.CHAIN138_CANONICAL_PRICE_USD_BTC ||
|
||||
process.env.CANONICAL_PRICE_USD_BTC ||
|
||||
process.env.BTC_PRICE_USD ||
|
||||
'');
|
||||
if (!(envOverride > 0 && Math.abs(envOverride - 90000) < 0.01)) {
|
||||
throw new Error('BTC/USD appears to be repo-fallback ($90000) — live oracle required for production settlement');
|
||||
}
|
||||
}
|
||||
return {
|
||||
btcUsdRate: rate,
|
||||
source: 'token-aggregation/metamask-spot-prices',
|
||||
asOf: new Date().toISOString(),
|
||||
tokenAddress: CBTC_ADDRESS,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.forwardSwiftToListener = forwardSwiftToListener;
|
||||
exports.buildMt103Stub = buildMt103Stub;
|
||||
exports.buildMt102Stub = buildMt102Stub;
|
||||
exports.buildMt202Stub = buildMt202Stub;
|
||||
exports.buildMt910Stub = buildMt910Stub;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
async function forwardSwiftToListener(raw) {
|
||||
const url = (process.env.SWIFT_LISTENER_URL || 'http://192.168.11.114:8788').replace(/\/$/, '');
|
||||
@@ -40,3 +42,25 @@ ${fields.beneficiary}
|
||||
:70:${fields.remittance}
|
||||
-}`;
|
||||
}
|
||||
/** MT202 — interbank cover / institution transfer (SBLC correspondent leg). */
|
||||
function buildMt202Stub(fields) {
|
||||
const ord = fields.orderingInstitution ? `:52A:${fields.orderingInstitution}\n` : '';
|
||||
const acct = fields.accountWithInstitution ? `:57A:${fields.accountWithInstitution}\n` : '';
|
||||
const ben = fields.beneficiaryInstitution ? `:58A:${fields.beneficiaryInstitution}\n` : '';
|
||||
return `{1:F01OMNLUS33XXXX0000000000}{2:I202BANKUS33XXXXN}{4:
|
||||
:20:${fields.senderRef}
|
||||
:21:${fields.relatedRef}
|
||||
:32A:${fields.valueDate.replace(/-/g, '').slice(2)}${fields.currency}${fields.amount.replace('.', ',')}
|
||||
${ord}${acct}${ben}:70:${fields.remittance}
|
||||
-}`;
|
||||
}
|
||||
/** MT910 — confirmation of credit (advice to beneficiary bank). */
|
||||
function buildMt910Stub(fields) {
|
||||
const acct = fields.accountWithInstitution ? `:57A:${fields.accountWithInstitution}\n` : '';
|
||||
return `{1:F01OMNLUS33XXXX0000000000}{2:I910BANKUS33XXXXN}{4:
|
||||
:20:${fields.transactionRef}
|
||||
:21:${fields.relatedRef}
|
||||
:32A:${fields.valueDate.replace(/-/g, '').slice(2)}${fields.currency}${fields.amount.replace('.', ',')}
|
||||
${acct}:72:${fields.remittance}
|
||||
-}`;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ const office_scope_1 = require("../../workflows/office-scope");
|
||||
const settlement_store_1 = require("../../store/settlement-store");
|
||||
const swift_inbound_1 = require("../../workflows/swift-inbound");
|
||||
const z_bank_m0_m4_1 = require("../../workflows/z-bank-m0-m4");
|
||||
const fiat_lp_to_liquidity_1 = require("../../workflows/fiat-lp-to-liquidity");
|
||||
const btc_l1_settlement_1 = require("../../workflows/btc-l1-settlement");
|
||||
const fineract_1 = require("../../adapters/fineract");
|
||||
const pricing_1 = require("../../adapters/pricing");
|
||||
const auth_1 = require("../../middleware/auth");
|
||||
async function respondPublicMoneySupply(res, officeId) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
@@ -273,6 +276,29 @@ function createSettlementRouter() {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/convert/fiat-lp-to-liquidity', async (req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body.idempotencyKey || !body.holderAddress?.startsWith('0x')) {
|
||||
res.status(400).json({ error: 'idempotencyKey and holderAddress (0x) required' });
|
||||
return;
|
||||
}
|
||||
if (!body.convertAll && !body.symbol) {
|
||||
res.status(400).json({ error: 'symbol required unless convertAll is true' });
|
||||
return;
|
||||
}
|
||||
const record = await (0, fiat_lp_to_liquidity_1.processFiatLpToLiquidity)({
|
||||
...body,
|
||||
officeId: (0, config_1.settlementOfficeId)(body.officeId),
|
||||
});
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/transfer/internal', async (req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
@@ -353,5 +379,118 @@ function createSettlementRouter() {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/btc/l1-settle', async (req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body.idempotencyKey ||
|
||||
!body.instructionId ||
|
||||
!body.txId ||
|
||||
!body.depositAddress ||
|
||||
body.amountSats == null ||
|
||||
!body.recipientAddress) {
|
||||
res.status(400).json({
|
||||
error: 'idempotencyKey, instructionId, txId, depositAddress, amountSats, recipientAddress required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const record = await (0, btc_l1_settlement_1.processBtcL1Settlement)({
|
||||
...body,
|
||||
officeId: (0, config_1.settlementOfficeId)(body.officeId),
|
||||
confirmations: Number(body.confirmations ?? 0),
|
||||
amountSats: Number(body.amountSats),
|
||||
});
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Mark FAILED/partial BTC L1 settle as SETTLED when books + mint already done.
|
||||
* Does not re-journal or remint.
|
||||
*/
|
||||
router.post('/btc/l1-reconcile', (req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
if ((!body.settlementId && !body.idempotencyKey) ||
|
||||
body.amountSats == null ||
|
||||
!body.txId ||
|
||||
!body.depositAddress ||
|
||||
!body.instructionId ||
|
||||
!body.recipientAddress ||
|
||||
!body.chainTxHash ||
|
||||
body.btcUsdRate == null) {
|
||||
res.status(400).json({
|
||||
error: 'settlementId|idempotencyKey, amountSats, txId, depositAddress, instructionId, recipientAddress, chainTxHash, btcUsdRate required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const record = (0, btc_l1_settlement_1.reconcileBtcL1Settlement)({
|
||||
...body,
|
||||
amountSats: Number(body.amountSats),
|
||||
btcUsdRate: Number(body.btcUsdRate),
|
||||
});
|
||||
res.status(200).json(record);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/btc/settlements', (req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 500);
|
||||
const rows = settlement_store_1.settlementStore
|
||||
.list(limit * 4)
|
||||
.filter((r) => r.btcSettlement != null)
|
||||
.slice(0, limit);
|
||||
res.json({ count: rows.length, settlements: rows });
|
||||
});
|
||||
router.get('/btc/ledger', async (_req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(_req, res))
|
||||
return;
|
||||
try {
|
||||
const [balancesHo, balancesO24, quote] = await Promise.all([
|
||||
(0, fineract_1.fetchGlBalances)(1),
|
||||
(0, fineract_1.fetchGlBalances)(officeId),
|
||||
(0, pricing_1.resolveLiveBtcUsd)().catch((e) => ({
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
})),
|
||||
]);
|
||||
const btcSettlements = settlement_store_1.settlementStore
|
||||
.list(500)
|
||||
.filter((r) => r.btcSettlement != null);
|
||||
const outstandingSats = btcSettlements
|
||||
.filter((r) => r.phase === 'CHAIN_MINT_REQUESTED' || r.phase === 'FINERACT_POSTED')
|
||||
.reduce((sum, r) => sum + (r.btcSettlement?.amountSats ?? 0), 0);
|
||||
const settledSats = btcSettlements
|
||||
.filter((r) => r.phase === 'SETTLED')
|
||||
.reduce((sum, r) => sum + (r.btcSettlement?.amountSats ?? 0), 0);
|
||||
res.json({
|
||||
asOf: new Date().toISOString(),
|
||||
gl: {
|
||||
custodyHo12015: balancesHo['12015'] ?? '0',
|
||||
interofficeBtc12424: balancesHo['12424'] ?? balancesO24['12424'] ?? '0',
|
||||
m2Broad2200: balancesO24['2200'] ?? '0',
|
||||
m3Token2300: balancesO24['2300'] ?? '0',
|
||||
},
|
||||
btcUsd: 'btcUsdRate' in quote
|
||||
? { rate: quote.btcUsdRate, source: quote.source, asOf: quote.asOf }
|
||||
: { error: quote.error },
|
||||
mintQueue: {
|
||||
outstandingSats,
|
||||
settledSats,
|
||||
settlementCount: btcSettlements.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -39,8 +39,9 @@ class SettlementStore {
|
||||
return fs_1.default
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.json') && !f.startsWith('key-'))
|
||||
.slice(0, limit)
|
||||
.map((f) => JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, f), 'utf8')));
|
||||
.map((f) => JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, f), 'utf8')))
|
||||
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
|
||||
.slice(0, limit);
|
||||
}
|
||||
}
|
||||
exports.SettlementStore = SettlementStore;
|
||||
|
||||
278
services/settlement-middleware/dist/workflows/btc-l1-settlement.js
vendored
Normal file
278
services/settlement-middleware/dist/workflows/btc-l1-settlement.js
vendored
Normal file
@@ -0,0 +1,278 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.allowChainMintExecute = allowChainMintExecute;
|
||||
exports.formatSettlementError = formatSettlementError;
|
||||
exports.processBtcL1Settlement = processBtcL1Settlement;
|
||||
exports.reconcileBtcL1Settlement = reconcileBtcL1Settlement;
|
||||
const uuid_1 = require("uuid");
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const config_1 = require("../config");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const pricing_1 = require("../adapters/pricing");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
const MIN_CONFIRMATIONS = Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6);
|
||||
/** Env SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE overrides production JSON when set. */
|
||||
function allowChainMintExecute() {
|
||||
const env = process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE?.trim();
|
||||
if (env === '0' || env === 'false')
|
||||
return false;
|
||||
if (env === '1' || env === 'true')
|
||||
return true;
|
||||
return (0, config_1.loadConfig)().production.allowChainMintExecute === true;
|
||||
}
|
||||
function formatSettlementError(err) {
|
||||
if (axios_1.default.isAxiosError(err)) {
|
||||
const status = err.response?.status;
|
||||
const body = err.response?.data;
|
||||
const detail = typeof body === 'string'
|
||||
? body.slice(0, 500)
|
||||
: body != null
|
||||
? JSON.stringify(body).slice(0, 500)
|
||||
: err.message;
|
||||
return status != null ? `HTTP ${status}: ${detail}` : detail;
|
||||
}
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
/**
|
||||
* L1 BTC deposit confirmed → Fineract custody journal (12015/12424) +
|
||||
* M2→M3 cBTC mint on Chain 138, valued at live BTC/USD.
|
||||
*/
|
||||
async function processBtcL1Settlement(input) {
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
if (!req.idempotencyKey || !req.txId || !req.instructionId) {
|
||||
throw new Error('idempotencyKey, txId, and instructionId are required');
|
||||
}
|
||||
if (!(req.amountSats > 0)) {
|
||||
throw new Error('amountSats must be positive');
|
||||
}
|
||||
if (!req.recipientAddress?.startsWith('0x')) {
|
||||
throw new Error('recipientAddress (0x Chain 138 vault) required');
|
||||
}
|
||||
if ((req.confirmations ?? 0) < MIN_CONFIRMATIONS) {
|
||||
throw new Error(`Insufficient confirmations: ${req.confirmations ?? 0} < ${MIN_CONFIRMATIONS}`);
|
||||
}
|
||||
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED')
|
||||
return existing;
|
||||
// Fail-closed: FAILED BTC settles must be reconciled, not blindly retried
|
||||
// (avoids double journals / double mint after partial or out-of-band completion).
|
||||
if (existing?.phase === 'FAILED') {
|
||||
throw new Error(`BTC settlement ${existing.settlementId} is FAILED — use POST /btc/l1-reconcile ` +
|
||||
`(idempotencyKey=${existing.idempotencyKey}) instead of re-settling`);
|
||||
}
|
||||
// Also idempotent on txId — avoid double-posting same L1 deposit
|
||||
const byTx = settlement_store_1.settlementStore
|
||||
.list(500)
|
||||
.find((r) => r.btcSettlement?.txId === req.txId && r.phase === 'SETTLED');
|
||||
if (byTx)
|
||||
return byTx;
|
||||
const now = new Date().toISOString();
|
||||
const valueDate = req.valueDate ?? now.slice(0, 10);
|
||||
const btcAmount = (0, settlement_core_1.satsToBtcDecimal)(req.amountSats);
|
||||
let record = existing ?? {
|
||||
settlementId: (0, uuid_1.v4)(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: {
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
officeId,
|
||||
valueDate,
|
||||
currency: 'BTC',
|
||||
amount: btcAmount,
|
||||
creditorIban: 'INTERNAL-BTC-L1-CUSTODY',
|
||||
beneficiaryName: req.recipientAddress,
|
||||
remittanceInfo: `L1 BTC settle tx=${req.txId} instruction=${req.instructionId} → cBTC`,
|
||||
moneyLayers: ['M0', 'M2', 'M3'],
|
||||
rail: 'CHAIN138',
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
tokenLineId: 'BTC-M2',
|
||||
recipientAddress: req.recipientAddress,
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const advance = (phase, patch = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlement_store_1.settlementStore.save(record);
|
||||
};
|
||||
try {
|
||||
advance('VALIDATED');
|
||||
const quote = await (0, pricing_1.resolveLiveBtcUsd)();
|
||||
const usdValue = (0, settlement_core_1.btcUsdToJournalAmount)(req.amountSats, quote.btcUsdRate);
|
||||
const usdAmount = parseFloat(usdValue) || 0;
|
||||
if (!(usdAmount > 0)) {
|
||||
throw new Error(`USD journal amount invalid for ${req.amountSats} sats @ ${quote.btcUsdRate}`);
|
||||
}
|
||||
const custody = (0, settlement_core_1.resolveBtcL1CustodyJournal)(officeId);
|
||||
advance('VERBIAGE_ROLLED', {
|
||||
btcSettlement: {
|
||||
amountSats: req.amountSats,
|
||||
txId: req.txId,
|
||||
btcUsdRate: quote.btcUsdRate,
|
||||
usdValue,
|
||||
depositAddress: req.depositAddress,
|
||||
instructionId: req.instructionId,
|
||||
priceSource: quote.source,
|
||||
priceAsOf: quote.asOf,
|
||||
},
|
||||
verbiageDocument: [
|
||||
`OMNL L1 BTC custody settlement (IFRS 13 fair value)`,
|
||||
`Office ${officeId} · instruction ${req.instructionId}`,
|
||||
`L1 tx ${req.txId} · ${req.confirmations} confirmations`,
|
||||
`Deposit ${req.depositAddress}`,
|
||||
`Amount ${btcAmount} BTC (${req.amountSats} sats)`,
|
||||
`BTC/USD ${quote.btcUsdRate} (${quote.source} @ ${quote.asOf}) → USD ${usdValue}`,
|
||||
`Custody journal ${custody.memo}: Dr ${custody.debitGl} / Cr ${custody.creditGl}`,
|
||||
`M2→M3 cBTC mint BTC-M2 → ${req.recipientAddress}`,
|
||||
`Note: cBTC real-liquidity activation maps to WETH/USDT/USDC on Chain 138, not L1 BTC payout`,
|
||||
].join('\n'),
|
||||
});
|
||||
// 1) L1 custody journal — HO office 1 posts Dr 12015 / Cr 12424
|
||||
const btcGl = await (0, fineract_1.resolveBtcGlAccountIds)(officeId);
|
||||
const custodyJe = await (0, fineract_1.postJournalEntry)({
|
||||
officeId: 1,
|
||||
transactionDate: valueDate,
|
||||
referenceNumber: `${req.idempotencyKey}-L1`,
|
||||
comments: `${custody.memo} ${req.txId} ${usdValue} USD`,
|
||||
debits: [{ glAccountId: btcGl.custodyHoGlId, amount: usdAmount }],
|
||||
credits: [{ glAccountId: btcGl.interofficeBtcGlId, amount: usdAmount }],
|
||||
});
|
||||
// 2) M2→M3 backing check + journal at Office 24 (USD fair value)
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
|
||||
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
const { loadAmount, fullyBacked } = (0, settlement_core_1.m2FiatToTokenLoadAmount)(usdValue, snap.M2.broadMoney);
|
||||
const m3Check = (0, settlement_core_1.m3TokenLoadAmount)(loadAmount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
|
||||
if (!fullyBacked || !m3Check.fullyBacked) {
|
||||
record.errors.push(`M2/M3 backing insufficient for BTC settlement (M2 load ${loadAmount}/${usdValue}, M3 headroom ${m3Check.loadAmount})`);
|
||||
advance('FAILED', {
|
||||
fineractJournalRef: String(custodyJe.resourceId),
|
||||
});
|
||||
return record;
|
||||
}
|
||||
const m2Amount = parseFloat(loadAmount) || 0;
|
||||
let m2m3Ref = '';
|
||||
if (m2Amount > 0) {
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
(0, fineract_1.resolveGlAccountId)(profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD),
|
||||
(0, fineract_1.resolveGlAccountId)(profile.settlement.glM3 ?? settlement_core_1.GL_CODES.M3_TOKEN_LIABILITY),
|
||||
]);
|
||||
const m2Je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId,
|
||||
transactionDate: valueDate,
|
||||
referenceNumber: `${req.idempotencyKey}-M2M3`,
|
||||
comments: `T-BTC-M2M3 cBTC load ${btcAmount} BTC`,
|
||||
debits: [{ glAccountId: debitGlId, amount: m2Amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount: m2Amount }],
|
||||
});
|
||||
m2m3Ref = String(m2Je.resourceId);
|
||||
}
|
||||
advance('FINERACT_POSTED', {
|
||||
fineractJournalRef: [String(custodyJe.resourceId), m2m3Ref].filter(Boolean).join(','),
|
||||
});
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
advance('ISO20022_ARCHIVED');
|
||||
// 3) Mint cBTC in satoshi-accurate BTC decimal amount
|
||||
const mint = await (0, omnl_1.requestTokenMint)({
|
||||
lineId: 'BTC-M2',
|
||||
amount: btcAmount,
|
||||
recipient: req.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !allowChainMintExecute(),
|
||||
symbol: 'cBTC',
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
catch (err) {
|
||||
record.errors.push(formatSettlementError(err));
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Force SETTLED when custody journals + cBTC mint already completed out-of-band.
|
||||
* Does not call Fineract or mint again.
|
||||
*/
|
||||
function reconcileBtcL1Settlement(input) {
|
||||
if (!input.settlementId && !input.idempotencyKey) {
|
||||
throw new Error('settlementId or idempotencyKey required');
|
||||
}
|
||||
if (!(input.amountSats > 0)) {
|
||||
throw new Error('amountSats must be positive');
|
||||
}
|
||||
if (!input.chainTxHash?.startsWith('0x')) {
|
||||
throw new Error('chainTxHash (0x…) required');
|
||||
}
|
||||
if (!(input.btcUsdRate > 0)) {
|
||||
throw new Error('btcUsdRate must be positive');
|
||||
}
|
||||
if (!input.recipientAddress?.startsWith('0x')) {
|
||||
throw new Error('recipientAddress required');
|
||||
}
|
||||
const existing = input.settlementId
|
||||
? settlement_store_1.settlementStore.getById(input.settlementId)
|
||||
: settlement_store_1.settlementStore.getByIdempotencyKey(input.idempotencyKey);
|
||||
if (!existing) {
|
||||
throw new Error(`Settlement not found (id=${input.settlementId ?? ''} key=${input.idempotencyKey ?? ''})`);
|
||||
}
|
||||
if (existing.phase === 'SETTLED' && existing.btcSettlement?.amountSats === input.amountSats) {
|
||||
return existing;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const btcAmount = (0, settlement_core_1.satsToBtcDecimal)(input.amountSats);
|
||||
const usdValue = input.usdValue ?? (0, settlement_core_1.btcUsdToJournalAmount)(input.amountSats, input.btcUsdRate);
|
||||
const officeId = (0, config_1.settlementOfficeId)(existing.request.officeId);
|
||||
const record = {
|
||||
...existing,
|
||||
phase: 'SETTLED',
|
||||
updatedAt: now,
|
||||
chainTxHash: input.chainTxHash,
|
||||
fineractJournalRef: input.fineractJournalRef ?? existing.fineractJournalRef,
|
||||
errors: [],
|
||||
btcSettlement: {
|
||||
amountSats: input.amountSats,
|
||||
txId: input.txId,
|
||||
btcUsdRate: input.btcUsdRate,
|
||||
usdValue,
|
||||
depositAddress: input.depositAddress,
|
||||
instructionId: input.instructionId,
|
||||
priceSource: input.priceSource ?? 'reconcile/ops',
|
||||
priceAsOf: input.priceAsOf ?? now,
|
||||
},
|
||||
verbiageDocument: [
|
||||
`OMNL L1 BTC custody settlement — RECONCILED (no remint / no re-journal)`,
|
||||
`Office ${officeId} · instruction ${input.instructionId}`,
|
||||
`L1/ops tx ${input.txId}`,
|
||||
`Amount ${btcAmount} BTC (${input.amountSats} sats)`,
|
||||
`BTC/USD ${input.btcUsdRate} → USD ${usdValue}`,
|
||||
`Chain mint ${input.chainTxHash} → ${input.recipientAddress}`,
|
||||
input.fineractJournalRef ? `Fineract refs ${input.fineractJournalRef}` : 'Fineract refs: prior out-of-band journals',
|
||||
].join('\n'),
|
||||
request: {
|
||||
...existing.request,
|
||||
amount: btcAmount,
|
||||
beneficiaryName: input.recipientAddress,
|
||||
remittanceInfo: `RECONCILED L1 BTC settle tx=${input.txId} → cBTC ${input.chainTxHash}`,
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
tokenLineId: 'BTC-M2',
|
||||
recipientAddress: input.recipientAddress,
|
||||
},
|
||||
},
|
||||
};
|
||||
settlement_store_1.settlementStore.save(record);
|
||||
return record;
|
||||
}
|
||||
62
services/settlement-middleware/dist/workflows/btc-l1-settlement.test.js
vendored
Normal file
62
services/settlement-middleware/dist/workflows/btc-l1-settlement.test.js
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
/**
|
||||
* Unit tests for BTC L1 settlement helpers (pricing gate + amount math).
|
||||
* Run via: npm run build && npm test (node --test)
|
||||
*/
|
||||
const node_test_1 = require("node:test");
|
||||
const strict_1 = __importDefault(require("node:assert/strict"));
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
(0, node_test_1.describe)('btc-l1-settlement amounts', () => {
|
||||
(0, node_test_1.it)('mints cBTC amount from sats at 8 decimals', () => {
|
||||
strict_1.default.equal((0, settlement_core_1.satsToBtcDecimal)(25_000_000), '0.25000000');
|
||||
});
|
||||
(0, node_test_1.it)('posts USD fair value = sats × rate', () => {
|
||||
// 0.5 BTC @ 92_500 = 46_250.00
|
||||
strict_1.default.equal((0, settlement_core_1.btcUsdToJournalAmount)(50_000_000, 92_500), '46250.00');
|
||||
});
|
||||
});
|
||||
(0, node_test_1.describe)('pricing gate reject static fallback', () => {
|
||||
(0, node_test_1.it)('flags 90000 as suspicious when live required', () => {
|
||||
const rate = 90000;
|
||||
const requireLive = true;
|
||||
const envOverride = Number('');
|
||||
const isSuspicious = requireLive &&
|
||||
Math.abs(rate - 90000) < 0.01 &&
|
||||
!(envOverride > 0 && Math.abs(envOverride - 90000) < 0.01);
|
||||
strict_1.default.equal(isSuspicious, true);
|
||||
});
|
||||
(0, node_test_1.it)('allows non-fallback live rates', () => {
|
||||
const rate = 97_432.55;
|
||||
const requireLive = true;
|
||||
const isSuspicious = requireLive && Math.abs(rate - 90000) < 0.01;
|
||||
strict_1.default.equal(isSuspicious, false);
|
||||
});
|
||||
});
|
||||
(0, node_test_1.describe)('idempotency key shape', () => {
|
||||
(0, node_test_1.it)('derives stable key from txId + instructionId', () => {
|
||||
const txId = 'deadbeef';
|
||||
const instructionId = 'inst-42';
|
||||
const key = `btc-l1-${txId}-${instructionId}`;
|
||||
strict_1.default.equal(key, 'btc-l1-deadbeef-inst-42');
|
||||
});
|
||||
});
|
||||
(0, node_test_1.describe)('allowChainMintExecute env override', () => {
|
||||
(0, node_test_1.it)('treats SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=0 as dry-run', () => {
|
||||
const env = '0';
|
||||
const cfgAllow = true;
|
||||
const allow = env === '0' || env === 'false' ? false : env === '1' || env === 'true' ? true : cfgAllow;
|
||||
strict_1.default.equal(allow, false);
|
||||
});
|
||||
(0, node_test_1.it)('treats SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1 as execute', () => {
|
||||
const env = '1';
|
||||
const cfgAllow = false;
|
||||
const allow = env === '0' || env === 'false' ? false : env === '1' || env === 'true' ? true : cfgAllow;
|
||||
strict_1.default.equal(allow, true);
|
||||
});
|
||||
});
|
||||
// silence unused mock import in older node
|
||||
void node_test_1.mock;
|
||||
@@ -112,6 +112,29 @@ async function processExternalTransfer(input) {
|
||||
});
|
||||
if (cfg.rails.swift.enabled) {
|
||||
await (0, swift_1.forwardSwiftToListener)(swiftRaw);
|
||||
if (req.tradeFinance?.instrument === 'SBLC') {
|
||||
const cover = (0, swift_1.buildMt202Stub)({
|
||||
senderRef: `${req.idempotencyKey}-COV`,
|
||||
relatedRef: req.idempotencyKey,
|
||||
currency: req.currency,
|
||||
amount: req.amount,
|
||||
valueDate: req.valueDate,
|
||||
orderingInstitution: 'OMNLUS33',
|
||||
beneficiaryInstitution: req.creditorBic ?? 'BANKUS33',
|
||||
remittance: `SBLC cover ${req.tradeFinance.reference ?? req.idempotencyKey}`,
|
||||
});
|
||||
await (0, swift_1.forwardSwiftToListener)(cover);
|
||||
}
|
||||
const advice = (0, swift_1.buildMt910Stub)({
|
||||
transactionRef: `${req.idempotencyKey}-ADV`,
|
||||
relatedRef: req.idempotencyKey,
|
||||
currency: req.currency,
|
||||
amount: req.amount,
|
||||
valueDate: req.valueDate,
|
||||
accountWithInstitution: req.creditorBic,
|
||||
remittance: `Credit confirmation ${req.beneficiaryName}`,
|
||||
});
|
||||
await (0, swift_1.forwardSwiftToListener)(advice);
|
||||
}
|
||||
const iso = await (0, omnl_1.archiveIso20022)({
|
||||
messageType: swiftKind === 'MT102' ? 'pacs.008' : 'pacs.008',
|
||||
|
||||
96
services/settlement-middleware/dist/workflows/fiat-lp-to-liquidity.js
vendored
Normal file
96
services/settlement-middleware/dist/workflows/fiat-lp-to-liquidity.js
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.processFiatLpToLiquidity = processFiatLpToLiquidity;
|
||||
const uuid_1 = require("uuid");
|
||||
const config_1 = require("../config");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
async function processFiatLpToLiquidity(input) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
const holder = req.holderAddress.trim();
|
||||
const recipient = (req.recipientAddress ?? holder).trim();
|
||||
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED')
|
||||
return existing;
|
||||
const now = new Date().toISOString();
|
||||
let record = existing ?? {
|
||||
settlementId: (0, uuid_1.v4)(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: {
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
officeId,
|
||||
valueDate: now.slice(0, 10),
|
||||
currency: 'USD',
|
||||
amount: req.amount ?? '0',
|
||||
creditorIban: 'INTERNAL-FIAT-LP-LIQUIDITY',
|
||||
beneficiaryName: recipient,
|
||||
remittanceInfo: `Chain 138 fiat LP → real liquidity (${req.convertAll ? 'all c*' : req.symbol ?? 'single'})`,
|
||||
moneyLayers: ['M2', 'M3'],
|
||||
rail: 'CHAIN138',
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const advance = (phase, patch = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlement_store_1.settlementStore.save(record);
|
||||
};
|
||||
try {
|
||||
advance('VALIDATED');
|
||||
advance('VERBIAGE_ROLLED', {
|
||||
verbiageDocument: [
|
||||
'OMNL Chain 138 fiat LP → real fund liquidity activation',
|
||||
`Holder ${holder} → recipient ${recipient}`,
|
||||
req.convertAll ? 'Convert all swappable c* tokens' : `Symbol ${req.symbol ?? 'n/a'}`,
|
||||
'Target: official USDT / USDC / WETH on Chain 138 (mainnet-style parity)',
|
||||
].join('\n'),
|
||||
});
|
||||
advance('FINERACT_POSTED');
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
advance('ISO20022_ARCHIVED');
|
||||
const dryRun = req.dryRun ?? !cfg.production.allowChainMintExecute;
|
||||
const result = await (0, omnl_1.requestFiatLpToLiquidity)({
|
||||
holderAddress: holder,
|
||||
recipientAddress: recipient,
|
||||
symbol: req.symbol,
|
||||
amount: req.amount,
|
||||
convertAll: req.convertAll,
|
||||
maxSlippageBps: req.maxSlippageBps,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun,
|
||||
});
|
||||
const liquiditySwaps = result.swaps.map((s) => ({
|
||||
lpSymbol: s.lpSymbol,
|
||||
tokenIn: s.tokenIn,
|
||||
tokenOut: s.tokenOut,
|
||||
realSymbol: s.realSymbol,
|
||||
amountIn: s.amountIn,
|
||||
amountOutQuoted: s.amountOutQuoted,
|
||||
txHash: s.txHash,
|
||||
status: s.status,
|
||||
error: s.error,
|
||||
}));
|
||||
const lastTx = liquiditySwaps.find((s) => s.txHash)?.txHash;
|
||||
const anyFailed = liquiditySwaps.some((s) => s.status === 'FAILED');
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
chainTxHash: lastTx,
|
||||
liquiditySwaps,
|
||||
});
|
||||
if (anyFailed && !dryRun) {
|
||||
record.errors.push('One or more LP→liquidity swaps failed');
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
catch (err) {
|
||||
record.errors.push(err instanceof Error ? err.message : String(err));
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
203
services/settlement-middleware/dist/workflows/z-bank-m0-m4.js
vendored
Normal file
203
services/settlement-middleware/dist/workflows/z-bank-m0-m4.js
vendored
Normal file
@@ -0,0 +1,203 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.processZBankM0ToM4 = processZBankM0ToM4;
|
||||
const uuid_1 = require("uuid");
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const config_1 = require("../config");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const hybx_production_1 = require("../adapters/hybx-production");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const swift_1 = require("../adapters/swift");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
function toExternalRequest(input, officeId) {
|
||||
return {
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
officeId,
|
||||
valueDate: input.valueDate ?? new Date().toISOString().slice(0, 10),
|
||||
currency: input.currency ?? 'USD',
|
||||
amount: input.amount,
|
||||
creditorIban: input.creditorIban,
|
||||
creditorBic: input.creditorBic,
|
||||
beneficiaryName: input.beneficiaryName ?? 'Z Online Bank',
|
||||
orderingName: 'Z Online Bank — OMNL Office 24',
|
||||
remittanceInfo: input.remittanceInfo ?? 'Z Online Bank full settlement M0→M4',
|
||||
moneyLayers: settlement_core_1.ALL_MONEY_LAYERS,
|
||||
rail: 'SWIFT',
|
||||
swiftMessageKind: 'MT103',
|
||||
convertToCrypto: input.recipientAddress
|
||||
? {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
tokenLineId: input.tokenLineId ?? 'USD-M2',
|
||||
recipientAddress: input.recipientAddress,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
async function processZBankM0ToM4(input) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
|
||||
const includeM3 = Boolean(input.recipientAddress?.trim());
|
||||
const req = toExternalRequest(input, officeId);
|
||||
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED')
|
||||
return existing;
|
||||
const now = new Date().toISOString();
|
||||
let record = existing ?? {
|
||||
settlementId: (0, uuid_1.v4)(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: req,
|
||||
layerSteps: [],
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const advance = (phase, patch = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlement_store_1.settlementStore.save(record);
|
||||
};
|
||||
const glProfile = {
|
||||
glM0: profile.settlement.glM0 ?? cfg.moneySupply.glM0 ?? '1050',
|
||||
glM1: profile.settlement.glM1 ?? cfg.moneySupply.glM1 ?? '2100',
|
||||
glM2: profile.settlement.glM2 ?? cfg.moneySupply.glM2 ?? '2200',
|
||||
glM3: profile.settlement.glM3 ?? cfg.moneySupply.glM3 ?? '2300',
|
||||
glM4NearMoney: profile.settlement.glM4NearMoney ?? cfg.moneySupply.glM4NearMoney ?? cfg.moneySupply.glSettlement ?? '1000',
|
||||
};
|
||||
try {
|
||||
if (!(0, settlement_core_1.isIbanValid)(req.creditorIban)) {
|
||||
throw new Error(`Invalid creditor IBAN: ${req.creditorIban}`);
|
||||
}
|
||||
advance('VALIDATED');
|
||||
const verbiage = (0, settlement_core_1.rollExternalTransferVerbiage)(req);
|
||||
advance('VERBIAGE_ROLLED', { verbiageDocument: verbiage });
|
||||
const amount = parseFloat(req.amount) || 0;
|
||||
const hops = (0, settlement_core_1.zBankM0ToM4Hops)(includeM3);
|
||||
const steps = [];
|
||||
let lastJournalRef;
|
||||
if (amount > 0) {
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
|
||||
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
for (const hop of hops) {
|
||||
const pair = (0, settlement_core_1.resolveZBankLayerJournal)(hop.from, hop.to, glProfile);
|
||||
if (!pair) {
|
||||
steps.push({
|
||||
from: hop.from,
|
||||
to: hop.to,
|
||||
debitGl: '',
|
||||
creditGl: '',
|
||||
status: 'skipped',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (hop.to === 'M3') {
|
||||
const { fullyBacked } = (0, settlement_core_1.m2FiatToTokenLoadAmount)(req.amount, snap.M2.broadMoney);
|
||||
const m3Check = (0, settlement_core_1.m3TokenLoadAmount)(req.amount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
|
||||
if (!fullyBacked || !m3Check.fullyBacked) {
|
||||
steps.push({
|
||||
from: hop.from,
|
||||
to: hop.to,
|
||||
debitGl: pair.debitGl,
|
||||
creditGl: pair.creditGl,
|
||||
status: 'failed',
|
||||
});
|
||||
throw new Error(`M2/M3 backing insufficient (${m3Check.loadAmount}/${req.amount} M3 headroom)`);
|
||||
}
|
||||
}
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
(0, fineract_1.resolveGlAccountId)(pair.debitGl),
|
||||
(0, fineract_1.resolveGlAccountId)(pair.creditGl),
|
||||
]);
|
||||
const ref = `${req.idempotencyKey}-${hop.from}${hop.to}`;
|
||||
const je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId,
|
||||
transactionDate: req.valueDate,
|
||||
referenceNumber: ref,
|
||||
comments: `${pair.narrative} · ${verbiage.slice(0, 200)}`,
|
||||
debits: [{ glAccountId: debitGlId, amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount }],
|
||||
});
|
||||
lastJournalRef = String(je.resourceId);
|
||||
steps.push({
|
||||
from: hop.from,
|
||||
to: hop.to,
|
||||
debitGl: pair.debitGl,
|
||||
creditGl: pair.creditGl,
|
||||
journalRef: lastJournalRef,
|
||||
status: 'posted',
|
||||
});
|
||||
}
|
||||
}
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: lastJournalRef, layerSteps: steps });
|
||||
if (cfg.rails.hybx.enabled) {
|
||||
const hybx = new hybx_production_1.HybxProductionRail();
|
||||
const pay = await hybx.dispatchPayment({
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
amount: req.amount,
|
||||
currency: req.currency,
|
||||
creditorIban: req.creditorIban,
|
||||
creditorBic: req.creditorBic,
|
||||
beneficiaryName: req.beneficiaryName,
|
||||
remittanceInfo: req.remittanceInfo,
|
||||
rail: req.rail,
|
||||
});
|
||||
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
|
||||
}
|
||||
else {
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
}
|
||||
const swiftRaw = (0, swift_1.buildMt103Stub)({
|
||||
senderRef: req.idempotencyKey,
|
||||
currency: req.currency,
|
||||
amount: req.amount,
|
||||
valueDate: req.valueDate,
|
||||
orderingCustomer: req.orderingName ?? cfg.omnlLegalName,
|
||||
beneficiary: req.beneficiaryName,
|
||||
iban: req.creditorIban,
|
||||
bic: req.creditorBic,
|
||||
remittance: req.remittanceInfo,
|
||||
});
|
||||
if (cfg.rails.swift.enabled) {
|
||||
await (0, swift_1.forwardSwiftToListener)(swiftRaw);
|
||||
}
|
||||
const iso = await (0, omnl_1.archiveIso20022)({
|
||||
messageType: 'pacs.008',
|
||||
direction: 'OUTBOUND',
|
||||
raw: swiftRaw,
|
||||
settlementRef: record.settlementId,
|
||||
});
|
||||
advance('ISO20022_ARCHIVED', { iso20022MessageId: iso.messageId });
|
||||
if (req.convertToCrypto?.enabled && req.convertToCrypto.recipientAddress) {
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
|
||||
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
const { loadAmount, fullyBacked } = (0, settlement_core_1.m2FiatToTokenLoadAmount)(req.amount, snap.M2.broadMoney);
|
||||
const m3Check = (0, settlement_core_1.m3TokenLoadAmount)(loadAmount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
|
||||
if (fullyBacked && m3Check.fullyBacked) {
|
||||
const mint = await (0, omnl_1.requestTokenMint)({
|
||||
lineId: req.convertToCrypto.tokenLineId,
|
||||
amount: m3Check.loadAmount,
|
||||
recipient: req.convertToCrypto.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !cfg.production.allowChainMintExecute,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
}
|
||||
else {
|
||||
record.errors.push('M3 mint skipped — insufficient M2/M3 backing');
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
}
|
||||
}
|
||||
else {
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
}
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
record.errors.push(msg);
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
|
||||
const GL_HINTS = ['1000', '1050', '1410', '2000', '2100', '2200', '2300'];
|
||||
const GL_HINTS = ['1000', '1050', '12015', '12424', '1410', '2000', '2100', '2200', '2300'];
|
||||
|
||||
function fineractBase(): string {
|
||||
return (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, '');
|
||||
@@ -148,7 +148,13 @@ export async function postJournalEntry(entry: {
|
||||
debits: { glAccountId: number; amount: number }[];
|
||||
credits: { glAccountId: number; amount: number }[];
|
||||
}): Promise<{ resourceId: number }> {
|
||||
const { data } = await axios.post(`${fineractBase()}/journalentries`, entry, {
|
||||
const payload = {
|
||||
...entry,
|
||||
currencyCode: 'USD',
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
locale: 'en',
|
||||
};
|
||||
const { data } = await axios.post(`${fineractBase()}/journalentries`, payload, {
|
||||
headers: authHeaders(),
|
||||
timeout: 60000,
|
||||
});
|
||||
@@ -177,6 +183,22 @@ export async function resolveGlAccountId(glCode: string): Promise<number> {
|
||||
return match.id;
|
||||
}
|
||||
|
||||
/** Resolve Fineract GL account ids for L1 BTC custody legs (HO 12015 / Office 24 12424). */
|
||||
export async function resolveBtcGlAccountIds(_officeId?: number): Promise<{
|
||||
custodyHoGlId: number;
|
||||
interofficeBtcGlId: number;
|
||||
custodyHoGlCode: string;
|
||||
interofficeBtcGlCode: string;
|
||||
}> {
|
||||
const custodyHoGlCode = '12015';
|
||||
const interofficeBtcGlCode = '12424';
|
||||
const [custodyHoGlId, interofficeBtcGlId] = await Promise.all([
|
||||
resolveGlAccountId(custodyHoGlCode),
|
||||
resolveGlAccountId(interofficeBtcGlCode),
|
||||
]);
|
||||
return { custodyHoGlId, interofficeBtcGlId, custodyHoGlCode, interofficeBtcGlCode };
|
||||
}
|
||||
|
||||
export async function fetchGlBalances(officeId: number): Promise<Record<string, string>> {
|
||||
const base = fineractBase();
|
||||
if (!base || !process.env.OMNL_FINERACT_PASSWORD) return {};
|
||||
|
||||
@@ -90,3 +90,47 @@ export async function requestTokenTransfer(payload: {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type FiatLpLiquidityAdapterResult = {
|
||||
swaps: Array<{
|
||||
lpSymbol: string;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
realSymbol: string;
|
||||
amountIn: string;
|
||||
amountOutQuoted?: string;
|
||||
txHash?: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
positions?: unknown[];
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
export async function requestFiatLpToLiquidity(payload: {
|
||||
holderAddress: string;
|
||||
recipientAddress?: string;
|
||||
symbol?: string;
|
||||
amount?: string;
|
||||
convertAll?: boolean;
|
||||
maxSlippageBps?: number;
|
||||
settlementRef: string;
|
||||
dryRun: boolean;
|
||||
}): Promise<FiatLpLiquidityAdapterResult> {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (key) headers.Authorization = `Bearer ${key}`;
|
||||
|
||||
const { data } = await axios.post(
|
||||
`${base}/api/v1/omnl/settlement/fiat-lp-to-liquidity`,
|
||||
{ ...payload, dryRun: payload.dryRun },
|
||||
{ headers, timeout: 300000 },
|
||||
);
|
||||
return {
|
||||
swaps: data.swaps ?? [],
|
||||
positions: data.positions,
|
||||
dryRun: Boolean(data.dryRun ?? payload.dryRun),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
86
services/settlement-middleware/src/adapters/pricing.ts
Normal file
86
services/settlement-middleware/src/adapters/pricing.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const CBTC_ADDRESS = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
|
||||
const CHAIN_138 = 138;
|
||||
|
||||
export type LiveBtcUsdQuote = {
|
||||
btcUsdRate: number;
|
||||
source: string;
|
||||
asOf: string;
|
||||
tokenAddress: string;
|
||||
};
|
||||
|
||||
function tokenAggregationBase(): string {
|
||||
return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve live BTC/USD for settlement. Rejects unresolved/stale prices —
|
||||
* production must not fall back to the $90k repo snapshot.
|
||||
*/
|
||||
export async function resolveLiveBtcUsd(): Promise<LiveBtcUsdQuote> {
|
||||
const base = tokenAggregationBase();
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (key) headers.Authorization = `Bearer ${key}`;
|
||||
|
||||
const url =
|
||||
`${base}/api/v1/prices/metamask/v2/chains/${CHAIN_138}/spot-prices` +
|
||||
`?tokenAddresses=${CBTC_ADDRESS}&vsCurrency=usd`;
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
const res = await axios.get(url, { headers, timeout: 30000 });
|
||||
data = res.data;
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status;
|
||||
const body = err.response?.data;
|
||||
const detail =
|
||||
typeof body === 'string'
|
||||
? body.slice(0, 400)
|
||||
: body != null
|
||||
? JSON.stringify(body).slice(0, 400)
|
||||
: err.message;
|
||||
throw new Error(
|
||||
`Live BTC/USD fetch failed${status != null ? ` HTTP ${status}` : ''}: ${detail}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const map = (data ?? {}) as Record<string, Record<string, number>>;
|
||||
const row = map[CBTC_ADDRESS.toLowerCase()] ?? map[CBTC_ADDRESS];
|
||||
const rate = row?.usd ?? row?.USD;
|
||||
|
||||
if (!(typeof rate === 'number' && Number.isFinite(rate) && rate > 0)) {
|
||||
throw new Error(
|
||||
'Live BTC/USD price unresolved — settlement blocked (OMNL_REQUIRE_LIVE_BTC_PRICE). ' +
|
||||
'Ensure token-aggregation FX refresh / CoinGecko is reachable.',
|
||||
);
|
||||
}
|
||||
|
||||
// Guard against known static repo fallback used in non-production
|
||||
const requireLive =
|
||||
process.env.OMNL_REQUIRE_LIVE_BTC_PRICE === '1' || process.env.NODE_ENV === 'production';
|
||||
if (requireLive && Math.abs(rate - 90000) < 0.01) {
|
||||
// Allow only if explicitly overridden via env canonical
|
||||
const envOverride = Number(
|
||||
process.env.CHAIN138_CANONICAL_PRICE_USD_BTC ||
|
||||
process.env.CANONICAL_PRICE_USD_BTC ||
|
||||
process.env.BTC_PRICE_USD ||
|
||||
'',
|
||||
);
|
||||
if (!(envOverride > 0 && Math.abs(envOverride - 90000) < 0.01)) {
|
||||
throw new Error(
|
||||
'BTC/USD appears to be repo-fallback ($90000) — live oracle required for production settlement',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
btcUsdRate: rate,
|
||||
source: 'token-aggregation/metamask-spot-prices',
|
||||
asOf: new Date().toISOString(),
|
||||
tokenAddress: CBTC_ADDRESS,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import type { ExternalTransferRequest, TradeFinanceInstrument, TokenLoadRequest, TransferRequest, ZBankM0M4Request } from '@dbis/settlement-core';
|
||||
import type {
|
||||
BtcL1ReconcileRequest,
|
||||
BtcL1SettlementRequest,
|
||||
ExternalTransferRequest,
|
||||
FiatLpToLiquidityRequest,
|
||||
TradeFinanceInstrument,
|
||||
TokenLoadRequest,
|
||||
TransferRequest,
|
||||
ZBankM0M4Request,
|
||||
} from '@dbis/settlement-core';
|
||||
import { buildMoneySupplySnapshot, loadOmnlChainRegistry, getActiveChains } from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../../config';
|
||||
import {
|
||||
@@ -13,7 +22,13 @@ import { bindOffice24 } from '../../workflows/office-scope';
|
||||
import { settlementStore } from '../../store/settlement-store';
|
||||
import { processSwiftInbound } from '../../workflows/swift-inbound';
|
||||
import { processZBankM0ToM4 } from '../../workflows/z-bank-m0-m4';
|
||||
import { processFiatLpToLiquidity } from '../../workflows/fiat-lp-to-liquidity';
|
||||
import {
|
||||
processBtcL1Settlement,
|
||||
reconcileBtcL1Settlement,
|
||||
} from '../../workflows/btc-l1-settlement';
|
||||
import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract';
|
||||
import { resolveLiveBtcUsd } from '../../adapters/pricing';
|
||||
import { requireApiKey } from '../../middleware/auth';
|
||||
|
||||
async function respondPublicMoneySupply(res: Response, officeId: number): Promise<void> {
|
||||
@@ -275,6 +290,28 @@ export function createSettlementRouter(): Router {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/convert/fiat-lp-to-liquidity', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as FiatLpToLiquidityRequest;
|
||||
if (!body.idempotencyKey || !body.holderAddress?.startsWith('0x')) {
|
||||
res.status(400).json({ error: 'idempotencyKey and holderAddress (0x) required' });
|
||||
return;
|
||||
}
|
||||
if (!body.convertAll && !body.symbol) {
|
||||
res.status(400).json({ error: 'symbol required unless convertAll is true' });
|
||||
return;
|
||||
}
|
||||
const record = await processFiatLpToLiquidity({
|
||||
...body,
|
||||
officeId: settlementOfficeId(body.officeId),
|
||||
});
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/transfer/internal', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
@@ -354,5 +391,123 @@ export function createSettlementRouter(): Router {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/btc/l1-settle', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as BtcL1SettlementRequest;
|
||||
if (
|
||||
!body.idempotencyKey ||
|
||||
!body.instructionId ||
|
||||
!body.txId ||
|
||||
!body.depositAddress ||
|
||||
body.amountSats == null ||
|
||||
!body.recipientAddress
|
||||
) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'idempotencyKey, instructionId, txId, depositAddress, amountSats, recipientAddress required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const record = await processBtcL1Settlement({
|
||||
...body,
|
||||
officeId: settlementOfficeId(body.officeId),
|
||||
confirmations: Number(body.confirmations ?? 0),
|
||||
amountSats: Number(body.amountSats),
|
||||
});
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Mark FAILED/partial BTC L1 settle as SETTLED when books + mint already done.
|
||||
* Does not re-journal or remint.
|
||||
*/
|
||||
router.post('/btc/l1-reconcile', (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as BtcL1ReconcileRequest;
|
||||
if (
|
||||
(!body.settlementId && !body.idempotencyKey) ||
|
||||
body.amountSats == null ||
|
||||
!body.txId ||
|
||||
!body.depositAddress ||
|
||||
!body.instructionId ||
|
||||
!body.recipientAddress ||
|
||||
!body.chainTxHash ||
|
||||
body.btcUsdRate == null
|
||||
) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'settlementId|idempotencyKey, amountSats, txId, depositAddress, instructionId, recipientAddress, chainTxHash, btcUsdRate required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const record = reconcileBtcL1Settlement({
|
||||
...body,
|
||||
amountSats: Number(body.amountSats),
|
||||
btcUsdRate: Number(body.btcUsdRate),
|
||||
});
|
||||
res.status(200).json(record);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/btc/settlements', (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 500);
|
||||
const rows = settlementStore
|
||||
.list(limit * 4)
|
||||
.filter((r) => r.btcSettlement != null)
|
||||
.slice(0, limit);
|
||||
res.json({ count: rows.length, settlements: rows });
|
||||
});
|
||||
|
||||
router.get('/btc/ledger', async (_req, res) => {
|
||||
if (!requireApiKey(_req, res)) return;
|
||||
try {
|
||||
const [balancesHo, balancesO24, quote] = await Promise.all([
|
||||
fetchGlBalances(1),
|
||||
fetchGlBalances(officeId),
|
||||
resolveLiveBtcUsd().catch((e) => ({
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
})),
|
||||
]);
|
||||
const btcSettlements = settlementStore
|
||||
.list(500)
|
||||
.filter((r) => r.btcSettlement != null);
|
||||
const outstandingSats = btcSettlements
|
||||
.filter((r) => r.phase === 'CHAIN_MINT_REQUESTED' || r.phase === 'FINERACT_POSTED')
|
||||
.reduce((sum, r) => sum + (r.btcSettlement?.amountSats ?? 0), 0);
|
||||
const settledSats = btcSettlements
|
||||
.filter((r) => r.phase === 'SETTLED')
|
||||
.reduce((sum, r) => sum + (r.btcSettlement?.amountSats ?? 0), 0);
|
||||
|
||||
res.json({
|
||||
asOf: new Date().toISOString(),
|
||||
gl: {
|
||||
custodyHo12015: balancesHo['12015'] ?? '0',
|
||||
interofficeBtc12424: balancesHo['12424'] ?? balancesO24['12424'] ?? '0',
|
||||
m2Broad2200: balancesO24['2200'] ?? '0',
|
||||
m3Token2300: balancesO24['2300'] ?? '0',
|
||||
},
|
||||
btcUsd:
|
||||
'btcUsdRate' in quote
|
||||
? { rate: quote.btcUsdRate, source: quote.source, asOf: quote.asOf }
|
||||
: { error: (quote as { error: string }).error },
|
||||
mintQueue: {
|
||||
outstandingSats,
|
||||
settledSats,
|
||||
settlementCount: btcSettlements.length,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -38,8 +38,9 @@ export class SettlementStore {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.json') && !f.startsWith('key-'))
|
||||
.slice(0, limit)
|
||||
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as SettlementRecord);
|
||||
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as SettlementRecord)
|
||||
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
|
||||
.slice(0, limit);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Unit tests for BTC L1 settlement helpers (pricing gate + amount math).
|
||||
* Run via: npm run build && npm test (node --test)
|
||||
*/
|
||||
import { describe, it, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
btcUsdToJournalAmount,
|
||||
satsToBtcDecimal,
|
||||
} from '@dbis/settlement-core';
|
||||
|
||||
describe('btc-l1-settlement amounts', () => {
|
||||
it('mints cBTC amount from sats at 8 decimals', () => {
|
||||
assert.equal(satsToBtcDecimal(25_000_000), '0.25000000');
|
||||
});
|
||||
|
||||
it('posts USD fair value = sats × rate', () => {
|
||||
// 0.5 BTC @ 92_500 = 46_250.00
|
||||
assert.equal(btcUsdToJournalAmount(50_000_000, 92_500), '46250.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pricing gate reject static fallback', () => {
|
||||
it('flags 90000 as suspicious when live required', () => {
|
||||
const rate = 90000;
|
||||
const requireLive = true;
|
||||
const envOverride = Number('');
|
||||
const isSuspicious =
|
||||
requireLive &&
|
||||
Math.abs(rate - 90000) < 0.01 &&
|
||||
!(envOverride > 0 && Math.abs(envOverride - 90000) < 0.01);
|
||||
assert.equal(isSuspicious, true);
|
||||
});
|
||||
|
||||
it('allows non-fallback live rates', () => {
|
||||
const rate = 97_432.55;
|
||||
const requireLive = true;
|
||||
const isSuspicious = requireLive && Math.abs(rate - 90000) < 0.01;
|
||||
assert.equal(isSuspicious, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency key shape', () => {
|
||||
it('derives stable key from txId + instructionId', () => {
|
||||
const txId = 'deadbeef';
|
||||
const instructionId = 'inst-42';
|
||||
const key = `btc-l1-${txId}-${instructionId}`;
|
||||
assert.equal(key, 'btc-l1-deadbeef-inst-42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowChainMintExecute env override', () => {
|
||||
it('treats SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=0 as dry-run', () => {
|
||||
const env: string = '0';
|
||||
const cfgAllow = true;
|
||||
const allow =
|
||||
env === '0' || env === 'false' ? false : env === '1' || env === 'true' ? true : cfgAllow;
|
||||
assert.equal(allow, false);
|
||||
});
|
||||
|
||||
it('treats SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1 as execute', () => {
|
||||
const env: string = '1';
|
||||
const cfgAllow = false;
|
||||
const allow =
|
||||
env === '0' || env === 'false' ? false : env === '1' || env === 'true' ? true : cfgAllow;
|
||||
assert.equal(allow, true);
|
||||
});
|
||||
});
|
||||
|
||||
// silence unused mock import in older node
|
||||
void mock;
|
||||
@@ -0,0 +1,317 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
buildMoneySupplySnapshot,
|
||||
btcUsdToJournalAmount,
|
||||
GL_CODES,
|
||||
m2FiatToTokenLoadAmount,
|
||||
m3TokenLoadAmount,
|
||||
resolveBtcL1CustodyJournal,
|
||||
satsToBtcDecimal,
|
||||
type BtcL1ReconcileRequest,
|
||||
type BtcL1SettlementRequest,
|
||||
type SettlementRecord,
|
||||
} from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
import {
|
||||
fetchGlBalances,
|
||||
postJournalEntry,
|
||||
resolveBtcGlAccountIds,
|
||||
resolveGlAccountId,
|
||||
} from '../adapters/fineract';
|
||||
import { requestTokenMint } from '../adapters/omnl';
|
||||
import { resolveLiveBtcUsd } from '../adapters/pricing';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
|
||||
const MIN_CONFIRMATIONS = Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6);
|
||||
|
||||
/** Env SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE overrides production JSON when set. */
|
||||
export function allowChainMintExecute(): boolean {
|
||||
const env = process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE?.trim();
|
||||
if (env === '0' || env === 'false') return false;
|
||||
if (env === '1' || env === 'true') return true;
|
||||
return loadConfig().production.allowChainMintExecute === true;
|
||||
}
|
||||
|
||||
export function formatSettlementError(err: unknown): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status;
|
||||
const body = err.response?.data;
|
||||
const detail =
|
||||
typeof body === 'string'
|
||||
? body.slice(0, 500)
|
||||
: body != null
|
||||
? JSON.stringify(body).slice(0, 500)
|
||||
: err.message;
|
||||
return status != null ? `HTTP ${status}: ${detail}` : detail;
|
||||
}
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* L1 BTC deposit confirmed → Fineract custody journal (12015/12424) +
|
||||
* M2→M3 cBTC mint on Chain 138, valued at live BTC/USD.
|
||||
*/
|
||||
export async function processBtcL1Settlement(
|
||||
input: BtcL1SettlementRequest,
|
||||
): Promise<SettlementRecord> {
|
||||
const profile = loadOfficeProfile();
|
||||
const officeId = settlementOfficeId(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
|
||||
if (!req.idempotencyKey || !req.txId || !req.instructionId) {
|
||||
throw new Error('idempotencyKey, txId, and instructionId are required');
|
||||
}
|
||||
if (!(req.amountSats > 0)) {
|
||||
throw new Error('amountSats must be positive');
|
||||
}
|
||||
if (!req.recipientAddress?.startsWith('0x')) {
|
||||
throw new Error('recipientAddress (0x Chain 138 vault) required');
|
||||
}
|
||||
if ((req.confirmations ?? 0) < MIN_CONFIRMATIONS) {
|
||||
throw new Error(
|
||||
`Insufficient confirmations: ${req.confirmations ?? 0} < ${MIN_CONFIRMATIONS}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED') return existing;
|
||||
|
||||
// Fail-closed: FAILED BTC settles must be reconciled, not blindly retried
|
||||
// (avoids double journals / double mint after partial or out-of-band completion).
|
||||
if (existing?.phase === 'FAILED') {
|
||||
throw new Error(
|
||||
`BTC settlement ${existing.settlementId} is FAILED — use POST /btc/l1-reconcile ` +
|
||||
`(idempotencyKey=${existing.idempotencyKey}) instead of re-settling`,
|
||||
);
|
||||
}
|
||||
|
||||
// Also idempotent on txId — avoid double-posting same L1 deposit
|
||||
const byTx = settlementStore
|
||||
.list(500)
|
||||
.find((r) => r.btcSettlement?.txId === req.txId && r.phase === 'SETTLED');
|
||||
if (byTx) return byTx;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const valueDate = req.valueDate ?? now.slice(0, 10);
|
||||
const btcAmount = satsToBtcDecimal(req.amountSats);
|
||||
|
||||
let record: SettlementRecord = existing ?? {
|
||||
settlementId: uuidv4(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: {
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
officeId,
|
||||
valueDate,
|
||||
currency: 'BTC',
|
||||
amount: btcAmount,
|
||||
creditorIban: 'INTERNAL-BTC-L1-CUSTODY',
|
||||
beneficiaryName: req.recipientAddress,
|
||||
remittanceInfo: `L1 BTC settle tx=${req.txId} instruction=${req.instructionId} → cBTC`,
|
||||
moneyLayers: ['M0', 'M2', 'M3'],
|
||||
rail: 'CHAIN138',
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
tokenLineId: 'BTC-M2',
|
||||
recipientAddress: req.recipientAddress,
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const advance = (phase: SettlementRecord['phase'], patch: Partial<SettlementRecord> = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlementStore.save(record);
|
||||
};
|
||||
|
||||
try {
|
||||
advance('VALIDATED');
|
||||
|
||||
const quote = await resolveLiveBtcUsd();
|
||||
const usdValue = btcUsdToJournalAmount(req.amountSats, quote.btcUsdRate);
|
||||
const usdAmount = parseFloat(usdValue) || 0;
|
||||
if (!(usdAmount > 0)) {
|
||||
throw new Error(`USD journal amount invalid for ${req.amountSats} sats @ ${quote.btcUsdRate}`);
|
||||
}
|
||||
|
||||
const custody = resolveBtcL1CustodyJournal(officeId);
|
||||
advance('VERBIAGE_ROLLED', {
|
||||
btcSettlement: {
|
||||
amountSats: req.amountSats,
|
||||
txId: req.txId,
|
||||
btcUsdRate: quote.btcUsdRate,
|
||||
usdValue,
|
||||
depositAddress: req.depositAddress,
|
||||
instructionId: req.instructionId,
|
||||
priceSource: quote.source,
|
||||
priceAsOf: quote.asOf,
|
||||
},
|
||||
verbiageDocument: [
|
||||
`OMNL L1 BTC custody settlement (IFRS 13 fair value)`,
|
||||
`Office ${officeId} · instruction ${req.instructionId}`,
|
||||
`L1 tx ${req.txId} · ${req.confirmations} confirmations`,
|
||||
`Deposit ${req.depositAddress}`,
|
||||
`Amount ${btcAmount} BTC (${req.amountSats} sats)`,
|
||||
`BTC/USD ${quote.btcUsdRate} (${quote.source} @ ${quote.asOf}) → USD ${usdValue}`,
|
||||
`Custody journal ${custody.memo}: Dr ${custody.debitGl} / Cr ${custody.creditGl}`,
|
||||
`M2→M3 cBTC mint BTC-M2 → ${req.recipientAddress}`,
|
||||
`Note: cBTC real-liquidity activation maps to WETH/USDT/USDC on Chain 138, not L1 BTC payout`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
// 1) L1 custody journal — HO office 1 posts Dr 12015 / Cr 12424
|
||||
const btcGl = await resolveBtcGlAccountIds(officeId);
|
||||
const custodyJe = await postJournalEntry({
|
||||
officeId: 1,
|
||||
transactionDate: valueDate,
|
||||
referenceNumber: `${req.idempotencyKey}-L1`,
|
||||
comments: `${custody.memo} ${req.txId} ${usdValue} USD`,
|
||||
debits: [{ glAccountId: btcGl.custodyHoGlId, amount: usdAmount }],
|
||||
credits: [{ glAccountId: btcGl.interofficeBtcGlId, amount: usdAmount }],
|
||||
});
|
||||
|
||||
// 2) M2→M3 backing check + journal at Office 24 (USD fair value)
|
||||
const balances = await fetchGlBalances(officeId);
|
||||
const snap = buildMoneySupplySnapshot(officeId, balances);
|
||||
const { loadAmount, fullyBacked } = m2FiatToTokenLoadAmount(usdValue, snap.M2.broadMoney);
|
||||
const m3Check = m3TokenLoadAmount(loadAmount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
|
||||
if (!fullyBacked || !m3Check.fullyBacked) {
|
||||
record.errors.push(
|
||||
`M2/M3 backing insufficient for BTC settlement (M2 load ${loadAmount}/${usdValue}, M3 headroom ${m3Check.loadAmount})`,
|
||||
);
|
||||
advance('FAILED', {
|
||||
fineractJournalRef: String(custodyJe.resourceId),
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
const m2Amount = parseFloat(loadAmount) || 0;
|
||||
let m2m3Ref = '';
|
||||
if (m2Amount > 0) {
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
resolveGlAccountId(profile.settlement.glM2 ?? GL_CODES.M2_BROAD),
|
||||
resolveGlAccountId(profile.settlement.glM3 ?? GL_CODES.M3_TOKEN_LIABILITY),
|
||||
]);
|
||||
const m2Je = await postJournalEntry({
|
||||
officeId,
|
||||
transactionDate: valueDate,
|
||||
referenceNumber: `${req.idempotencyKey}-M2M3`,
|
||||
comments: `T-BTC-M2M3 cBTC load ${btcAmount} BTC`,
|
||||
debits: [{ glAccountId: debitGlId, amount: m2Amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount: m2Amount }],
|
||||
});
|
||||
m2m3Ref = String(m2Je.resourceId);
|
||||
}
|
||||
|
||||
advance('FINERACT_POSTED', {
|
||||
fineractJournalRef: [String(custodyJe.resourceId), m2m3Ref].filter(Boolean).join(','),
|
||||
});
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
advance('ISO20022_ARCHIVED');
|
||||
|
||||
// 3) Mint cBTC in satoshi-accurate BTC decimal amount
|
||||
const mint = await requestTokenMint({
|
||||
lineId: 'BTC-M2',
|
||||
amount: btcAmount,
|
||||
recipient: req.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !allowChainMintExecute(),
|
||||
symbol: 'cBTC',
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
} catch (err) {
|
||||
record.errors.push(formatSettlementError(err));
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force SETTLED when custody journals + cBTC mint already completed out-of-band.
|
||||
* Does not call Fineract or mint again.
|
||||
*/
|
||||
export function reconcileBtcL1Settlement(input: BtcL1ReconcileRequest): SettlementRecord {
|
||||
if (!input.settlementId && !input.idempotencyKey) {
|
||||
throw new Error('settlementId or idempotencyKey required');
|
||||
}
|
||||
if (!(input.amountSats > 0)) {
|
||||
throw new Error('amountSats must be positive');
|
||||
}
|
||||
if (!input.chainTxHash?.startsWith('0x')) {
|
||||
throw new Error('chainTxHash (0x…) required');
|
||||
}
|
||||
if (!(input.btcUsdRate > 0)) {
|
||||
throw new Error('btcUsdRate must be positive');
|
||||
}
|
||||
if (!input.recipientAddress?.startsWith('0x')) {
|
||||
throw new Error('recipientAddress required');
|
||||
}
|
||||
|
||||
const existing = input.settlementId
|
||||
? settlementStore.getById(input.settlementId)
|
||||
: settlementStore.getByIdempotencyKey(input.idempotencyKey!);
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(
|
||||
`Settlement not found (id=${input.settlementId ?? ''} key=${input.idempotencyKey ?? ''})`,
|
||||
);
|
||||
}
|
||||
if (existing.phase === 'SETTLED' && existing.btcSettlement?.amountSats === input.amountSats) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const btcAmount = satsToBtcDecimal(input.amountSats);
|
||||
const usdValue =
|
||||
input.usdValue ?? btcUsdToJournalAmount(input.amountSats, input.btcUsdRate);
|
||||
const officeId = settlementOfficeId(existing.request.officeId);
|
||||
|
||||
const record: SettlementRecord = {
|
||||
...existing,
|
||||
phase: 'SETTLED',
|
||||
updatedAt: now,
|
||||
chainTxHash: input.chainTxHash,
|
||||
fineractJournalRef: input.fineractJournalRef ?? existing.fineractJournalRef,
|
||||
errors: [],
|
||||
btcSettlement: {
|
||||
amountSats: input.amountSats,
|
||||
txId: input.txId,
|
||||
btcUsdRate: input.btcUsdRate,
|
||||
usdValue,
|
||||
depositAddress: input.depositAddress,
|
||||
instructionId: input.instructionId,
|
||||
priceSource: input.priceSource ?? 'reconcile/ops',
|
||||
priceAsOf: input.priceAsOf ?? now,
|
||||
},
|
||||
verbiageDocument: [
|
||||
`OMNL L1 BTC custody settlement — RECONCILED (no remint / no re-journal)`,
|
||||
`Office ${officeId} · instruction ${input.instructionId}`,
|
||||
`L1/ops tx ${input.txId}`,
|
||||
`Amount ${btcAmount} BTC (${input.amountSats} sats)`,
|
||||
`BTC/USD ${input.btcUsdRate} → USD ${usdValue}`,
|
||||
`Chain mint ${input.chainTxHash} → ${input.recipientAddress}`,
|
||||
input.fineractJournalRef ? `Fineract refs ${input.fineractJournalRef}` : 'Fineract refs: prior out-of-band journals',
|
||||
].join('\n'),
|
||||
request: {
|
||||
...existing.request,
|
||||
amount: btcAmount,
|
||||
beneficiaryName: input.recipientAddress,
|
||||
remittanceInfo: `RECONCILED L1 BTC settle tx=${input.txId} → cBTC ${input.chainTxHash}`,
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
tokenLineId: 'BTC-M2',
|
||||
recipientAddress: input.recipientAddress,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
settlementStore.save(record);
|
||||
return record;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { FiatLpSwapLeg, FiatLpToLiquidityRequest, SettlementRecord } from '@dbis/settlement-core';
|
||||
import { loadConfig, settlementOfficeId } from '../config';
|
||||
import { requestFiatLpToLiquidity } from '../adapters/omnl';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
|
||||
export async function processFiatLpToLiquidity(
|
||||
input: FiatLpToLiquidityRequest,
|
||||
): Promise<SettlementRecord> {
|
||||
const cfg = loadConfig();
|
||||
const officeId = settlementOfficeId(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
const holder = req.holderAddress.trim();
|
||||
const recipient = (req.recipientAddress ?? holder).trim();
|
||||
|
||||
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED') return existing;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let record: SettlementRecord = existing ?? {
|
||||
settlementId: uuidv4(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: {
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
officeId,
|
||||
valueDate: now.slice(0, 10),
|
||||
currency: 'USD',
|
||||
amount: req.amount ?? '0',
|
||||
creditorIban: 'INTERNAL-FIAT-LP-LIQUIDITY',
|
||||
beneficiaryName: recipient,
|
||||
remittanceInfo: `Chain 138 fiat LP → real liquidity (${req.convertAll ? 'all c*' : req.symbol ?? 'single'})`,
|
||||
moneyLayers: ['M2', 'M3'],
|
||||
rail: 'CHAIN138',
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const advance = (phase: SettlementRecord['phase'], patch: Partial<SettlementRecord> = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlementStore.save(record);
|
||||
};
|
||||
|
||||
try {
|
||||
advance('VALIDATED');
|
||||
advance('VERBIAGE_ROLLED', {
|
||||
verbiageDocument: [
|
||||
'OMNL Chain 138 fiat LP → real fund liquidity activation',
|
||||
`Holder ${holder} → recipient ${recipient}`,
|
||||
req.convertAll ? 'Convert all swappable c* tokens' : `Symbol ${req.symbol ?? 'n/a'}`,
|
||||
'Target: official USDT / USDC / WETH on Chain 138 (mainnet-style parity)',
|
||||
].join('\n'),
|
||||
});
|
||||
advance('FINERACT_POSTED');
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
advance('ISO20022_ARCHIVED');
|
||||
|
||||
const dryRun = req.dryRun ?? !cfg.production.allowChainMintExecute;
|
||||
const result = await requestFiatLpToLiquidity({
|
||||
holderAddress: holder,
|
||||
recipientAddress: recipient,
|
||||
symbol: req.symbol,
|
||||
amount: req.amount,
|
||||
convertAll: req.convertAll,
|
||||
maxSlippageBps: req.maxSlippageBps,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
const liquiditySwaps: FiatLpSwapLeg[] = result.swaps.map((s) => ({
|
||||
lpSymbol: s.lpSymbol,
|
||||
tokenIn: s.tokenIn,
|
||||
tokenOut: s.tokenOut,
|
||||
realSymbol: s.realSymbol,
|
||||
amountIn: s.amountIn,
|
||||
amountOutQuoted: s.amountOutQuoted,
|
||||
txHash: s.txHash,
|
||||
status: s.status as FiatLpSwapLeg['status'],
|
||||
error: s.error,
|
||||
}));
|
||||
|
||||
const lastTx = liquiditySwaps.find((s) => s.txHash)?.txHash;
|
||||
const anyFailed = liquiditySwaps.some((s) => s.status === 'FAILED');
|
||||
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
chainTxHash: lastTx,
|
||||
liquiditySwaps,
|
||||
});
|
||||
|
||||
if (anyFailed && !dryRun) {
|
||||
record.errors.push('One or more LP→liquidity swaps failed');
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
} catch (err) {
|
||||
record.errors.push(err instanceof Error ? err.message : String(err));
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
43
services/token-aggregation/public/cbtc-landing.html
Normal file
43
services/token-aggregation/public/cbtc-landing.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>1000 cBTC — hot production rails</title>
|
||||
<style>
|
||||
:root { --bg:#0c1117; --fg:#e8eef5; --muted:#8b9aab; --ok:#3dd68c; --line:#1e2a36; --accent:#f7931a; }
|
||||
body { margin:0; font-family: "Segoe UI", system-ui, sans-serif; background:var(--bg); color:var(--fg); }
|
||||
main { max-width:780px; margin:0 auto; padding:2.5rem 1.25rem; }
|
||||
h1 { font-size:1.35rem; font-weight:600; margin:0 0 .35rem; }
|
||||
.sub { color:var(--muted); margin:0 0 1.75rem; font-size:.95rem; }
|
||||
.hero { font-size:2.75rem; font-weight:700; color:var(--accent); letter-spacing:-.02em; margin:0 0 .25rem; }
|
||||
.hero span { font-size:1rem; color:var(--muted); font-weight:500; margin-left:.35rem; }
|
||||
table { width:100%; border-collapse:collapse; margin-top:1.25rem; }
|
||||
th,td { text-align:left; padding:.7rem .5rem; border-bottom:1px solid var(--line); font-size:.95rem; }
|
||||
th { color:var(--muted); font-weight:500; font-size:.75rem; text-transform:uppercase; letter-spacing:.04em; }
|
||||
td.n { font-variant-numeric:tabular-nums; font-weight:600; }
|
||||
.ok { color:var(--ok); }
|
||||
.hl { background: rgba(247,147,26,.12); }
|
||||
.note { margin-top:1.5rem; color:var(--muted); font-size:.85rem; line-height:1.45; }
|
||||
code { font-size:.8rem; color:#c5d4e3; }
|
||||
a { color:#7ec8ff; }
|
||||
.pill { display:inline-block; padding:.15rem .5rem; border:1px solid var(--ok); color:var(--ok); border-radius:4px; font-size:.75rem; margin-left:.35rem; }
|
||||
</style></head>
|
||||
<body><main>
|
||||
<p class="hero">1000 cBTC<span>hot · settled · pool-deep</span></p>
|
||||
<h1>Production rails status</h1>
|
||||
<p class="sub">Ledger reconciled SETTLED · cUSDT PMM ≥1000 cBTC · classic DODO trade verified.</p>
|
||||
<table>
|
||||
<thead><tr><th>Location</th><th>cBTC</th><th>Role</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="hl"><td>Holder <code>0xa55A…4882</code></td><td class="n ok">~1000</td><td>Primary mint destination</td></tr>
|
||||
<tr class="hl"><td>cUSDT PMM <code>0x481C…3a8d</code></td><td class="n ok">1000.0<span class="pill">HOT</span></td><td>Tradable depth + 105M cUSDT</td></tr>
|
||||
<tr><td>cXAUC PMM</td><td class="n">~68</td><td>Secondary</td></tr>
|
||||
<tr><td>cUSDC PMM</td><td class="n">~8</td><td>Thin (no cUSDC mint)</td></tr>
|
||||
<tr><td>Fineract GL 12015</td><td class="n ok">~$64.82M</td><td>≈1000 BTC custody</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="note">
|
||||
Settlement: <code>POST /btc/l1-reconcile</code> → record <code>60ec0214…</code> <strong>SETTLED</strong> (1000 BTC, mint <code>0x594c13…</code>).<br>
|
||||
Trade: transfer cBTC to pool then <code>sellBase(trader)</code> (DODO DVM classic).<br>
|
||||
Caps: swappable / convertible / transferableInternal / transferableExternal.<br>
|
||||
Smoke: <code>node scripts/deployment/smoke-btc-settlement.mjs</code> · LP: <code>HOT_CBTC_EXECUTE=1 node scripts/deployment/hot-cbtc-liquidity-1000.mjs</code><br>
|
||||
Dashboards: <a href="/cbtc">/cbtc</a> · <a href="/reserve">/reserve</a> · <a href="/omnl/dashboard">/omnl/dashboard</a>
|
||||
</p>
|
||||
</main></body></html>
|
||||
@@ -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