feat(settlement): production wallet L1 BTC to cBTC (not institutional ledger)
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 51s
CI/CD Pipeline / Security Scanning (push) Has started running
CI/CD Pipeline / Lint and Format (push) Has been cancelled
CI/CD Pipeline / Terraform Validation (push) Has been cancelled
CI/CD Pipeline / Kubernetes Validation (push) Has been cancelled
Deploy ChainID 138 / Deploy ChainID 138 (push) Has been cancelled
Validation / validate-genesis (push) Has been cancelled
Validation / validate-terraform (push) Has been cancelled
Validation / validate-kubernetes (push) Has been cancelled
Validation / validate-smart-contracts (push) Has been cancelled
Validation / validate-security (push) Has been cancelled
Validation / validate-documentation (push) Has been cancelled
Verify Deployment / Verify Deployment (push) Has been cancelled

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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-21 02:32:44 -07:00
parent 54ca9bea3b
commit 9f272674aa
22 changed files with 727 additions and 43 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:-}" \

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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<string, unknown> {
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;
}

View File

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