From 9f272674aae408c186b15c44d2789c6ef2bb80a1 Mon Sep 17 00:00:00 2001 From: zaragoza444 Date: Tue, 21 Jul 2026 02:32:44 -0700 Subject: [PATCH] feat(settlement): production wallet L1 BTC to cBTC (not institutional ledger) Add wallet settlement source with custody-backed mint path, btc-intake persistence, /btc/wallet/l1-settle and /btc/wallet/status APIs, and full production deploy script. Co-authored-by: Cursor --- config/btc-wallet-l1.chain138.v1.json | 71 ++++++++++ config/deployment-omnl.production.env.example | 3 +- .../settlement-middleware.production.v1.json | 10 ++ docker-compose.omnl-production.yml | 1 + .../dist/btc-settlement.test.js | 10 ++ .../settlement-core/dist/money-supply.d.ts | 9 ++ packages/settlement-core/dist/money-supply.js | 19 +++ packages/settlement-core/dist/types.d.ts | 4 + .../src/btc-settlement.test.ts | 13 ++ packages/settlement-core/src/money-supply.ts | 23 ++++ packages/settlement-core/src/types.ts | 5 + .../deploy-btc-wallet-production.sh | 123 ++++++++++++++++++ .../deployment/deploy-omnl-bank-production.sh | 1 + scripts/deployment/push-portal-env-to-lxc.sh | 2 +- .../src/adapters/settlement-mint-sink.ts | 3 +- .../btc-intake/src/native-bitcoin-watcher.ts | 44 ++++++- .../dist/api/routes/settlement.js | 76 +++++++++++ .../dist/btc-wallet-config.js | 62 +++++++++ .../dist/workflows/btc-l1-settlement.js | 63 ++++++--- .../src/api/routes/settlement.ts | 82 ++++++++++++ .../src/btc-wallet-config.ts | 72 ++++++++++ .../src/workflows/btc-l1-settlement.ts | 74 ++++++++--- 22 files changed, 727 insertions(+), 43 deletions(-) create mode 100644 config/btc-wallet-l1.chain138.v1.json create mode 100644 scripts/deployment/deploy-btc-wallet-production.sh create mode 100644 services/settlement-middleware/dist/btc-wallet-config.js create mode 100644 services/settlement-middleware/src/btc-wallet-config.ts diff --git a/config/btc-wallet-l1.chain138.v1.json b/config/btc-wallet-l1.chain138.v1.json new file mode 100644 index 0000000..c8541f4 --- /dev/null +++ b/config/btc-wallet-l1.chain138.v1.json @@ -0,0 +1,71 @@ +{ + "$schema": "OMNL wallet L1 BTC → cBTC (normal bitcoind deposits, not institutional ledger program)", + "version": "1.0.0", + "updated": "2026-07-21", + "chainId": 138, + "officeId": 24, + "settlementSource": "wallet", + "confirmationsRequired": 6, + "maxDepositSats": 100000000000, + "backingMode": "custody_fair_value", + "token": { + "symbol": "cBTC", + "address": "0xe94260c555aC1d9D3CC9E1632883452ebDf0082E", + "decimals": 8, + "omnlLine": "BTC-M2", + "lineId": "BTC-M2" + }, + "recipients": { + "defaultVault": "0xeb389B0FeB3186e88002e51b404685081C8dB8Ff", + "notes": "Per-deposit recipient comes from btc-intake basketMandate.chain138VaultAddress" + }, + "custody": { + "debitGl": "12015", + "creditGl": "12424", + "memo": "T-BTC-WALLET-L1" + }, + "fineractGl": { + "custodyHo12015": { "glCode": "12015", "officeId": 1, "memo": "T-BTC-WALLET-L1" }, + "interofficeBtc12424": { "glCode": "12424", "officeId": 1, "memo": "T-BTC-WALLET-L1" }, + "m2Broad2200": { "glCode": "2200", "officeId": 24, "memo": "T-BTC-WALLET-M2M3" }, + "m3Token2300": { "glCode": "2300", "officeId": 24, "memo": "T-BTC-WALLET-M2M3" } + }, + "oracle": { + "aggregator": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506", + "proxy": "0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6", + "reserveSystem": "0x607e97cD626f209facfE48c1464815DDE15B5093", + "priceFeedKeeper": "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04" + }, + "dodoEvm": { + "pmmIntegration": "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895" + }, + "pmmPools": { + "cBTC_cUSDT": "0x481CcE1046e1F1ab13BC37f0f374bb5ff75F3a8d", + "cBTC_cUSDC": "0xD8e6131Fe469F0450b19ef71c9878bf9CFd4a5BD", + "cBTC_cXAUC": "0xBb7653746EC39809C8DE27C52fD858d7E32e6A77" + }, + "quoteTokens": { + "cUSDT": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22", + "cUSDC": "0xf22258f57794CC8E06237084b353Ab30fFfa640b", + "cXAUC": "0x290E52a8819A4fbD0714E517225429aA2B70EC6b" + }, + "rpc": { + "envVar": "RPC_URL_138", + "public": "https://rpc-http-pub.d-bis.org", + "explorer": "https://explorer.d-bis.org" + }, + "settlementApi": { + "walletL1Settle": "/btc/wallet/l1-settle", + "walletStatus": "/btc/wallet/status", + "depositInstructions": "http://127.0.0.1:3013/deposit-instructions", + "productionBase": "https://secure.omdnl.org/settlement" + }, + "envKeys": { + "config": "OMNL_BTC_WALLET_CONFIG", + "cbtcAddress": "CBTC_ADDRESS_138", + "mintOperator": "OMNL_MINT_OPERATOR_PRIVATE_KEY", + "rpc138": "RPC_URL_138", + "allowMint": "SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE", + "bitcoindUrl": "BITCOIND_RPC_URL" + } +} diff --git a/config/deployment-omnl.production.env.example b/config/deployment-omnl.production.env.example index ba684d2..49bf4bc 100644 --- a/config/deployment-omnl.production.env.example +++ b/config/deployment-omnl.production.env.example @@ -129,8 +129,9 @@ ORACLE_PROXY_ADDRESS=0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6 CHAIN138_POOL_CBTC_CUSDT=0x481CcE1046e1F1ab13BC37f0f374bb5ff75F3a8d CHAIN138_POOL_CBTC_CUSDC=0xD8e6131Fe469F0450b19ef71c9878bf9CFd4a5BD CHAIN138_POOL_CBTC_CXAUC=0xBb7653746EC39809C8DE27C52fD858d7E32e6A77 +OMNL_BTC_WALLET_CONFIG=config/btc-wallet-l1.chain138.v1.json OMNL_MINT_OPERATOR_PRIVATE_KEY= -SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=0 +SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=1 # 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 diff --git a/config/settlement-middleware.production.v1.json b/config/settlement-middleware.production.v1.json index f6e83af..6446ed8 100644 --- a/config/settlement-middleware.production.v1.json +++ b/config/settlement-middleware.production.v1.json @@ -67,6 +67,16 @@ "ledgerEndpoint": "/btc/ledger", "l1SettleEndpoint": "/btc/l1-settle" }, + "btcWallet": { + "configPath": "config/btc-wallet-l1.chain138.v1.json", + "settlementSource": "wallet", + "backingMode": "custody_fair_value", + "confirmationsRequired": 6, + "walletSettleEndpoint": "/btc/wallet/l1-settle", + "walletStatusEndpoint": "/btc/wallet/status", + "btcIntakePort": 3013, + "notes": "Normal bitcoind wallet deposits → custody journal + cBTC mint. Not subject to institutional 1,000 BTC headroom gate." + }, "guosmm": { "emcbCharter": "GUOSMM-B-2026-001", "registryPath": "config/guosmm-notices.v1.json", diff --git a/docker-compose.omnl-production.yml b/docker-compose.omnl-production.yml index 18e5efa..c0f24ae 100644 --- a/docker-compose.omnl-production.yml +++ b/docker-compose.omnl-production.yml @@ -56,6 +56,7 @@ services: - SETTLEMENT_MIDDLEWARE_PORT=3011 - SETTLEMENT_MIDDLEWARE_CONFIG=/app/config/settlement-middleware.production.v1.json - OMNL_BTC_LEDGER_CONFIG=/app/config/btc-l1-ledger-1000.chain138.v1.json + - OMNL_BTC_WALLET_CONFIG=/app/config/btc-wallet-l1.chain138.v1.json - OMNL_SUPPORTED_CHAINS_CONFIG=/app/config/omnl-supported-chains.v1.json - TOKEN_AGGREGATION_URL=http://token-aggregation:3000 - CHAIN138_ORACLE_URL=http://chain138-oracle:3500 diff --git a/packages/settlement-core/dist/btc-settlement.test.js b/packages/settlement-core/dist/btc-settlement.test.js index a159a25..b674fb8 100644 --- a/packages/settlement-core/dist/btc-settlement.test.js +++ b/packages/settlement-core/dist/btc-settlement.test.js @@ -23,4 +23,14 @@ const money_supply_1 = require("./money-supply"); strict_1.default.equal(j.creditGl, '12424'); strict_1.default.equal(j.memo, 'T-BTC-L1-HO'); }); + (0, node_test_1.it)('wallet custody-backed load accepts fair-value deposit within cap', () => { + const ok = (0, money_supply_1.walletBtcCustodyBackedLoad)('65000.00', 10_000_000_000, 100_000_000); + strict_1.default.equal(ok.fullyBacked, true); + strict_1.default.equal(ok.loadAmount, '65000.00'); + }); + (0, node_test_1.it)('wallet custody-backed load rejects over max deposit sats', () => { + const bad = (0, money_supply_1.walletBtcCustodyBackedLoad)('95000.00', 50_000_000, 100_000_000); + strict_1.default.equal(bad.fullyBacked, false); + strict_1.default.match(bad.reason ?? '', /exceeds wallet max/i); + }); }); diff --git a/packages/settlement-core/dist/money-supply.d.ts b/packages/settlement-core/dist/money-supply.d.ts index 05a6de7..a666bb4 100644 --- a/packages/settlement-core/dist/money-supply.d.ts +++ b/packages/settlement-core/dist/money-supply.d.ts @@ -29,6 +29,15 @@ export declare function m2FiatToTokenLoadAmount(fiatAmount: string, m2Available: loadAmount: string; fullyBacked: boolean; }; +/** + * Wallet L1 BTC deposit: custody journal (12015/12424) backs cBTC mint at IFRS 13 fair value. + * Skips institutional M2−M3 headroom gate — each confirmed on-chain deposit is self-backed. + */ +export declare function walletBtcCustodyBackedLoad(usdValue: string, maxDepositSats?: number, amountSats?: number): { + loadAmount: string; + fullyBacked: boolean; + reason?: string; +}; /** M3 on-chain token mint must not exceed outstanding M2 broad money backing. */ export declare function m3TokenLoadAmount(requested: string, m2Broad: string, m3Outstanding: string): { loadAmount: string; diff --git a/packages/settlement-core/dist/money-supply.js b/packages/settlement-core/dist/money-supply.js index 0bdc570..6d49d7a 100644 --- a/packages/settlement-core/dist/money-supply.js +++ b/packages/settlement-core/dist/money-supply.js @@ -4,6 +4,7 @@ exports.ALL_MONEY_LAYERS = exports.POLICY = exports.GL_CODES = void 0; exports.aggregateMetaFiatLayers = aggregateMetaFiatLayers; exports.buildMoneySupplySnapshot = buildMoneySupplySnapshot; exports.m2FiatToTokenLoadAmount = m2FiatToTokenLoadAmount; +exports.walletBtcCustodyBackedLoad = walletBtcCustodyBackedLoad; exports.m3TokenLoadAmount = m3TokenLoadAmount; exports.resolveZBankLayerJournal = resolveZBankLayerJournal; exports.zBankM0ToM4Hops = zBankM0ToM4Hops; @@ -98,6 +99,24 @@ function m2FiatToTokenLoadAmount(fiatAmount, m2Available) { fullyBacked: load >= fiat && fiat > 0, }; } +/** + * Wallet L1 BTC deposit: custody journal (12015/12424) backs cBTC mint at IFRS 13 fair value. + * Skips institutional M2−M3 headroom gate — each confirmed on-chain deposit is self-backed. + */ +function walletBtcCustodyBackedLoad(usdValue, maxDepositSats, amountSats) { + const fiat = parseFloat(usdValue) || 0; + if (!(fiat > 0)) { + return { loadAmount: '0.00', fullyBacked: false, reason: 'USD value must be positive' }; + } + if (maxDepositSats != null && maxDepositSats > 0 && amountSats != null && amountSats > maxDepositSats) { + return { + loadAmount: '0.00', + fullyBacked: false, + reason: `Deposit ${amountSats} sats exceeds wallet max ${maxDepositSats} sats`, + }; + } + return { loadAmount: fiat.toFixed(2), fullyBacked: true }; +} /** M3 on-chain token mint must not exceed outstanding M2 broad money backing. */ function m3TokenLoadAmount(requested, m2Broad, m3Outstanding) { const req = parseFloat(requested) || 0; diff --git a/packages/settlement-core/dist/types.d.ts b/packages/settlement-core/dist/types.d.ts index 9ff57dd..a0c7136 100644 --- a/packages/settlement-core/dist/types.d.ts +++ b/packages/settlement-core/dist/types.d.ts @@ -102,6 +102,8 @@ export type FiatLpToLiquidityRequest = { maxSlippageBps?: number; dryRun?: boolean; }; +/** Wallet = real L1 bitcoind deposit; ledger = institutional 1,000 BTC program. */ +export type BtcL1SettlementSource = 'wallet' | 'ledger'; /** L1 Bitcoin deposit confirmed → Fineract custody journal + cBTC mint. */ export type BtcL1SettlementRequest = { idempotencyKey: string; @@ -115,6 +117,8 @@ export type BtcL1SettlementRequest = { /** Chain 138 vault for cBTC mint. */ recipientAddress: string; valueDate?: string; + /** wallet = custody-backed mint (btc-intake); ledger = M2/M3 headroom program. */ + settlementSource?: BtcL1SettlementSource; }; /** * Mark a FAILED/partial BTC L1 settle as SETTLED when books + mint already diff --git a/packages/settlement-core/src/btc-settlement.test.ts b/packages/settlement-core/src/btc-settlement.test.ts index 503d919..d547881 100644 --- a/packages/settlement-core/src/btc-settlement.test.ts +++ b/packages/settlement-core/src/btc-settlement.test.ts @@ -4,6 +4,7 @@ import { btcUsdToJournalAmount, resolveBtcL1CustodyJournal, satsToBtcDecimal, + walletBtcCustodyBackedLoad, } from './money-supply'; describe('BTC settlement helpers', () => { @@ -25,4 +26,16 @@ describe('BTC settlement helpers', () => { assert.equal(j.creditGl, '12424'); assert.equal(j.memo, 'T-BTC-L1-HO'); }); + + it('wallet custody-backed load accepts fair-value deposit within cap', () => { + const ok = walletBtcCustodyBackedLoad('65000.00', 10_000_000_000, 100_000_000); + assert.equal(ok.fullyBacked, true); + assert.equal(ok.loadAmount, '65000.00'); + }); + + it('wallet custody-backed load rejects over max deposit sats', () => { + const bad = walletBtcCustodyBackedLoad('95000.00', 50_000_000, 100_000_000); + assert.equal(bad.fullyBacked, false); + assert.match(bad.reason ?? '', /exceeds wallet max/i); + }); }); diff --git a/packages/settlement-core/src/money-supply.ts b/packages/settlement-core/src/money-supply.ts index 8f6a4d1..c96916d 100644 --- a/packages/settlement-core/src/money-supply.ts +++ b/packages/settlement-core/src/money-supply.ts @@ -100,6 +100,29 @@ export function m2FiatToTokenLoadAmount(fiatAmount: string, m2Available: string) }; } +/** + * Wallet L1 BTC deposit: custody journal (12015/12424) backs cBTC mint at IFRS 13 fair value. + * Skips institutional M2−M3 headroom gate — each confirmed on-chain deposit is self-backed. + */ +export function walletBtcCustodyBackedLoad(usdValue: string, maxDepositSats?: number, amountSats?: number): { + loadAmount: string; + fullyBacked: boolean; + reason?: string; +} { + const fiat = parseFloat(usdValue) || 0; + if (!(fiat > 0)) { + return { loadAmount: '0.00', fullyBacked: false, reason: 'USD value must be positive' }; + } + if (maxDepositSats != null && maxDepositSats > 0 && amountSats != null && amountSats > maxDepositSats) { + return { + loadAmount: '0.00', + fullyBacked: false, + reason: `Deposit ${amountSats} sats exceeds wallet max ${maxDepositSats} sats`, + }; + } + return { loadAmount: fiat.toFixed(2), fullyBacked: true }; +} + /** M3 on-chain token mint must not exceed outstanding M2 broad money backing. */ export function m3TokenLoadAmount(requested: string, m2Broad: string, m3Outstanding: string): { loadAmount: string; diff --git a/packages/settlement-core/src/types.ts b/packages/settlement-core/src/types.ts index 6bd7d9b..004e793 100644 --- a/packages/settlement-core/src/types.ts +++ b/packages/settlement-core/src/types.ts @@ -134,6 +134,9 @@ export type FiatLpToLiquidityRequest = { dryRun?: boolean; }; +/** Wallet = real L1 bitcoind deposit; ledger = institutional 1,000 BTC program. */ +export type BtcL1SettlementSource = 'wallet' | 'ledger'; + /** L1 Bitcoin deposit confirmed → Fineract custody journal + cBTC mint. */ export type BtcL1SettlementRequest = { idempotencyKey: string; @@ -147,6 +150,8 @@ export type BtcL1SettlementRequest = { /** Chain 138 vault for cBTC mint. */ recipientAddress: string; valueDate?: string; + /** wallet = custody-backed mint (btc-intake); ledger = M2/M3 headroom program. */ + settlementSource?: BtcL1SettlementSource; }; /** diff --git a/scripts/deployment/deploy-btc-wallet-production.sh b/scripts/deployment/deploy-btc-wallet-production.sh new file mode 100644 index 0000000..4e25f6b --- /dev/null +++ b/scripts/deployment/deploy-btc-wallet-production.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Full production deploy: wallet L1 BTC → cBTC (settlement-middleware + btc-intake). +# +# Run on dev-bis-ali / secure.omdnl.org backend: +# bash scripts/deployment/deploy-btc-wallet-production.sh +# +set -euo pipefail + +REPO_DIR="${OMNL_BANK_ROOT:-$HOME/smom-dbis-138}" +ENV_FILE="${OMNL_BANK_ENV:-$REPO_DIR/.env}" +LOG_DIR="${OMNL_BANK_LOG_DIR:-$HOME/omnl-bank/logs}" + +log() { echo "[$(date -Iseconds)] $*"; } + +cd "$REPO_DIR" + +if [[ "${OMNL_SKIP_GIT_PULL:-0}" != "1" ]]; then + log "Pull latest main..." + git fetch origin && git checkout main && git pull --ff-only origin main +fi + +export SETTLEMENT_MIDDLEWARE_CONFIG="${SETTLEMENT_MIDDLEWARE_CONFIG:-$REPO_DIR/config/settlement-middleware.production.v1.json}" +export OMNL_BTC_LEDGER_CONFIG="${OMNL_BTC_LEDGER_CONFIG:-$REPO_DIR/config/btc-l1-ledger-1000.chain138.v1.json}" +export OMNL_BTC_WALLET_CONFIG="${OMNL_BTC_WALLET_CONFIG:-$REPO_DIR/config/btc-wallet-l1.chain138.v1.json}" + +if [[ -f "$ENV_FILE" ]]; then + set -a + set +u + # shellcheck disable=SC1090 + source "$ENV_FILE" + set -u + set +a +fi + +if [[ -f "$REPO_DIR/scripts/deployment/sync-btc-ledger-contract-env.mjs" ]]; then + node "$REPO_DIR/scripts/deployment/sync-btc-ledger-contract-env.mjs" | while IFS= read -r line; do + key="${line%%=*}" + grep -q "^${key}=" "$ENV_FILE" 2>/dev/null || echo "$line" >>"$ENV_FILE" + done + set -a + set +u + # shellcheck disable=SC1090 + source "$ENV_FILE" + set -u + set +a +fi + +log "Building settlement-core, settlement-middleware, btc-intake..." +for dir in packages/settlement-core services/settlement-middleware services/btc-intake; do + cd "$REPO_DIR/$dir" + NODE_ENV=development npm install --no-fund --no-audit + npm run build +done + +mkdir -p "$LOG_DIR" + +start_settlement() { + cd "$REPO_DIR/services/settlement-middleware" + pkill -f "smom-dbis-138/services/settlement-middleware" 2>/dev/null || true + fuser -k 3011/tcp 2>/dev/null || true + sleep 1 + nohup env \ + SETTLEMENT_MIDDLEWARE_CONFIG="$SETTLEMENT_MIDDLEWARE_CONFIG" \ + OMNL_BTC_LEDGER_CONFIG="$OMNL_BTC_LEDGER_CONFIG" \ + OMNL_BTC_WALLET_CONFIG="$OMNL_BTC_WALLET_CONFIG" \ + OMNL_SUPPORTED_CHAINS_CONFIG="${OMNL_SUPPORTED_CHAINS_CONFIG:-$REPO_DIR/config/omnl-supported-chains.v1.json}" \ + OMNL_M2_TOKEN_REGISTRY="${OMNL_M2_TOKEN_REGISTRY:-$REPO_DIR/config/omnl-m2-token-registry.v1.json}" \ + OMNL_API_KEY="${OMNL_API_KEY:-${OMNL_SUPER_ADMIN_SECURE_KEY:-}}" \ + OMNL_FINERACT_BASE_URL="${OMNL_FINERACT_BASE_URL:-${FINERACT_BASE_URL:-}}" \ + OMNL_FINERACT_TENANT="${OMNL_FINERACT_TENANT:-${FINERACT_TENANT:-omnl}}" \ + OMNL_FINERACT_USER="${OMNL_FINERACT_USER:-}" \ + OMNL_FINERACT_USERNAME="${OMNL_FINERACT_USERNAME:-}" \ + OMNL_FINERACT_PASSWORD="${OMNL_FINERACT_PASSWORD:-}" \ + SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE="${SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE:-1}" \ + TOKEN_AGGREGATION_URL="${TOKEN_AGGREGATION_URL:-http://127.0.0.1:3000}" \ + CHAIN138_ORACLE_URL="${CHAIN138_ORACLE_URL:-http://127.0.0.1:3500}" \ + RPC_URL_138="${RPC_URL_138:-${CHAIN_138_RPC_URL:-}}" \ + OMNL_MINT_OPERATOR_PRIVATE_KEY="${OMNL_MINT_OPERATOR_PRIVATE_KEY:-}" \ + OMNL_REQUIRE_LIVE_BTC_PRICE="${OMNL_REQUIRE_LIVE_BTC_PRICE:-1}" \ + CBTC_ADDRESS_138="${CBTC_ADDRESS_138:-}" \ + BTC_L1_RECIPIENT_ADDRESS="${BTC_L1_RECIPIENT_ADDRESS:-}" \ + DODO_PMM_INTEGRATION="${DODO_PMM_INTEGRATION:-}" \ + npm run start >"$LOG_DIR/settlement-middleware.log" 2>&1 & + echo $! >"$LOG_DIR/settlement-middleware.pid" +} + +start_btc_intake() { + cd "$REPO_DIR/services/btc-intake" + pkill -f "smom-dbis-138/services/btc-intake" 2>/dev/null || true + fuser -k 3013/tcp 2>/dev/null || true + sleep 1 + nohup env \ + BTC_INTAKE_PORT="${BTC_INTAKE_PORT:-3013}" \ + BTC_INTAKE_DATA_DIR="${BTC_INTAKE_DATA_DIR:-$REPO_DIR/data/btc-intake}" \ + SETTLEMENT_MIDDLEWARE_URL="${SETTLEMENT_MIDDLEWARE_URL:-http://127.0.0.1:3011}" \ + OMNL_API_KEY="${OMNL_API_KEY:-${OMNL_SUPER_ADMIN_SECURE_KEY:-}}" \ + BITCOIND_RPC_URL="${BITCOIND_RPC_URL:-}" \ + BITCOIND_RPC_USER="${BITCOIND_RPC_USER:-}" \ + BITCOIND_RPC_PASS="${BITCOIND_RPC_PASS:-}" \ + BTC_NETWORK="${BTC_NETWORK:-mainnet}" \ + BTC_CONFIRMATIONS_REQUIRED="${BTC_CONFIRMATIONS_REQUIRED:-6}" \ + BTC_INTAKE_SYNC_INTERVAL_MS="${BTC_INTAKE_SYNC_INTERVAL_MS:-30000}" \ + npm run start >"$LOG_DIR/btc-intake.log" 2>&1 & + echo $! >"$LOG_DIR/btc-intake.pid" +} + +start_settlement +sleep 4 +start_btc_intake +sleep 3 + +API_KEY="${OMNL_API_KEY:-${OMNL_SUPER_ADMIN_SECURE_KEY:-}}" +log "Smoke: GET /btc/wallet/status" +curl -sf -H "Authorization: Bearer ${API_KEY}" \ + "http://127.0.0.1:3011/api/v1/settlement/btc/wallet/status" | head -c 500 +echo +log "Smoke: GET btc-intake /health" +curl -sf "http://127.0.0.1:3013/health" | head -c 200 || log "WARN: btc-intake degraded (bitcoind may be offline)" +echo +log "Wallet BTC production deploy complete" +log " Deposit: POST http://127.0.0.1:3013/deposit-instructions" +log " Settle: POST https://secure.omdnl.org/settlement/btc/wallet/l1-settle" +log " Status: GET https://secure.omdnl.org/settlement/btc/wallet/status" diff --git a/scripts/deployment/deploy-omnl-bank-production.sh b/scripts/deployment/deploy-omnl-bank-production.sh index c334e71..0e579ba 100644 --- a/scripts/deployment/deploy-omnl-bank-production.sh +++ b/scripts/deployment/deploy-omnl-bank-production.sh @@ -172,6 +172,7 @@ start_svc() { OMNL_MINT_OPERATOR_PRIVATE_KEY="${OMNL_MINT_OPERATOR_PRIVATE_KEY:-}" \ OMNL_REQUIRE_LIVE_BTC_PRICE="${OMNL_REQUIRE_LIVE_BTC_PRICE:-1}" \ OMNL_BTC_LEDGER_CONFIG="${OMNL_BTC_LEDGER_CONFIG:-$REPO_DIR/config/btc-l1-ledger-1000.chain138.v1.json}" \ + OMNL_BTC_WALLET_CONFIG="${OMNL_BTC_WALLET_CONFIG:-$REPO_DIR/config/btc-wallet-l1.chain138.v1.json}" \ CBTC_ADDRESS_138="${CBTC_ADDRESS_138:-}" \ BTC_L1_RECIPIENT_ADDRESS="${BTC_L1_RECIPIENT_ADDRESS:-}" \ BTC_EXISTING_MINT_HOLDER="${BTC_EXISTING_MINT_HOLDER:-}" \ diff --git a/scripts/deployment/push-portal-env-to-lxc.sh b/scripts/deployment/push-portal-env-to-lxc.sh index 6380eca..2082847 100644 --- a/scripts/deployment/push-portal-env-to-lxc.sh +++ b/scripts/deployment/push-portal-env-to-lxc.sh @@ -26,7 +26,7 @@ set -u set +a FRAG="$(mktemp)" -grep -E '^(OMNL_FINERACT_|OMNL_PUBLIC_MONEY_SUPPLY=|OMNL_REQUIRE_API_KEY=|OMNL_CUSTOMER_SECURITY_PRODUCTION=|OMNL_CUSTOMER_API_KEYS=|OFFICE24_OPENING_M1_USD=|OMNL_PORTAL_INTERNAL_SECRET=|JWT_SECRET=|DATABASE_URL=|RPC_URL_138=|CHAIN_138_RPC_URL=|SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=|SETTLEMENT_ALLOW_HYBX_PRODUCTION=|OMNL_ALLOW_CHAIN_MINT_EXECUTE=|OMNL_MINT_OPERATOR_PRIVATE_KEY=|OMNL_BTC_LEDGER_CONFIG=|CBTC_ADDRESS_138=|BTC_L1_RECIPIENT_ADDRESS=|BTC_EXISTING_MINT_HOLDER=|DODO_PMM_INTEGRATION=|CHAIN_138_DODO_PMM_INTEGRATION=|RESERVE_SYSTEM=|ORACLE_PROXY_ADDRESS=|CHAIN138_POOL_CBTC_CUSDT=|CHAIN138_POOL_CBTC_CUSDC=|CHAIN138_POOL_CBTC_CXAUC=|CHAIN138_ORACLE_URL=|CHAIN138_ORACLE_PORT=|TOKEN_AGGREGATION_URL=|OMNL_REQUIRE_LIVE_BTC_PRICE=|KEEPER_PRIVATE_KEY=|PRICE_FEED_KEEPER_ADDRESS=|AGGREGATOR_ADDRESS=|SWIFT_LISTENER_URL=|HYBX_BASE_URL=|HYBX_API=|HYBX_ENVIRONMENT=|HYBX_API_KEY=|HYBX_CLIENT_ID=|HYBX_CLIENT_SECRET=|HYBX_WEBHOOK_SECRET=|HYBX_MIFOS_FINERACT_SIDECAR_URL=|HYBX_ALLOW_LAN_SIDECAR=|INFURA_PROJECT_ID=|INFURA_PROJECT_SECRET=)' "$ENV_FILE" >"$FRAG" || true +grep -E '^(OMNL_FINERACT_|OMNL_PUBLIC_MONEY_SUPPLY=|OMNL_REQUIRE_API_KEY=|OMNL_CUSTOMER_SECURITY_PRODUCTION=|OMNL_CUSTOMER_API_KEYS=|OFFICE24_OPENING_M1_USD=|OMNL_PORTAL_INTERNAL_SECRET=|JWT_SECRET=|DATABASE_URL=|RPC_URL_138=|CHAIN_138_RPC_URL=|SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=|SETTLEMENT_ALLOW_HYBX_PRODUCTION=|OMNL_ALLOW_CHAIN_MINT_EXECUTE=|OMNL_MINT_OPERATOR_PRIVATE_KEY=|OMNL_BTC_LEDGER_CONFIG=|OMNL_BTC_WALLET_CONFIG=|CBTC_ADDRESS_138=|BTC_L1_RECIPIENT_ADDRESS=|BTC_EXISTING_MINT_HOLDER=|DODO_PMM_INTEGRATION=|CHAIN_138_DODO_PMM_INTEGRATION=|RESERVE_SYSTEM=|ORACLE_PROXY_ADDRESS=|CHAIN138_POOL_CBTC_CUSDT=|CHAIN138_POOL_CBTC_CUSDC=|CHAIN138_POOL_CBTC_CXAUC=|BITCOIND_RPC_URL=|BITCOIND_RPC_USER=|BITCOIND_RPC_PASS=|BTC_NETWORK=|BTC_CONFIRMATIONS_REQUIRED=|BTC_INTAKE_PORT=|SETTLEMENT_MIDDLEWARE_URL=|CHAIN138_ORACLE_URL=|CHAIN138_ORACLE_PORT=|TOKEN_AGGREGATION_URL=|OMNL_REQUIRE_LIVE_BTC_PRICE=|KEEPER_PRIVATE_KEY=|PRICE_FEED_KEEPER_ADDRESS=|AGGREGATOR_ADDRESS=|SWIFT_LISTENER_URL=|HYBX_BASE_URL=|HYBX_API=|HYBX_ENVIRONMENT=|HYBX_API_KEY=|HYBX_CLIENT_ID=|HYBX_CLIENT_SECRET=|HYBX_WEBHOOK_SECRET=|HYBX_MIFOS_FINERACT_SIDECAR_URL=|HYBX_ALLOW_LAN_SIDECAR=|INFURA_PROJECT_ID=|INFURA_PROJECT_SECRET=)' "$ENV_FILE" >"$FRAG" || true ADMIN_ENV_KEY="$(node -e " const fs = require('fs'); diff --git a/services/btc-intake/src/adapters/settlement-mint-sink.ts b/services/btc-intake/src/adapters/settlement-mint-sink.ts index 10291b5..c554420 100644 --- a/services/btc-intake/src/adapters/settlement-mint-sink.ts +++ b/services/btc-intake/src/adapters/settlement-mint-sink.ts @@ -64,11 +64,12 @@ export class SettlementMintJobSink implements MintJobSink { amountSats: job.amountSats, confirmations: job.confirmations ?? Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6), recipientAddress: job.chain138VaultAddress, + settlementSource: 'wallet' as const, }; try { const { data } = await axios.post( - `${this.settlementBase}/api/v1/settlement/btc/l1-settle`, + `${this.settlementBase}/api/v1/settlement/btc/wallet/l1-settle`, body, { headers, timeout: 180000 }, ); diff --git a/services/btc-intake/src/native-bitcoin-watcher.ts b/services/btc-intake/src/native-bitcoin-watcher.ts index 3e9d497..eef2bc9 100644 --- a/services/btc-intake/src/native-bitcoin-watcher.ts +++ b/services/btc-intake/src/native-bitcoin-watcher.ts @@ -1,4 +1,6 @@ import { randomUUID } from 'crypto'; +import fs from 'fs'; +import path from 'path'; import type { AuditPublisher, CustodyAdapter, @@ -13,7 +15,19 @@ import type { MintJob, } from './types'; -const DEFAULT_CONFIRMATIONS_REQUIRED = 6; +const DEFAULT_CONFIRMATIONS_REQUIRED = Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6); + +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 instructionsPath(): string { + return path.join(dataDir(), 'instructions.json'); +} function nowIso(): string { return new Date().toISOString(); @@ -34,7 +48,27 @@ export class NativeBitcoinWatcher { private readonly mintSink: MintJobSink, private readonly auditPublisher: AuditPublisher, private readonly outstandingPolicy?: OutstandingPolicy, - ) {} + ) { + this.loadInstructionsFromDisk(); + } + + private persistInstructions(): void { + const rows = Array.from(this.instructions.values()); + fs.writeFileSync(instructionsPath(), JSON.stringify(rows, null, 2)); + } + + private loadInstructionsFromDisk(): void { + const file = instructionsPath(); + if (!fs.existsSync(file)) return; + try { + const rows = JSON.parse(fs.readFileSync(file, 'utf8')) as DepositInstruction[]; + for (const row of rows) { + if (row?.id) this.instructions.set(row.id, row); + } + } catch { + // ignore corrupt store — address-map.json still maps deposits + } + } async createDepositInstruction(input: { basketMandate: BasketMandateSnapshot; @@ -60,6 +94,7 @@ export class NativeBitcoinWatcher { }; this.instructions.set(instruction.id, instruction); + this.persistInstructions(); await this.publishAudit({ id: randomUUID(), instructionId, @@ -130,6 +165,7 @@ export class NativeBitcoinWatcher { }, createdAt: instruction.updatedAt, }); + this.persistInstructions(); return; } @@ -138,7 +174,7 @@ export class NativeBitcoinWatcher { id: randomUUID(), instructionId: instruction.id, category: 'confirmation', - message: 'Deposit reached 6 confirmations and is eligible for minting', + message: `Deposit reached ${instruction.confirmationsRequired} confirmations and is eligible for minting`, metadata: { txId: event.txId, confirmations: event.confirmations, @@ -212,6 +248,7 @@ export class NativeBitcoinWatcher { this.mintJobs.set(instruction.id, mintJob); instruction.status = 'minted'; instruction.updatedAt = timestamp; + this.persistInstructions(); await this.publishAudit({ id: randomUUID(), @@ -235,6 +272,7 @@ export class NativeBitcoinWatcher { instruction.status = 'frozen'; instruction.freezeReason = reason; instruction.updatedAt = nowIso(); + this.persistInstructions(); await this.publishAudit({ id: randomUUID(), diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index 88d17dc..b3f3f92 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -16,6 +16,7 @@ const btc_l1_settlement_1 = require("../../workflows/btc-l1-settlement"); const fineract_1 = require("../../adapters/fineract"); const pricing_1 = require("../../adapters/pricing"); const btc_ledger_config_1 = require("../../btc-ledger-config"); +const btc_wallet_config_1 = require("../../btc-wallet-config"); const external_banks_hub_1 = require("../../adapters/external-banks-hub"); const card_issuers_hub_1 = require("../../adapters/card-issuers-hub"); const auth_1 = require("../../middleware/auth"); @@ -588,6 +589,7 @@ function createSettlementRouter() { officeId: (0, config_1.settlementOfficeId)(body.officeId), confirmations: Number(body.confirmations ?? 0), amountSats: Number(body.amountSats), + settlementSource: body.settlementSource ?? 'ledger', }); res.status(record.phase === 'FAILED' ? 422 : 200).json(record); } @@ -648,6 +650,80 @@ function createSettlementRouter() { res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); } }); + /** Normal wallet L1 BTC → cBTC (bitcoind deposits via btc-intake). */ + router.post('/btc/wallet/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), + settlementSource: 'wallet', + }); + res.status(record.phase === 'FAILED' ? 422 : 200).json(record); + } + catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); + router.get('/btc/wallet/status', async (_req, res) => { + if (!(0, auth_1.requireApiKey)(_req, res)) + return; + try { + const walletCfg = (0, btc_wallet_config_1.loadBtcWalletConfig)(); + const [balancesHo, quote] = await Promise.all([ + (0, fineract_1.fetchGlBalances)(1), + (0, pricing_1.resolveLiveBtcUsd)().catch((e) => ({ + error: e instanceof Error ? e.message : String(e), + })), + ]); + const walletSettlements = settlement_store_1.settlementStore + .list(500) + .filter((r) => r.btcSettlement != null && + r.request.remittanceInfo?.includes('wallet settle')); + const settledSats = walletSettlements + .filter((r) => r.phase === 'SETTLED') + .reduce((sum, r) => sum + (r.btcSettlement?.amountSats ?? 0), 0); + res.json({ + ...(0, btc_wallet_config_1.btcWalletStatusPayload)(), + asOf: new Date().toISOString(), + gl: { + custodyHo12015: balancesHo['12015'] ?? '0', + interofficeBtc12424: balancesHo['12424'] ?? '0', + }, + btcUsd: 'btcUsdRate' in quote + ? { rate: quote.btcUsdRate, source: quote.source, asOf: quote.asOf } + : { error: quote.error }, + mintQueue: { + settledSats, + settlementCount: walletSettlements.length, + }, + capabilities: { + depositInstructions: walletCfg.settlementApi?.depositInstructions, + walletSettle: '/btc/wallet/l1-settle', + backingMode: walletCfg.backingMode, + note: 'Real L1 BTC from bitcoind wallet — custody-backed cBTC mint, not institutional ledger headroom', + }, + }); + } + catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); router.get('/btc/ledger', async (_req, res) => { if (!(0, auth_1.requireApiKey)(_req, res)) return; diff --git a/services/settlement-middleware/dist/btc-wallet-config.js b/services/settlement-middleware/dist/btc-wallet-config.js new file mode 100644 index 0000000..ba21e49 --- /dev/null +++ b/services/settlement-middleware/dist/btc-wallet-config.js @@ -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 }); +exports.loadBtcWalletConfig = loadBtcWalletConfig; +exports.btcWalletStatusPayload = btcWalletStatusPayload; +exports.clearBtcWalletConfigCache = clearBtcWalletConfigCache; +const fs_1 = __importDefault(require("fs")); +const path_1 = __importDefault(require("path")); +const btc_ledger_config_1 = require("./btc-ledger-config"); +let cached = null; +function repoRoot() { + return path_1.default.resolve(__dirname, '../../../'); +} +function configPath() { + const rel = process.env.OMNL_BTC_WALLET_CONFIG || + process.env.BTC_WALLET_L1_CONFIG || + 'config/btc-wallet-l1.chain138.v1.json'; + return path_1.default.isAbsolute(rel) ? rel : path_1.default.join(repoRoot(), rel.replace(/^\//, '')); +} +function loadBtcWalletConfig() { + if (cached) + return cached; + const raw = JSON.parse(fs_1.default.readFileSync(configPath(), 'utf8')); + const ledger = (0, btc_ledger_config_1.loadBtcLedgerConfig)(); + cached = { + ...raw, + token: { ...ledger.token, ...raw.token }, + oracle: { ...ledger.oracle, ...raw.oracle }, + dodoEvm: { ...ledger.dodoEvm, ...raw.dodoEvm }, + pmmPools: { ...ledger.pmmPools, ...raw.pmmPools }, + quoteTokens: { ...ledger.quoteTokens, ...raw.quoteTokens }, + rpc: { ...ledger.rpc, ...raw.rpc }, + confirmationsRequired: Number(process.env.BTC_CONFIRMATIONS_REQUIRED || raw.confirmationsRequired || 6), + maxDepositSats: Number(process.env.BTC_WALLET_MAX_DEPOSIT_SATS || raw.maxDepositSats || 0), + }; + return cached; +} +function btcWalletStatusPayload() { + const cfg = loadBtcWalletConfig(); + return { + configPath: configPath(), + settlementSource: 'wallet', + chainId: cfg.chainId, + officeId: cfg.officeId, + confirmationsRequired: cfg.confirmationsRequired, + maxDepositSats: cfg.maxDepositSats, + backingMode: cfg.backingMode, + token: cfg.token, + recipients: cfg.recipients, + custody: cfg.custody, + dodoEvm: { pmmIntegration: cfg.dodoEvm.pmmIntegration }, + pmmPools: cfg.pmmPools, + rpc: cfg.rpc, + depositApi: cfg.settlementApi?.depositInstructions, + settleEndpoint: cfg.settlementApi?.walletL1Settle ?? '/btc/wallet/l1-settle', + }; +} +function clearBtcWalletConfigCache() { + cached = null; +} diff --git a/services/settlement-middleware/dist/workflows/btc-l1-settlement.js b/services/settlement-middleware/dist/workflows/btc-l1-settlement.js index 216b7a4..9289242 100644 --- a/services/settlement-middleware/dist/workflows/btc-l1-settlement.js +++ b/services/settlement-middleware/dist/workflows/btc-l1-settlement.js @@ -15,8 +15,18 @@ const fineract_1 = require("../adapters/fineract"); const omnl_1 = require("../adapters/omnl"); const pricing_1 = require("../adapters/pricing"); const btc_ledger_config_1 = require("../btc-ledger-config"); +const btc_wallet_config_1 = require("../btc-wallet-config"); const settlement_store_1 = require("../store/settlement-store"); const MIN_CONFIRMATIONS = Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6); +function resolveSettlementSource(input) { + const explicit = input.settlementSource?.trim(); + if (explicit === 'wallet' || explicit === 'ledger') + return explicit; + return 'wallet'; +} +function loadTokenConfig(source) { + return source === 'ledger' ? (0, btc_ledger_config_1.loadBtcLedgerConfig)() : (0, btc_wallet_config_1.loadBtcWalletConfig)(); +} /** Env SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE overrides production JSON when set. */ function allowChainMintExecute() { const env = process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE?.trim(); @@ -46,7 +56,10 @@ function formatSettlementError(err) { async function processBtcL1Settlement(input) { const profile = (0, config_1.loadOfficeProfile)(); const officeId = (0, config_1.settlementOfficeId)(input.officeId); - const req = { ...input, officeId }; + const settlementSource = resolveSettlementSource(input); + const tokenCfg = loadTokenConfig(settlementSource); + const walletCfg = settlementSource === 'wallet' ? (0, btc_wallet_config_1.loadBtcWalletConfig)() : null; + const req = { ...input, officeId, settlementSource }; if (!req.idempotencyKey || !req.txId || !req.instructionId) { throw new Error('idempotencyKey, txId, and instructionId are required'); } @@ -89,7 +102,7 @@ async function processBtcL1Settlement(input) { amount: btcAmount, creditorIban: 'INTERNAL-BTC-L1-CUSTODY', beneficiaryName: req.recipientAddress, - remittanceInfo: `L1 BTC settle tx=${req.txId} instruction=${req.instructionId} → cBTC`, + remittanceInfo: `L1 BTC ${settlementSource} settle tx=${req.txId} instruction=${req.instructionId} → cBTC`, moneyLayers: ['M0', 'M2', 'M3'], rail: 'CHAIN138', convertToCrypto: { @@ -128,7 +141,7 @@ async function processBtcL1Settlement(input) { priceAsOf: quote.asOf, }, verbiageDocument: [ - `OMNL L1 BTC custody settlement (IFRS 13 fair value)`, + `OMNL L1 BTC ${settlementSource} settlement (IFRS 13 fair value)`, `Office ${officeId} · instruction ${req.instructionId}`, `L1 tx ${req.txId} · ${req.confirmations} confirmations`, `Deposit ${req.depositAddress}`, @@ -150,16 +163,31 @@ async function processBtcL1Settlement(input) { 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; + let loadAmount = '0.00'; + let fullyBacked = false; + if (settlementSource === 'wallet') { + const walletLoad = (0, settlement_core_1.walletBtcCustodyBackedLoad)(usdValue, walletCfg?.maxDepositSats, req.amountSats); + loadAmount = walletLoad.loadAmount; + fullyBacked = walletLoad.fullyBacked; + if (!fullyBacked) { + record.errors.push(walletLoad.reason || + `Wallet BTC custody-backed load rejected for ${req.amountSats} sats`); + advance('FAILED', { fineractJournalRef: String(custodyJe.resourceId) }); + return record; + } + } + else { + const balances = await (0, fineract_1.fetchGlBalances)(officeId); + const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances); + const m2Check = (0, settlement_core_1.m2FiatToTokenLoadAmount)(usdValue, snap.M2.broadMoney); + const m3Check = (0, settlement_core_1.m3TokenLoadAmount)(m2Check.loadAmount, snap.M2.broadMoney, snap.M3.tokenLiabilities); + loadAmount = m2Check.loadAmount; + fullyBacked = m2Check.fullyBacked && m3Check.fullyBacked; + if (!fullyBacked) { + record.errors.push(`M2/M3 backing insufficient for ledger BTC settlement (M2 load ${m2Check.loadAmount}/${usdValue}, M3 headroom ${m3Check.loadAmount})`); + advance('FAILED', { fineractJournalRef: String(custodyJe.resourceId) }); + return record; + } } const m2Amount = parseFloat(loadAmount) || 0; let m2m3Ref = ''; @@ -172,7 +200,7 @@ async function processBtcL1Settlement(input) { officeId, transactionDate: valueDate, referenceNumber: `${req.idempotencyKey}-M2M3`, - comments: `T-BTC-M2M3 cBTC load ${btcAmount} BTC`, + comments: `T-BTC-${settlementSource.toUpperCase()}-M2M3 cBTC load ${btcAmount} BTC`, debits: [{ glAccountId: debitGlId, amount: m2Amount }], credits: [{ glAccountId: creditGlId, amount: m2Amount }], }); @@ -184,15 +212,14 @@ async function processBtcL1Settlement(input) { advance('HYBX_RAIL_DISPATCHED'); advance('ISO20022_ARCHIVED'); // 3) Mint cBTC in satoshi-accurate BTC decimal amount - const btcLedger = (0, btc_ledger_config_1.loadBtcLedgerConfig)(); const mint = await (0, omnl_1.requestTokenMint)({ - lineId: btcLedger.token.lineId, + lineId: tokenCfg.token.lineId, amount: btcAmount, recipient: req.recipientAddress, settlementRef: record.settlementId, dryRun: !allowChainMintExecute(), - symbol: btcLedger.token.symbol, - tokenAddress: btcLedger.token.address, + symbol: tokenCfg.token.symbol, + tokenAddress: tokenCfg.token.address, }); advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash }); advance('SETTLED'); diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts index d9e98d7..904d904 100644 --- a/services/settlement-middleware/src/api/routes/settlement.ts +++ b/services/settlement-middleware/src/api/routes/settlement.ts @@ -30,6 +30,7 @@ import { import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract'; import { resolveLiveBtcUsd } from '../../adapters/pricing'; import { btcLedgerContractsPayload, loadBtcLedgerConfig } from '../../btc-ledger-config'; +import { btcWalletStatusPayload, loadBtcWalletConfig } from '../../btc-wallet-config'; import { dispatchExternalBankOrExchange, getExternalRailsStatus, @@ -619,6 +620,7 @@ export function createSettlementRouter(): Router { officeId: settlementOfficeId(body.officeId), confirmations: Number(body.confirmations ?? 0), amountSats: Number(body.amountSats), + settlementSource: body.settlementSource ?? 'ledger', }); res.status(record.phase === 'FAILED' ? 422 : 200).json(record); } catch (e) { @@ -680,6 +682,86 @@ export function createSettlementRouter(): Router { } }); + /** Normal wallet L1 BTC → cBTC (bitcoind deposits via btc-intake). */ + router.post('/btc/wallet/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), + settlementSource: 'wallet', + }); + res.status(record.phase === 'FAILED' ? 422 : 200).json(record); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); + + router.get('/btc/wallet/status', async (_req, res) => { + if (!requireApiKey(_req, res)) return; + try { + const walletCfg = loadBtcWalletConfig(); + const [balancesHo, quote] = await Promise.all([ + fetchGlBalances(1), + resolveLiveBtcUsd().catch((e) => ({ + error: e instanceof Error ? e.message : String(e), + })), + ]); + const walletSettlements = settlementStore + .list(500) + .filter( + (r) => + r.btcSettlement != null && + r.request.remittanceInfo?.includes('wallet settle'), + ); + const settledSats = walletSettlements + .filter((r) => r.phase === 'SETTLED') + .reduce((sum, r) => sum + (r.btcSettlement?.amountSats ?? 0), 0); + + res.json({ + ...btcWalletStatusPayload(), + asOf: new Date().toISOString(), + gl: { + custodyHo12015: balancesHo['12015'] ?? '0', + interofficeBtc12424: balancesHo['12424'] ?? '0', + }, + btcUsd: + 'btcUsdRate' in quote + ? { rate: quote.btcUsdRate, source: quote.source, asOf: quote.asOf } + : { error: (quote as { error: string }).error }, + mintQueue: { + settledSats, + settlementCount: walletSettlements.length, + }, + capabilities: { + depositInstructions: walletCfg.settlementApi?.depositInstructions, + walletSettle: '/btc/wallet/l1-settle', + backingMode: walletCfg.backingMode, + note: 'Real L1 BTC from bitcoind wallet — custody-backed cBTC mint, not institutional ledger headroom', + }, + }); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); + router.get('/btc/ledger', async (_req, res) => { if (!requireApiKey(_req, res)) return; try { diff --git a/services/settlement-middleware/src/btc-wallet-config.ts b/services/settlement-middleware/src/btc-wallet-config.ts new file mode 100644 index 0000000..e26fa5b --- /dev/null +++ b/services/settlement-middleware/src/btc-wallet-config.ts @@ -0,0 +1,72 @@ +import fs from 'fs'; +import path from 'path'; +import type { BtcLedgerConfig } from './btc-ledger-config'; +import { loadBtcLedgerConfig } from './btc-ledger-config'; + +export type BtcWalletConfig = BtcLedgerConfig & { + settlementSource: 'wallet'; + confirmationsRequired: number; + maxDepositSats: number; + backingMode: 'custody_fair_value'; + custody: { debitGl: string; creditGl: string; memo: string }; + recipients: BtcLedgerConfig['recipients'] & { defaultVault?: string }; +}; + +let cached: BtcWalletConfig | null = null; + +function repoRoot(): string { + return path.resolve(__dirname, '../../../'); +} + +function configPath(): string { + const rel = + process.env.OMNL_BTC_WALLET_CONFIG || + process.env.BTC_WALLET_L1_CONFIG || + 'config/btc-wallet-l1.chain138.v1.json'; + return path.isAbsolute(rel) ? rel : path.join(repoRoot(), rel.replace(/^\//, '')); +} + +export function loadBtcWalletConfig(): BtcWalletConfig { + if (cached) return cached; + const raw = JSON.parse(fs.readFileSync(configPath(), 'utf8')) as BtcWalletConfig; + const ledger = loadBtcLedgerConfig(); + cached = { + ...raw, + token: { ...ledger.token, ...raw.token }, + oracle: { ...ledger.oracle, ...raw.oracle }, + dodoEvm: { ...ledger.dodoEvm, ...raw.dodoEvm }, + pmmPools: { ...ledger.pmmPools, ...raw.pmmPools }, + quoteTokens: { ...ledger.quoteTokens, ...raw.quoteTokens }, + rpc: { ...ledger.rpc, ...raw.rpc }, + confirmationsRequired: Number( + process.env.BTC_CONFIRMATIONS_REQUIRED || raw.confirmationsRequired || 6, + ), + maxDepositSats: Number(process.env.BTC_WALLET_MAX_DEPOSIT_SATS || raw.maxDepositSats || 0), + }; + return cached; +} + +export function btcWalletStatusPayload(): Record { + const cfg = loadBtcWalletConfig(); + return { + configPath: configPath(), + settlementSource: 'wallet', + chainId: cfg.chainId, + officeId: cfg.officeId, + confirmationsRequired: cfg.confirmationsRequired, + maxDepositSats: cfg.maxDepositSats, + backingMode: cfg.backingMode, + token: cfg.token, + recipients: cfg.recipients, + custody: cfg.custody, + dodoEvm: { pmmIntegration: cfg.dodoEvm.pmmIntegration }, + pmmPools: cfg.pmmPools, + rpc: cfg.rpc, + depositApi: cfg.settlementApi?.depositInstructions, + settleEndpoint: cfg.settlementApi?.walletL1Settle ?? '/btc/wallet/l1-settle', + }; +} + +export function clearBtcWalletConfigCache(): void { + cached = null; +} diff --git a/services/settlement-middleware/src/workflows/btc-l1-settlement.ts b/services/settlement-middleware/src/workflows/btc-l1-settlement.ts index 29527e1..4dc1eb6 100644 --- a/services/settlement-middleware/src/workflows/btc-l1-settlement.ts +++ b/services/settlement-middleware/src/workflows/btc-l1-settlement.ts @@ -8,8 +8,10 @@ import { m3TokenLoadAmount, resolveBtcL1CustodyJournal, satsToBtcDecimal, + walletBtcCustodyBackedLoad, type BtcL1ReconcileRequest, type BtcL1SettlementRequest, + type BtcL1SettlementSource, type SettlementRecord, } from '@dbis/settlement-core'; import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config'; @@ -22,10 +24,21 @@ import { import { requestTokenMint } from '../adapters/omnl'; import { resolveLiveBtcUsd } from '../adapters/pricing'; import { loadBtcLedgerConfig } from '../btc-ledger-config'; +import { loadBtcWalletConfig } from '../btc-wallet-config'; import { settlementStore } from '../store/settlement-store'; const MIN_CONFIRMATIONS = Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6); +function resolveSettlementSource(input: BtcL1SettlementRequest): BtcL1SettlementSource { + const explicit = input.settlementSource?.trim(); + if (explicit === 'wallet' || explicit === 'ledger') return explicit; + return 'wallet'; +} + +function loadTokenConfig(source: BtcL1SettlementSource) { + return source === 'ledger' ? loadBtcLedgerConfig() : loadBtcWalletConfig(); +} + /** 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(); @@ -58,7 +71,10 @@ export async function processBtcL1Settlement( ): Promise { const profile = loadOfficeProfile(); const officeId = settlementOfficeId(input.officeId); - const req = { ...input, officeId }; + const settlementSource = resolveSettlementSource(input); + const tokenCfg = loadTokenConfig(settlementSource); + const walletCfg = settlementSource === 'wallet' ? loadBtcWalletConfig() : null; + const req = { ...input, officeId, settlementSource }; if (!req.idempotencyKey || !req.txId || !req.instructionId) { throw new Error('idempotencyKey, txId, and instructionId are required'); @@ -109,7 +125,7 @@ export async function processBtcL1Settlement( amount: btcAmount, creditorIban: 'INTERNAL-BTC-L1-CUSTODY', beneficiaryName: req.recipientAddress, - remittanceInfo: `L1 BTC settle tx=${req.txId} instruction=${req.instructionId} → cBTC`, + remittanceInfo: `L1 BTC ${settlementSource} settle tx=${req.txId} instruction=${req.instructionId} → cBTC`, moneyLayers: ['M0', 'M2', 'M3'], rail: 'CHAIN138', convertToCrypto: { @@ -152,7 +168,7 @@ export async function processBtcL1Settlement( priceAsOf: quote.asOf, }, verbiageDocument: [ - `OMNL L1 BTC custody settlement (IFRS 13 fair value)`, + `OMNL L1 BTC ${settlementSource} settlement (IFRS 13 fair value)`, `Office ${officeId} · instruction ${req.instructionId}`, `L1 tx ${req.txId} · ${req.confirmations} confirmations`, `Deposit ${req.depositAddress}`, @@ -176,18 +192,39 @@ export async function processBtcL1Settlement( }); // 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})`, + let loadAmount = '0.00'; + let fullyBacked = false; + + if (settlementSource === 'wallet') { + const walletLoad = walletBtcCustodyBackedLoad( + usdValue, + walletCfg?.maxDepositSats, + req.amountSats, ); - advance('FAILED', { - fineractJournalRef: String(custodyJe.resourceId), - }); - return record; + loadAmount = walletLoad.loadAmount; + fullyBacked = walletLoad.fullyBacked; + if (!fullyBacked) { + record.errors.push( + walletLoad.reason || + `Wallet BTC custody-backed load rejected for ${req.amountSats} sats`, + ); + advance('FAILED', { fineractJournalRef: String(custodyJe.resourceId) }); + return record; + } + } else { + const balances = await fetchGlBalances(officeId); + const snap = buildMoneySupplySnapshot(officeId, balances); + const m2Check = m2FiatToTokenLoadAmount(usdValue, snap.M2.broadMoney); + const m3Check = m3TokenLoadAmount(m2Check.loadAmount, snap.M2.broadMoney, snap.M3.tokenLiabilities); + loadAmount = m2Check.loadAmount; + fullyBacked = m2Check.fullyBacked && m3Check.fullyBacked; + if (!fullyBacked) { + record.errors.push( + `M2/M3 backing insufficient for ledger BTC settlement (M2 load ${m2Check.loadAmount}/${usdValue}, M3 headroom ${m3Check.loadAmount})`, + ); + advance('FAILED', { fineractJournalRef: String(custodyJe.resourceId) }); + return record; + } } const m2Amount = parseFloat(loadAmount) || 0; @@ -201,7 +238,7 @@ export async function processBtcL1Settlement( officeId, transactionDate: valueDate, referenceNumber: `${req.idempotencyKey}-M2M3`, - comments: `T-BTC-M2M3 cBTC load ${btcAmount} BTC`, + comments: `T-BTC-${settlementSource.toUpperCase()}-M2M3 cBTC load ${btcAmount} BTC`, debits: [{ glAccountId: debitGlId, amount: m2Amount }], credits: [{ glAccountId: creditGlId, amount: m2Amount }], }); @@ -215,15 +252,14 @@ export async function processBtcL1Settlement( advance('ISO20022_ARCHIVED'); // 3) Mint cBTC in satoshi-accurate BTC decimal amount - const btcLedger = loadBtcLedgerConfig(); const mint = await requestTokenMint({ - lineId: btcLedger.token.lineId, + lineId: tokenCfg.token.lineId, amount: btcAmount, recipient: req.recipientAddress, settlementRef: record.settlementId, dryRun: !allowChainMintExecute(), - symbol: btcLedger.token.symbol, - tokenAddress: btcLedger.token.address, + symbol: tokenCfg.token.symbol, + tokenAddress: tokenCfg.token.address, }); advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash }); advance('SETTLED');