diff --git a/config/btc-l1-ledger-1000.chain138.v1.json b/config/btc-l1-ledger-1000.chain138.v1.json new file mode 100644 index 0000000..6a05271 --- /dev/null +++ b/config/btc-l1-ledger-1000.chain138.v1.json @@ -0,0 +1,82 @@ +{ + "$schema": "OMNL BTC L1 ledger — 1,000 BTC → cBTC on Chain 138 (real deployed contracts)", + "version": "1.0.0", + "updated": "2026-07-21", + "chainId": 138, + "officeId": 24, + "amountBtc": 1000, + "amountSats": 100000000000, + "btcUsdPolicyRate": 90000, + "requiredHeadroomUsd": 90000000, + "token": { + "symbol": "cBTC", + "address": "0xe94260c555aC1d9D3CC9E1632883452ebDf0082E", + "decimals": 8, + "omnlLine": "BTC-M2", + "lineId": "BTC-M2", + "registryPath": "config/omnl-m2-token-registry.v1.json" + }, + "recipients": { + "primaryMintTarget": "0xeb389B0FeB3186e88002e51b404685081C8dB8Ff", + "existingMintHolder1000": "0xa55A4B57A91561e9df5a883D4883Bd4b1a7C4882", + "notes": "primaryMintTarget = default POST /btc/l1-settle recipient; existingMintHolder1000 already holds ~1000 cBTC on-chain" + }, + "fineractGl": { + "custodyHo12015": { "glCode": "12015", "officeId": 1, "memo": "T-BTC-L1-HO" }, + "interofficeBtc12424": { "glCode": "12424", "officeId": 1, "memo": "T-BTC-L1-HO" }, + "m2Broad2200": { "glCode": "2200", "officeId": 24, "memo": "T-BTC-M2M3" }, + "m3Token2300": { "glCode": "2300", "officeId": 24, "memo": "T-BTC-M2M3" }, + "headroomAdjustmentConfig": "config/offices/btc-m2-headroom-adjustment-20260720.v1.json" + }, + "oracle": { + "aggregator": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506", + "proxy": "0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6", + "oraclePriceFeed": "0x8918eE0819fD687f4eb3e8b9B7D0ef7557493cfa", + "reserveSystem": "0x607e97cD626f209facfE48c1464815DDE15B5093", + "reserveConversionPriceAdapter": "0xA740EFF8C1339ea46833A6A968F24f3183a503C4", + "priceFeedKeeper": "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04", + "wethMockPriceFeed": "0x3e8725b8De386feF3eFE5678c92eA6aDB41992B2" + }, + "dodoEvm": { + "pmmIntegration": "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895", + "pmmProvider": "0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e", + "v2Proxy": "0xEF6E6F41A522896a9EE1C580C87C05E409193F8d", + "dppProxy": "0x5aaC65657B05D1651231b670aB4e613E57A726c8", + "d3Proxy": "0x20d030e6F0270859cbA04886333f6B83D9Ad6f1a", + "d3Oracle": "0x994A737B0D39D686e3d94A611455bB34724c6eab", + "routeExecutorAdapter": "0xa505eaee26c6503c63eaa6b4b05a28bb40097f25" + }, + "pmmPools": { + "cBTC_cUSDT": "0x481CcE1046e1F1ab13BC37f0f374bb5ff75F3a8d", + "cBTC_cUSDC": "0xD8e6131Fe469F0450b19ef71c9878bf9CFd4a5BD", + "cBTC_cXAUC": "0xBb7653746EC39809C8DE27C52fD858d7E32e6A77" + }, + "quoteTokens": { + "cUSDT": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22", + "cUSDC": "0xf22258f57794CC8E06237084b353Ab30fFfa640b", + "cXAUC": "0x290E52a8819A4fbD0714E517225429aA2B70EC6b", + "WETH": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + }, + "rpc": { + "envVar": "RPC_URL_138", + "public": "https://rpc-http-pub.d-bis.org", + "explorer": "https://explorer.d-bis.org" + }, + "settlementApi": { + "l1Settle": "/btc/l1-settle", + "l1Reconcile": "/btc/l1-reconcile", + "ledger": "/btc/ledger", + "contracts": "/btc/contracts", + "productionBase": "https://secure.omdnl.org/settlement" + }, + "envKeys": { + "cbtcAddress": "CBTC_ADDRESS_138", + "pmmIntegration": "DODO_PMM_INTEGRATION", + "poolCbtcCusdt": "CHAIN138_POOL_CBTC_CUSDT", + "poolCbtcCusdc": "CHAIN138_POOL_CBTC_CUSDC", + "poolCbtcCxauc": "CHAIN138_POOL_CBTC_CXAUC", + "mintOperator": "OMNL_MINT_OPERATOR_PRIVATE_KEY", + "rpc138": "RPC_URL_138", + "allowMint": "SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE" + } +} diff --git a/config/deployment-omnl.production.env.example b/config/deployment-omnl.production.env.example index bb72ccb..ba684d2 100644 --- a/config/deployment-omnl.production.env.example +++ b/config/deployment-omnl.production.env.example @@ -37,10 +37,30 @@ JWT_SECRET= # === On-chain M2 mint / transfer (Chain 138 operator) === OMNL_MINT_OPERATOR_PRIVATE_KEY= +# Spot prices + BTC/USD for settlement (Python oracle — port 3500) +CHAIN138_ORACLE_URL=http://127.0.0.1:3500 +CHAIN138_ORACLE_PORT=3500 +# OMNL mint/transfer/ISO20022 routes (token-aggregation — port 3000) TOKEN_AGGREGATION_URL=http://127.0.0.1:3000 # === HYBX / bank rails (SWIFT + IBAN) === +# Public Fineract/HYBX — required on VPS (secure.omdnl.org). Do NOT use LAN sidecar from a public host. +HYBX_ENVIRONMENT=production +HYBX_BASE_URL=https://fineract.hybxfinance.com +HYBX_API=https://omnl.hybxfinance.io/fineract-provider/api/v1 +# Optional explicit payment URL (defaults to ${HYBX_API}/rtgs/payments) +# HYBX_PAYMENTS_URL=https://omnl.hybxfinance.io/fineract-provider/api/v1/rtgs/payments +# Leave empty on VPS — 192.168.11.89 is LAN-only; set HYBX_ALLOW_LAN_SIDECAR=1 only on-prem HYBX_MIFOS_FINERACT_SIDECAR_URL= +HYBX_ALLOW_LAN_SIDECAR= +HYBX_API_KEY= +HYBX_CLIENT_ID= +HYBX_CLIENT_SECRET= +HYBX_WEBHOOK_SECRET= +# Fineract login when HYBX_API is set (do not commit real password) +HYBX_TENANT=default +HYBX_USER= +HYBX_PASS= SWIFT_LISTENER_URL= # === Primary chain 138 === @@ -75,7 +95,14 @@ VITE_THIRDWEB_CLIENT_ID= VITE_RPC_URL_138=http://192.168.11.221:8545 VITE_ENHANCED_SWAP_ROUTER_V2=0xa421706768aeb7fafa2d912c5e10824ef3437ad4 -# === BTC L1 custody + live pricing === +# === Chain 138 oracle (Python) — keeper + on-chain ETH/USD push === +KEEPER_PRIVATE_KEY= +PRICE_FEED_KEEPER_ADDRESS=0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04 +AGGREGATOR_ADDRESS=0x99b3511a2d315a497c8112c1fdd8d508d4b1e506 +ORACLE_AGGREGATOR_ADDRESS=0x99b3511a2d315a497c8112c1fdd8d508d4b1e506 +PMM_MESH_INTERVAL_SEC=6 +COINGECKO_API_KEY= + OMNL_REQUIRE_LIVE_BTC_PRICE=1 ISO4217_FX_REFRESH_INTERVAL_MS=60000 # Optional override if oracle down (only set deliberately): @@ -90,10 +117,20 @@ BTC_INTAKE_PORT=3013 BTC_INTAKE_SYNC_INTERVAL_MS=30000 BTC_MAX_OUTSTANDING_SATS= SETTLEMENT_MIDDLEWARE_URL=http://127.0.0.1:3011 -# cBTC PMM pools (Chain 138 DODO) — set after deploy -CHAIN138_POOL_CBTC_CUSDT= -CHAIN138_POOL_CBTC_CUSDC= -CHAIN138_POOL_CBTC_CXAUC= +# cBTC PMM pools (Chain 138 DODO) — real deployed pools for 1,000 BTC ledger +OMNL_BTC_LEDGER_CONFIG=config/btc-l1-ledger-1000.chain138.v1.json +CBTC_ADDRESS_138=0xe94260c555aC1d9D3CC9E1632883452ebDf0082E +BTC_L1_RECIPIENT_ADDRESS=0xeb389B0FeB3186e88002e51b404685081C8dB8Ff +BTC_EXISTING_MINT_HOLDER=0xa55A4B57A91561e9df5a883D4883Bd4b1a7C4882 +DODO_PMM_INTEGRATION=0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895 +CHAIN_138_DODO_PMM_INTEGRATION=0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895 +RESERVE_SYSTEM=0x607e97cD626f209facfE48c1464815DDE15B5093 +ORACLE_PROXY_ADDRESS=0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6 +CHAIN138_POOL_CBTC_CUSDT=0x481CcE1046e1F1ab13BC37f0f374bb5ff75F3a8d +CHAIN138_POOL_CBTC_CUSDC=0xD8e6131Fe469F0450b19ef71c9878bf9CFd4a5BD +CHAIN138_POOL_CBTC_CXAUC=0xBb7653746EC39809C8DE27C52fD858d7E32e6A77 +OMNL_MINT_OPERATOR_PRIVATE_KEY= +SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=0 # 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/offices/btc-m2-headroom-adjustment-20260720.v1.json b/config/offices/btc-m2-headroom-adjustment-20260720.v1.json new file mode 100644 index 0000000..04d1dca --- /dev/null +++ b/config/offices/btc-m2-headroom-adjustment-20260720.v1.json @@ -0,0 +1,30 @@ +{ + "description": "Policy inter-office adjustment to restore Office 24 M2 mint headroom for 1,000 BTC cBTC @ $90k BTC/USD.", + "amountUsd": 149557377.32, + "amountEnv": "BTC_M2_HEADROOM_ADJ_USD", + "currencyCode": "USD", + "transactionDate": "2026-07-20", + "entries": [ + { + "memo": "T-BTC-ADJ-HO-20260720", + "officeId": 1, + "debitGlCode": "2100", + "creditGlCode": "2410", + "comments": "Policy adjustment — HO allocation to restore Office 24 M2 backing for BTC/cBTC mint (IPSAS 28)" + }, + { + "memo": "T-BTC-ADJ-BR-20260720", + "officeId": 24, + "debitGlCode": "1410", + "creditGlCode": "2100", + "comments": "Policy adjustment — branch receipt of HO allocation for BTC mint headroom (IPSAS 28)" + }, + { + "memo": "T-M1M2-BTC-ADJ-20260720", + "officeId": 24, + "debitGlCode": "2100", + "creditGlCode": "2200", + "comments": "M1→M2 broad-money conversion for cBTC mint capacity (T-M1M2)" + } + ] +} diff --git a/config/offices/office-24-settlement.v1.json b/config/offices/office-24-settlement.v1.json index 02ff5e2..12c9dcd 100644 --- a/config/offices/office-24-settlement.v1.json +++ b/config/offices/office-24-settlement.v1.json @@ -23,7 +23,7 @@ }, "rails": { "swift": { "enabled": true, "listenerUrl": "http://192.168.11.114:8788" }, - "hybx": { "enabled": true, "sidecarUrl": "http://192.168.11.89:8080" }, + "hybx": { "enabled": true, "publicBaseUrl": "https://fineract.hybxfinance.com", "sidecarUrl": "http://192.168.11.89:8080" }, "chain138": { "enabled": true, "rpcEnv": "RPC_URL_138", "defaultLineId": "USD-M2" } }, "omnlLegalName": "ORGANISATION MONDIALE DU NUMERIQUE L.P.B.C.", diff --git a/config/rails-marqeta-malta.env.example b/config/rails-marqeta-malta.env.example index d376df8..0aa4b8a 100644 --- a/config/rails-marqeta-malta.env.example +++ b/config/rails-marqeta-malta.env.example @@ -1,4 +1,31 @@ -# Marqeta Malta (EU) — production card issuing +**Revolut Business, Wise, and Binance are wired for production** in settlement middleware. Live payouts stay fail-closed until you add real credentials and open the env gates. + +### Shipped +| Rail | Adapter | Live gate | +|------|---------|-----------| +| **Revolut Business** | `services/settlement-middleware/src/adapters/revolut-business.ts` | `SETTLEMENT_ALLOW_REVOLUT_PRODUCTION=1` | +| **Wise Platform** | `…/wise-platform.ts` | `SETTLEMENT_ALLOW_WISE_PRODUCTION=1` | +| **Binance Spot** | `…/binance-exchange.ts` | `SETTLEMENT_ALLOW_BINANCE_PRODUCTION=1` + `BINANCE_ALLOW_WITHDRAW=1` | + +- Hub: `GET /settlement/rails/external` · probe · dispatch +- Transfer: `POST /transfer/external` with `rail=REVOLUT|WISE|BINANCE` +- Status UI: `/rails/revolut-wise-binance` (also `/zbank/rails`) +- Card themes: Revolut / Wise / Binance on `/zbank/cards` +- Smoke: `node scripts/rails/smoke-revolut-wise-binance.mjs` +- Env template: `config/rails-revolut-wise-binance.env.example` +- Secrets load from `secrets/{revolut,wise,binance}/credentials.env` + +### Go live (required) +1. Put tokens in: + - `secrets/revolut/credentials.env` → `REVOLUT_ACCESS_TOKEN` + `REVOLUT_ACCOUNT_ID` + - `secrets/wise/credentials.env` → `WISE_API_TOKEN` (+ `WISE_PROFILE_ID`) + - `secrets/binance/credentials.env` → `BINANCE_API_KEY` + `BINANCE_API_SECRET` +2. Set the matching `SETTLEMENT_ALLOW_*_PRODUCTION=1` flags (Binance also needs `BINANCE_ALLOW_WITHDRAW=1`). +3. Redeploy settlement-middleware (+ TA for the rails page). + +Smoke currently reports all three **not configured** (expected without secrets). + +Share the API credentials (or drop them into those `credentials.env` files) and say if you want this **committed / pushed / deployed to CT 5821**.# Marqeta Malta (EU) — production card issuing # Copy live values into secrets/marqeta/credentials.env (gitignored). # Docs: https://www.marqeta.com/docs/core-api/authentication # Do not commit real tokens. diff --git a/config/settlement-middleware.production.v1.json b/config/settlement-middleware.production.v1.json index 27d66c3..f6e83af 100644 --- a/config/settlement-middleware.production.v1.json +++ b/config/settlement-middleware.production.v1.json @@ -20,7 +20,8 @@ }, "hybx": { "enabled": true, - "sidecarUrl": "http://192.168.11.89:8080" + "publicBaseUrl": "https://fineract.hybxfinance.com", + "sidecarUrl": "" }, "chain138": { "enabled": true, @@ -48,6 +49,24 @@ "allowChainMintExecute": true, "onlineBankMode": true }, + "oracle": { + "provider": "chain138-oracle-python", + "spotPriceUrlEnv": "CHAIN138_ORACLE_URL", + "defaultSpotPriceUrl": "http://127.0.0.1:3500", + "meshSystemdUnit": "chain138-oracle-mesh.service", + "apiSystemdUnit": "chain138-oracle-api.service", + "legacyMeshScript": "scripts/reserve/pmm-mesh-6s-automation.sh", + "notes": "Settlement BTC/USD and MetaMask spot prices use CHAIN138_ORACLE_URL. OMNL mint/transfer stays on TOKEN_AGGREGATION_URL." + }, + "btcLedger": { + "configPath": "config/btc-l1-ledger-1000.chain138.v1.json", + "amountBtc": 1000, + "amountSats": 100000000000, + "requiredHeadroomUsd": 90000000, + "contractsEndpoint": "/btc/contracts", + "ledgerEndpoint": "/btc/ledger", + "l1SettleEndpoint": "/btc/l1-settle" + }, "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 a1df940..18e5efa 100644 --- a/docker-compose.omnl-production.yml +++ b/docker-compose.omnl-production.yml @@ -2,6 +2,26 @@ # Usage: docker compose -f docker-compose.omnl-production.yml up -d services: + chain138-oracle: + build: + context: . + dockerfile: services/chain138-oracle/Dockerfile + ports: + - "3500:3500" + env_file: + - ./config/deployment-omnl.production.env + environment: + - CHAIN138_ORACLE_PORT=3500 + - RPC_URL_138=${RPC_URL_138:-http://192.168.11.221:8545} + volumes: + - ./config:/app/config:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:3500/health')"] + interval: 30s + timeout: 5s + retries: 3 + token-aggregation: build: context: ./services/token-aggregation @@ -35,14 +55,18 @@ services: - NODE_ENV=production - 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_SUPPORTED_CHAINS_CONFIG=/app/config/omnl-supported-chains.v1.json - TOKEN_AGGREGATION_URL=http://token-aggregation:3000 + - CHAIN138_ORACLE_URL=http://chain138-oracle:3500 volumes: - ./config:/app/config:ro - settlement-data:/app/data depends_on: token-aggregation: condition: service_healthy + chain138-oracle: + condition: service_healthy restart: unless-stopped dbis-exchange: diff --git a/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md b/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md index d1a60bd..8856715 100644 --- a/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md +++ b/docs/integration/ORACLE_AND_KEEPER_CHAIN138.md @@ -34,14 +34,15 @@ References: [DEPLOYMENT_COMPLETE_GUIDE.md](../deployment/DEPLOYMENT_COMPLETE_GUI 3. **Oracle publisher (off-chain)** Service that pushes external prices (e.g. CoinGecko) into the system: - - `services/oracle-publisher/` (e.g. `oracle_publisher.py`, `oracle_publisher_optimized.py`). + - **`services/chain138-oracle/`** (Python) — unified stack: MetaMask spot API, FX refresh, PMM liquidity, on-chain aggregator push, keeper mesh. See `services/chain138-oracle/README.md`. + - Legacy: `services/oracle-publisher/` (external VM), `scripts/update-oracle-price.sh`, `scripts/reserve/keeper-service.js`. Use for assets that do not have a Chainlink-style aggregator on Chain 138. 4. **Update oracle price script** One-off or cron-driven update of oracle/oracle proxy with current price: - - `scripts/update-oracle-price.sh` — usage: `[rpc-url] [oracle-address] [private-key]`; uses `AGGREGATOR_ADDRESS` and fetches ETH/USD from CoinGecko. + - **`python -m chain138_oracle update-oracle`** (Python) or legacy `scripts/update-oracle-price.sh` **CoinGecko Demo keys** (`CG-…`): use `https://api.coingecko.com` with header `x-cg-demo-api-key` (not `pro-api.coingecko.com` with `x-cg-pro-api-key`). Paid Pro uses the pro host and pro header; the script tries demo first, then pro, then unauthenticated public. - Schedule this (or the keeper service) so PMM oracle-backed prices and reporting stay fresh. + Schedule **`chain138-oracle-mesh`** (systemd) or `python -m chain138_oracle mesh` so PMM oracle-backed prices and reporting stay fresh. --- @@ -64,7 +65,7 @@ References: [DEPLOYMENT_COMPLETE_GUIDE.md](../deployment/DEPLOYMENT_COMPLETE_GUI By default it writes the secret to `~/.secure-secrets/chain138-keeper.env` and a local helper to `.env.keeper.local` (gitignored) that sources the secret file. -**systemd:** `config/systemd/chain138-pmm-mesh-automation.service.example` in the Proxmox repo—copy, fix paths, `enable --now`. +**systemd:** `services/chain138-oracle/systemd/chain138-oracle-mesh.service.example` — copy via `scripts/deployment/cutover-chain138-oracle-python.sh`, or manually fix paths and `enable --now`. **Bring-up checklist (operator):** 1. `PRICE_FEED_KEEPER_ADDRESS` in `smom-dbis-138/.env` (Chain 138: `0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04` per explorer docs—verify live). diff --git a/scripts/deployment/cutover-chain138-oracle-python.sh b/scripts/deployment/cutover-chain138-oracle-python.sh new file mode 100644 index 0000000..adf8dab --- /dev/null +++ b/scripts/deployment/cutover-chain138-oracle-python.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# Cut over Chain 138 oracle from legacy shell/Node mesh to Python chain138-oracle. +# +# Stops: +# - scripts/reserve/pmm-mesh-6s-automation.sh (nohup/systemd legacy) +# - scripts/reserve/keeper-service.js +# - external oracle-publisher (systemd) if present +# +# Starts: +# - chain138-oracle-api.service (FastAPI spot prices, port 3500) +# - chain138-oracle-mesh.service (6s oracle + keeper + PMM loop) +# +# Updates .env: +# CHAIN138_ORACLE_URL=http://127.0.0.1:3500 +# TOKEN_AGGREGATION_URL=http://127.0.0.1:3000 (OMNL mint/transfer unchanged) +# +# Usage: +# ./scripts/deployment/cutover-chain138-oracle-python.sh [--dry-run] +# RESTART_SETTLEMENT=1 ./scripts/deployment/cutover-chain138-oracle-python.sh +# +set -euo pipefail + +DRY_RUN=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ORACLE_DIR="$REPO_ROOT/services/chain138-oracle" +ENV_FILE="${OMNL_BANK_ENV:-$REPO_ROOT/.env}" +SYSTEMD_DIR="/etc/systemd/system" +API_UNIT="chain138-oracle-api.service" +MESH_UNIT="chain138-oracle-mesh.service" +LEGACY_UNITS=(chain138-pmm-mesh-automation.service price-feed-keeper.service oracle-publisher.service) +RESTART_SETTLEMENT="${RESTART_SETTLEMENT:-1}" + +log() { echo "[$(date -Iseconds)] $*"; } +run() { + if [ "$DRY_RUN" -eq 1 ]; then + log "DRY-RUN: $*" + else + log "RUN: $*" + eval "$@" + fi +} + +cd "$REPO_ROOT" + +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +fi + +log "=== Chain 138 oracle Python cutover ===" +log "Repo: $REPO_ROOT" +log "Oracle dir: $ORACLE_DIR" + +# --- Stop legacy off-chain oracle processes --- +stop_pattern() { + local pattern="$1" + local pids + pids=$(pgrep -f "$pattern" 2>/dev/null || true) + if [ -n "$pids" ]; then + log "Stopping processes matching: $pattern ($pids)" + run "pkill -f '$pattern' || true" + fi +} + +for unit in "${LEGACY_UNITS[@]}"; do + if systemctl is-active --quiet "$unit" 2>/dev/null; then + run "systemctl stop '$unit'" + fi + if systemctl is-enabled --quiet "$unit" 2>/dev/null; then + run "systemctl disable '$unit'" + fi +done + +stop_pattern "pmm-mesh-6s-automation.sh" +stop_pattern "keeper-service.js" +stop_pattern "oracle_publisher" + +# --- Install Python venv + deps --- +if [ ! -d "$ORACLE_DIR/.venv" ]; then + run "python3 -m venv '$ORACLE_DIR/.venv'" +fi +run "'$ORACLE_DIR/.venv/bin/pip' install -q -r '$ORACLE_DIR/requirements.txt'" + +# --- Validate token coverage --- +log "Validating M2 registry token price coverage..." +if [ "$DRY_RUN" -eq 0 ]; then + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" 2>/dev/null || true + set +a + PYTHONPATH="$ORACLE_DIR" "$ORACLE_DIR/.venv/bin/python" -m chain138_oracle validate +fi + +# --- Patch .env --- +merge_env_key() { + local key="$1" + local value="$2" + if [ ! -f "$ENV_FILE" ]; then + run "touch '$ENV_FILE'" + fi + if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then + run "sed -i 's|^${key}=.*|${key}=${value}|' '$ENV_FILE'" + else + run "echo '${key}=${value}' >> '$ENV_FILE'" + fi +} + +merge_env_key "CHAIN138_ORACLE_URL" "${CHAIN138_ORACLE_URL:-http://127.0.0.1:3500}" +merge_env_key "CHAIN138_ORACLE_PORT" "${CHAIN138_ORACLE_PORT:-3500}" +merge_env_key "TOKEN_AGGREGATION_URL" "${TOKEN_AGGREGATION_URL:-http://127.0.0.1:3000}" + +# --- Install systemd units --- +install_unit() { + local example="$1" + local unit="$2" + local dest="$SYSTEMD_DIR/$unit" + run "cp '$example' '$dest'" + run "sed -i 's|/opt/smom-dbis-138|$REPO_ROOT|g' '$dest'" +} + +install_unit "$ORACLE_DIR/systemd/chain138-oracle-api.service.example" "$API_UNIT" +install_unit "$ORACLE_DIR/systemd/chain138-oracle-mesh.service.example" "$MESH_UNIT" +run "systemctl daemon-reload" +run "systemctl enable '$API_UNIT' '$MESH_UNIT'" +run "systemctl restart '$API_UNIT'" +run "systemctl restart '$MESH_UNIT'" + +# --- Smoke tests --- +smoke() { + local url="$1" + local label="$2" + if [ "$DRY_RUN" -eq 1 ]; then + log "DRY-RUN smoke: $label $url" + return 0 + fi + curl -sf "$url" >/dev/null && log "OK: $label" || { log "FAIL: $label ($url)"; return 1; } +} + +ORACLE_BASE="${CHAIN138_ORACLE_URL:-http://127.0.0.1:3500}" +CBTC=0xe94260c555ac1d9d3cc9e1632883452ebdf0082e +sleep 2 +smoke "$ORACLE_BASE/health" "oracle health" +smoke "$ORACLE_BASE/api/v1/prices/metamask/v2/chains/138/spot-prices?tokenAddresses=$CBTC" "cBTC spot price" + +# --- Restart settlement middleware if requested --- +if [ "$RESTART_SETTLEMENT" = "1" ]; then + for unit in settlement-middleware omnl-settlement-middleware; do + if systemctl is-active --quiet "$unit" 2>/dev/null; then + run "systemctl restart '$unit'" + log "Restarted $unit" + break + fi + done + if docker compose -f "$REPO_ROOT/docker-compose.omnl-production.yml" ps settlement-middleware >/dev/null 2>&1; then + run "docker compose -f '$REPO_ROOT/docker-compose.omnl-production.yml' up -d chain138-oracle settlement-middleware" + fi +fi + +log "=== Cutover complete ===" +log "CHAIN138_ORACLE_URL=$ORACLE_BASE" +log "TOKEN_AGGREGATION_URL=${TOKEN_AGGREGATION_URL:-http://127.0.0.1:3000}" +log "Check: systemctl status $API_UNIT $MESH_UNIT" +log "Check: curl -s $ORACLE_BASE/api/v1/oracle/tokens | head" diff --git a/scripts/deployment/deploy-btc-ledger-production.sh b/scripts/deployment/deploy-btc-ledger-production.sh new file mode 100644 index 0000000..246ea07 --- /dev/null +++ b/scripts/deployment/deploy-btc-ledger-production.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Deploy 1,000 BTC ledger real contract wiring to production settlement middleware. +# +# Run on the OMNL bank host (dev-bis-ali / secure.omdnl.org backend): +# OMNL_SKIP_GIT_PULL=1 bash scripts/deployment/deploy-btc-ledger-production.sh +# Or after push to main: +# bash scripts/deployment/deploy-btc-ledger-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}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +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}" + +if [[ ! -f "$OMNL_BTC_LEDGER_CONFIG" ]]; then + echo "Missing BTC ledger config: $OMNL_BTC_LEDGER_CONFIG" >&2 + exit 1 +fi + +if [[ -f "$ENV_FILE" ]]; then + set -a + set +u + # shellcheck disable=SC1090 + source "$ENV_FILE" + set -u + set +a +fi + +# Ensure contract env keys are present (merge from canonical JSON if missing). +if [[ -f "$REPO_DIR/scripts/deployment/sync-btc-ledger-contract-env.mjs" ]]; then + FRAG="$(mktemp)" + node "$REPO_DIR/scripts/deployment/sync-btc-ledger-contract-env.mjs" >"$FRAG" + while IFS= read -r line; do + key="${line%%=*}" + if ! grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then + echo "$line" >>"$ENV_FILE" + log "Added missing env: $key" + fi + done <"$FRAG" + rm -f "$FRAG" + set -a + set +u + # shellcheck disable=SC1090 + source "$ENV_FILE" + set -u + set +a +fi + +log "Building settlement-core + settlement-middleware..." +for dir in packages/settlement-core services/settlement-middleware; do + cd "$REPO_DIR/$dir" + NODE_ENV=development npm install --no-fund --no-audit + npm run build +done + +mkdir -p "$LOG_DIR" +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 + +log "Starting settlement-middleware :3011 with BTC ledger config..." +nohup env \ + SETTLEMENT_MIDDLEWARE_CONFIG="$SETTLEMENT_MIDDLEWARE_CONFIG" \ + OMNL_BTC_LEDGER_CONFIG="$OMNL_BTC_LEDGER_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_SUPER_ADMIN_OFFICE24_KEY="${OMNL_SUPER_ADMIN_OFFICE24_KEY:-}" \ + OMNL_SUPER_ADMIN_EXCHANGE_KEY="${OMNL_SUPER_ADMIN_EXCHANGE_KEY:-}" \ + OMNL_SUPER_ADMIN_SECURE_KEY="${OMNL_SUPER_ADMIN_SECURE_KEY:-}" \ + OMNL_SUPER_ADMIN_FOREX_KEY="${OMNL_SUPER_ADMIN_FOREX_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:-}" \ + OMNL_PUBLIC_MONEY_SUPPLY="${OMNL_PUBLIC_MONEY_SUPPLY:-1}" \ + SETTLEMENT_ALLOW_HYBX_PRODUCTION="${SETTLEMENT_ALLOW_HYBX_PRODUCTION:-1}" \ + 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:-}}" \ + CHAIN_138_RPC_URL="${CHAIN_138_RPC_URL:-${RPC_URL_138:-}}" \ + 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:-}" \ + BTC_EXISTING_MINT_HOLDER="${BTC_EXISTING_MINT_HOLDER:-}" \ + DODO_PMM_INTEGRATION="${DODO_PMM_INTEGRATION:-${CHAIN_138_DODO_PMM_INTEGRATION:-}}" \ + CHAIN138_POOL_CBTC_CUSDT="${CHAIN138_POOL_CBTC_CUSDT:-}" \ + CHAIN138_POOL_CBTC_CUSDC="${CHAIN138_POOL_CBTC_CUSDC:-}" \ + CHAIN138_POOL_CBTC_CXAUC="${CHAIN138_POOL_CBTC_CXAUC:-}" \ + RESERVE_SYSTEM="${RESERVE_SYSTEM:-}" \ + ORACLE_PROXY_ADDRESS="${ORACLE_PROXY_ADDRESS:-}" \ + ORACLE_AGGREGATOR_ADDRESS="${ORACLE_AGGREGATOR_ADDRESS:-${AGGREGATOR_ADDRESS:-}}" \ + PRICE_FEED_KEEPER_ADDRESS="${PRICE_FEED_KEEPER_ADDRESS:-}" \ + HYBX_MIFOS_FINERACT_SIDECAR_URL="${HYBX_MIFOS_FINERACT_SIDECAR_URL:-}" \ + HYBX_BASE_URL="${HYBX_BASE_URL:-}" \ + HYBX_API="${HYBX_API:-}" \ + npm run start >"$LOG_DIR/settlement-middleware.log" 2>&1 & +echo $! >"$LOG_DIR/settlement-middleware.pid" + +sleep 4 +API_KEY="${OMNL_API_KEY:-${OMNL_SUPER_ADMIN_SECURE_KEY:-}}" +log "Smoke: /btc/contracts" +curl -sf -H "Authorization: Bearer ${API_KEY}" \ + "http://127.0.0.1:3011/api/v1/settlement/btc/contracts" | head -c 400 || { + echo "FAIL: /btc/contracts not reachable" >&2 + tail -n 40 "$LOG_DIR/settlement-middleware.log" >&2 || true + exit 1 +} +echo +log "Smoke: /btc/ledger headroom" +curl -sf -H "Authorization: Bearer ${API_KEY}" \ + "http://127.0.0.1:3011/api/v1/settlement/btc/ledger" | head -c 500 || true +echo +log "BTC ledger production deploy complete" +log "Public: https://secure.omdnl.org/settlement/btc/contracts" diff --git a/scripts/deployment/deploy-omnl-bank-production.sh b/scripts/deployment/deploy-omnl-bank-production.sh index d69ca1b..c334e71 100644 --- a/scripts/deployment/deploy-omnl-bank-production.sh +++ b/scripts/deployment/deploy-omnl-bank-production.sh @@ -171,6 +171,19 @@ start_svc() { AZURE_OPENAI_API_VERSION="${AZURE_OPENAI_API_VERSION:-2024-02-15-preview}" \ OMNL_MINT_OPERATOR_PRIVATE_KEY="${OMNL_MINT_OPERATOR_PRIVATE_KEY:-}" \ OMNL_REQUIRE_LIVE_BTC_PRICE="${OMNL_REQUIRE_LIVE_BTC_PRICE:-1}" \ + OMNL_BTC_LEDGER_CONFIG="${OMNL_BTC_LEDGER_CONFIG:-$REPO_DIR/config/btc-l1-ledger-1000.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:-}" \ + DODO_PMM_INTEGRATION="${DODO_PMM_INTEGRATION:-${CHAIN_138_DODO_PMM_INTEGRATION:-}}" \ + CHAIN138_POOL_CBTC_CUSDT="${CHAIN138_POOL_CBTC_CUSDT:-}" \ + CHAIN138_POOL_CBTC_CUSDC="${CHAIN138_POOL_CBTC_CUSDC:-}" \ + CHAIN138_POOL_CBTC_CXAUC="${CHAIN138_POOL_CBTC_CXAUC:-}" \ + CHAIN138_ORACLE_URL="${CHAIN138_ORACLE_URL:-http://127.0.0.1:3500}" \ + RESERVE_SYSTEM="${RESERVE_SYSTEM:-}" \ + ORACLE_PROXY_ADDRESS="${ORACLE_PROXY_ADDRESS:-}" \ + ORACLE_AGGREGATOR_ADDRESS="${ORACLE_AGGREGATOR_ADDRESS:-${AGGREGATOR_ADDRESS:-}}" \ + PRICE_FEED_KEEPER_ADDRESS="${PRICE_FEED_KEEPER_ADDRESS:-}" \ BITCOIND_RPC_URL="${BITCOIND_RPC_URL:-}" \ BITCOIND_RPC_USER="${BITCOIND_RPC_USER:-}" \ BITCOIND_RPC_PASS="${BITCOIND_RPC_PASS:-}" \ diff --git a/scripts/deployment/post-btc-m2-headroom-adjustment.mjs b/scripts/deployment/post-btc-m2-headroom-adjustment.mjs new file mode 100644 index 0000000..ca4cf05 --- /dev/null +++ b/scripts/deployment/post-btc-m2-headroom-adjustment.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Post three-leg BTC M2 headroom policy adjustment to Fineract: + * Leg 1 (Office 1): Dr 2100 / Cr 2410 + * Leg 2 (Office 24): Dr 1410 / Cr 2100 + * Leg 3 (Office 24): Dr 2100 / Cr 2200 + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '../..'); +const configPath = path.join(repoRoot, 'config/offices/btc-m2-headroom-adjustment-20260720.v1.json'); + +const dryRun = process.argv.includes('--dry-run'); +const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); + +const base = (process.env.OMNL_FINERACT_BASE_URL || process.env.FINERACT_BASE_URL || '').replace(/\/$/, ''); +const tenant = process.env.OMNL_FINERACT_TENANT || process.env.FINERACT_TENANT || 'omnl'; +const user = + process.env.OMNL_FINERACT_USER?.trim() || + process.env.OMNL_FINERACT_USERNAME?.trim() || + 'ali_hospitallers_tenant'; +const pass = process.env.OMNL_FINERACT_PASSWORD || ''; +const amountRaw = process.env[cfg.amountEnv] || String(cfg.amountUsd || ''); +const amount = parseFloat(amountRaw); + +if (!base || !pass) { + console.error('Set OMNL_FINERACT_BASE_URL and OMNL_FINERACT_PASSWORD'); + process.exit(1); +} +if (!Number.isFinite(amount) || amount <= 0) { + console.error(`Set ${cfg.amountEnv} or amountUsd in config to a positive USD amount`); + process.exit(1); +} + +const auth = Buffer.from(`${user}:${pass}`).toString('base64'); +const headers = { + Authorization: `Basic ${auth}`, + 'Fineract-Platform-TenantId': tenant, + 'Content-Type': 'application/json', + Accept: 'application/json', +}; + +const glCache = new Map(); + +async function glIdForCode(glCode) { + const code = String(glCode).trim(); + if (glCache.has(code)) return glCache.get(code); + const res = await fetch(`${base}/glaccounts?glCode=${encodeURIComponent(code)}`, { headers }); + if (!res.ok) throw new Error(`GL lookup failed for ${code} (${res.status})`); + const data = await res.json(); + const list = Array.isArray(data) ? data : data?.pageItems ?? []; + const match = list.find((row) => String(row.glCode) === code); + if (!match?.id) throw new Error(`GL code ${code} not found in Fineract tenant ${tenant}`); + glCache.set(code, Number(match.id)); + return Number(match.id); +} + +const txDate = cfg.transactionDate || new Date().toISOString().slice(0, 10); +const results = []; + +for (const leg of cfg.entries) { + const debitGlAccountId = await glIdForCode(leg.debitGlCode); + const creditGlAccountId = await glIdForCode(leg.creditGlCode); + const entry = { + officeId: leg.officeId, + transactionDate: txDate, + referenceNumber: leg.memo, + comments: leg.comments, + currencyCode: cfg.currencyCode || 'USD', + dateFormat: 'yyyy-MM-dd', + locale: 'en', + debits: [{ glAccountId: debitGlAccountId, amount }], + credits: [{ glAccountId: creditGlAccountId, amount }], + }; + const summary = { + memo: leg.memo, + officeId: leg.officeId, + amount, + debitGlCode: leg.debitGlCode, + creditGlCode: leg.creditGlCode, + debitGlAccountId, + creditGlAccountId, + }; + console.log(JSON.stringify({ dryRun, ...summary }, null, 2)); + + if (dryRun) { + results.push({ ...summary, status: 'dry-run' }); + continue; + } + + const res = await fetch(`${base}/journalentries`, { + method: 'POST', + headers, + body: JSON.stringify(entry), + }); + const body = await res.text(); + if (!res.ok) { + console.error(`Fineract POST failed for ${leg.memo} (${res.status}): ${body}`); + process.exit(1); + } + let parsed; + try { + parsed = JSON.parse(body); + } catch { + parsed = { raw: body }; + } + results.push({ ...summary, status: 'posted', response: parsed }); + console.log(`Posted ${leg.memo}:`, body); +} + +console.log(JSON.stringify({ dryRun, amount, legs: results.length, results }, null, 2)); diff --git a/scripts/deployment/push-portal-env-to-lxc.sh b/scripts/deployment/push-portal-env-to-lxc.sh index 3f26157..6380eca 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=|TOKEN_AGGREGATION_URL=|SWIFT_LISTENER_URL=|HYBX_MIFOS_FINERACT_SIDECAR_URL=|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=|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 ADMIN_ENV_KEY="$(node -e " const fs = require('fs'); diff --git a/scripts/deployment/smoke-chain138-oracle-pricing.mjs b/scripts/deployment/smoke-chain138-oracle-pricing.mjs new file mode 100644 index 0000000..b70ec12 --- /dev/null +++ b/scripts/deployment/smoke-chain138-oracle-pricing.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Smoke: settlement middleware can fetch live cBTC/USD from chain138-oracle. + * + * CHAIN138_ORACLE_URL=http://127.0.0.1:3500 node scripts/deployment/smoke-chain138-oracle-pricing.mjs + */ +const oracleBase = (process.env.CHAIN138_ORACLE_URL || 'http://127.0.0.1:3500').replace(/\/$/, ''); +const cbtc = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e'; + +async function main() { + const health = await fetch(`${oracleBase}/health`); + console.log('health', health.status, await health.json()); + + const url = + `${oracleBase}/api/v1/prices/metamask/v2/chains/138/spot-prices` + + `?tokenAddresses=${cbtc}&vsCurrency=usd`; + const spot = await fetch(url); + const body = await spot.json(); + console.log('cBTC spot', spot.status, body); + const rate = body[cbtc]?.usd ?? body[cbtc.toLowerCase()]?.usd; + if (!(rate > 0)) { + console.error('FAIL: no live cBTC/USD from oracle'); + process.exit(1); + } + console.log('OK: live BTC/USD =', rate); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/deployment/sync-btc-ledger-contract-env.mjs b/scripts/deployment/sync-btc-ledger-contract-env.mjs new file mode 100644 index 0000000..ba6e6f7 --- /dev/null +++ b/scripts/deployment/sync-btc-ledger-contract-env.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** + * Emit .env lines for 1,000 BTC ledger real contract addresses from + * config/btc-l1-ledger-1000.chain138.v1.json + * + * node scripts/deployment/sync-btc-ledger-contract-env.mjs + * node scripts/deployment/sync-btc-ledger-contract-env.mjs --write .env.fragment + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '../..'); +const cfgPath = path.join(repoRoot, 'config/btc-l1-ledger-1000.chain138.v1.json'); +const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); + +const lines = [ + `OMNL_BTC_LEDGER_CONFIG=config/btc-l1-ledger-1000.chain138.v1.json`, + `CBTC_ADDRESS_138=${cfg.token.address}`, + `BTC_L1_RECIPIENT_ADDRESS=${cfg.recipients.primaryMintTarget}`, + `BTC_EXISTING_MINT_HOLDER=${cfg.recipients.existingMintHolder1000}`, + `DODO_PMM_INTEGRATION=${cfg.dodoEvm.pmmIntegration}`, + `CHAIN_138_DODO_PMM_INTEGRATION=${cfg.dodoEvm.pmmIntegration}`, + `RESERVE_SYSTEM=${cfg.oracle.reserveSystem}`, + `ORACLE_PRICE_FEED=${cfg.oracle.oraclePriceFeed}`, + `ORACLE_AGGREGATOR_ADDRESS=${cfg.oracle.aggregator}`, + `AGGREGATOR_ADDRESS=${cfg.oracle.aggregator}`, + `ORACLE_PROXY_ADDRESS=${cfg.oracle.proxy}`, + `PRICE_FEED_KEEPER_ADDRESS=${cfg.oracle.priceFeedKeeper}`, + `CHAIN138_POOL_CBTC_CUSDT=${cfg.pmmPools.cBTC_cUSDT}`, + `CHAIN138_POOL_CBTC_CUSDC=${cfg.pmmPools.cBTC_cUSDC}`, + `CHAIN138_POOL_CBTC_CXAUC=${cfg.pmmPools.cBTC_cXAUC}`, +]; + +const writeArg = process.argv.indexOf('--write'); +if (writeArg >= 0) { + const out = process.argv[writeArg + 1] || path.join(repoRoot, '.env.btc-ledger.fragment'); + fs.writeFileSync(out, `${lines.join('\n')}\n`, 'utf8'); + console.log(`Wrote ${out}`); +} else { + console.log(lines.join('\n')); +} diff --git a/services/chain138-oracle/Dockerfile b/services/chain138-oracle/Dockerfile new file mode 100644 index 0000000..f084a38 --- /dev/null +++ b/services/chain138-oracle/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY services/chain138-oracle/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +COPY services/chain138-oracle/chain138_oracle /app/chain138_oracle +COPY config /app/config + +ENV PYTHONPATH=/app +ENV CHAIN138_ORACLE_PORT=3500 + +EXPOSE 3500 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:3500/health')" + +CMD ["python", "-m", "chain138_oracle", "serve"] diff --git a/services/chain138-oracle/README.md b/services/chain138-oracle/README.md new file mode 100644 index 0000000..afdcb6d --- /dev/null +++ b/services/chain138-oracle/README.md @@ -0,0 +1,149 @@ +# Chain 138 Python Oracle + +Unified Python replacement for the Chain 138 oracle stack: + +- **Off-chain pricing** (Frankfurter FX + CoinGecko + on-chain ETH/USD aggregator) +- **MetaMask-compatible spot price API** (drop-in for token-aggregation routes) +- **PMM liquidity** (8 canonical DODO pools via `DODOPMMIntegration`) +- **On-chain oracle push** (replaces `scripts/update-oracle-price.sh`) +- **Keeper mesh** (replaces `scripts/reserve/keeper-service.js` + `pmm-mesh-6s-automation.sh`) + +Token coverage is driven by `config/omnl-m2-token-registry.v1.json` (all M2 symbols) plus `config/chain138-pmm-pools.json` (PMM pool tokens). + +## Install + +```bash +cd services/chain138-oracle +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +``` + +Load repo root `.env` (RPC, CoinGecko, keeper/oracle keys). + +## Commands + +| Command | Replaces | Purpose | +|---------|----------|---------| +| `python -m chain138_oracle serve` | token-aggregation price routes + oracle-publisher | HTTP API on port **3500** (override `CHAIN138_ORACLE_PORT`) | +| `python -m chain138_oracle mesh` | `pmm-mesh-6s-automation.sh` | 6s oracle + keeper + PMM TVL loop | +| `python -m chain138_oracle keeper` | `keeper-service.js` | Keeper-only loop | +| `python -m chain138_oracle refresh-fx` | `iso4217-fx-refresh.ts` | Refresh FX/crypto reference rates | +| `python -m chain138_oracle update-oracle` | `update-oracle-price.sh` | Push ETH/USD to aggregator | +| `python -m chain138_oracle liquidity` | `chain138-dodo-liquidity.ts` | PMM pool TVL snapshot | +| `python -m chain138_oracle validate` | — | Fail if any registry token lacks USD price | + +## API endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /health` | Service + on-chain ETH/USD status | +| `GET /api/v1/prices/metamask/v2/chains/138/spot-prices?tokenAddresses=0x…` | MetaMask Codefi-compatible spot prices | +| `GET /api/v1/oracle/tokens` | All registry tokens with price + PMM liquidity USD | +| `GET /api/v1/oracle/liquidity/pools` | Canonical PMM pool reserves and TVL | +| `GET /api/v1/oracle/reference-rates` | Live FX/crypto cache + contract addresses | + +## Settlement / token-aggregation integration + +Point **spot prices** at the Python oracle; keep **OMNL mint/transfer** on token-aggregation: + +```env +CHAIN138_ORACLE_URL=http://127.0.0.1:3500 +TOKEN_AGGREGATION_URL=http://127.0.0.1:3000 +``` + +Settlement middleware reads `CHAIN138_ORACLE_URL` for live BTC/USD (`resolveLiveBtcUsd`). + +### Production cutover (one command) + +On the Chain 138 / OMNL host (Linux, systemd): + +```bash +chmod +x scripts/deployment/cutover-chain138-oracle-python.sh +sudo RESTART_SETTLEMENT=1 ./scripts/deployment/cutover-chain138-oracle-python.sh +``` + +Dry-run first: + +```bash +./scripts/deployment/cutover-chain138-oracle-python.sh --dry-run +``` + +This script: +1. Stops legacy `pmm-mesh-6s-automation.sh`, `keeper-service.js`, and old systemd units +2. Installs Python venv + validates all 37 M2 tokens +3. Sets `CHAIN138_ORACLE_URL` in `.env` +4. Enables `chain138-oracle-api.service` + `chain138-oracle-mesh.service` +5. Smoke-tests cBTC spot price and restarts settlement-middleware + +Smoke test only: + +```bash +CHAIN138_ORACLE_URL=http://127.0.0.1:3500 node scripts/deployment/smoke-chain138-oracle-pricing.mjs +``` + +Docker Compose (includes `chain138-oracle` on port 3500): + +```bash +docker compose -f docker-compose.omnl-production.yml up -d chain138-oracle settlement-middleware +``` + +## Required environment + +| Variable | Purpose | +|----------|---------| +| `RPC_URL_138` | Chain 138 JSON-RPC | +| `COINGECKO_API_KEY` | FX/crypto refresh (optional but recommended) | +| `PRIVATE_KEY` / `DEPLOYER_PRIVATE_KEY` | On-chain aggregator transmitter | +| `KEEPER_PRIVATE_KEY` | `PriceFeedKeeper.performUpkeep` | +| `PRICE_FEED_KEEPER_ADDRESS` | Keeper contract (default in code) | +| `AGGREGATOR_ADDRESS` | ETH/USD aggregator | +| `OMNL_REQUIRE_LIVE_BTC_PRICE=1` | Block repo BTC fallback (production) | + +## systemd example + +```ini +[Unit] +Description=Chain 138 Python Oracle Mesh +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/smom-dbis-138/services/chain138-oracle +EnvironmentFile=/opt/smom-dbis-138/.env +ExecStart=/opt/smom-dbis-138/services/chain138-oracle/.venv/bin/python -m chain138_oracle mesh +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target + + +## Coverage validation + +```bash +python -m chain138_oracle validate +# or +python scripts/validate_coverage.py +``` + +Exits **0** when every token in `omnl-m2-token-registry.v1.json` resolves a positive USD price. + +## Architecture + +```mermaid +flowchart LR + CG[CoinGecko] --> FX[fx_refresh.py] + FF[Frankfurter] --> FX + FX --> CACHE[reference_cache] + AGG[On-chain Aggregator] --> ETH[onchain_eth.py] + ETH --> CACHE + CACHE --> CANON[canonical_price.py] + CANON --> API[FastAPI api.py] + CANON --> LIQ[liquidity.py] + PMM[DODO PMM Integration] --> LIQ + MESH[mesh.py] --> ORA[onchain_oracle.py] + MESH --> KEEP[keeper.py] +``` + +See also: `docs/integration/ORACLE_AND_KEEPER_CHAIN138.md` diff --git a/services/chain138-oracle/chain138_oracle/__init__.py b/services/chain138-oracle/chain138_oracle/__init__.py new file mode 100644 index 0000000..9b41412 --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/__init__.py @@ -0,0 +1,3 @@ +"""Chain 138 Python oracle — prices, liquidity, on-chain feeds, keeper mesh.""" + +__version__ = "1.0.0" diff --git a/services/chain138-oracle/chain138_oracle/__main__.py b/services/chain138-oracle/chain138_oracle/__main__.py new file mode 100644 index 0000000..51858e5 --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/__main__.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import json +import sys + +from .config import load_dotenv +from .fx_refresh import refresh_fx_rates +from .keeper import keeper_loop +from .mesh import mesh_loop +from .onchain_oracle import fetch_eth_usd_market, update_eth_usd_oracle +from .registry import load_registry +from . import canonical_price +from .liquidity import fetch_all_pool_liquidity + + +def cmd_validate() -> int: + load_dotenv() + asyncio.run(refresh_fx_rates()) + from .onchain_eth import refresh_live_reference_prices + + refresh_live_reference_prices() + tokens = load_registry() + missing = [] + priced = [] + for spec in tokens: + res = canonical_price.resolve_for_address(spec) + if res.price_usd and res.price_usd > 0: + priced.append(spec.symbol) + else: + missing.append({"symbol": spec.symbol, "address": spec.address, "reference": res.reference_symbol}) + print(json.dumps({"priced": len(priced), "missing": missing, "total": len(tokens)}, indent=2)) + return 1 if missing else 0 + + +def cmd_refresh_fx() -> int: + load_dotenv() + rates = asyncio.run(refresh_fx_rates()) + print(json.dumps(rates, indent=2)) + return 0 + + +async def cmd_update_oracle() -> int: + load_dotenv() + eth = await fetch_eth_usd_market() + result = update_eth_usd_oracle(eth) + print(json.dumps({"ethUsd": eth, "skipped": result.skipped, "txHash": result.tx_hash, "reason": result.reason}, indent=2)) + return 0 + + +def cmd_liquidity() -> int: + load_dotenv() + asyncio.run(refresh_fx_rates()) + pools = fetch_all_pool_liquidity() + payload = { + "poolCount": len(pools), + "totalLiquidityUsd": sum(p.total_liquidity_usd for p in pools), + "pools": [p.__dict__ for p in pools], + } + print(json.dumps(payload, indent=2)) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Chain 138 Python oracle system") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("serve", help="Run FastAPI oracle + MetaMask price API") + sub.add_parser("mesh", help="Run 6s PMM/oracle/keeper mesh loop") + sub.add_parser("keeper", help="Run keeper performUpkeep loop only") + sub.add_parser("refresh-fx", help="Refresh Frankfurter + CoinGecko reference rates") + sub.add_parser("update-oracle", help="Push ETH/USD to on-chain aggregator") + sub.add_parser("liquidity", help="Print PMM pool liquidity snapshot") + sub.add_parser("validate", help="Validate all M2 registry tokens have USD prices") + + args = parser.parse_args(argv) + load_dotenv() + + if args.command == "serve": + from .api import run_server + + run_server() + return 0 + if args.command == "mesh": + asyncio.run(mesh_loop()) + return 0 + if args.command == "keeper": + asyncio.run(keeper_loop()) + return 0 + if args.command == "refresh-fx": + return cmd_refresh_fx() + if args.command == "update-oracle": + return asyncio.run(cmd_update_oracle()) + if args.command == "liquidity": + return cmd_liquidity() + if args.command == "validate": + return cmd_validate() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/chain138-oracle/chain138_oracle/api.py b/services/chain138-oracle/chain138_oracle/api.py new file mode 100644 index 0000000..8d9f067 --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/api.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from fastapi import FastAPI, HTTPException, Query + +from . import reference_cache +from .config import addresses, api_port, load_dotenv +from .fx_refresh import ensure_fx_primed, refresh_fx_rates +from .liquidity import fetch_all_pool_liquidity, liquidity_by_token +from .onchain_eth import get_eth_usd_on_chain, refresh_live_reference_prices +from .registry import CHAIN_ID, load_registry, merge_pmm_tokens, load_pmm_token_map +from .spot_price import resolve_spot_price +from . import canonical_price + +load_dotenv() + +app = FastAPI( + title="Chain 138 Oracle (Python)", + version="1.0.0", + description="Python oracle for Chain 138 — MetaMask spot prices, token coverage, PMM liquidity, on-chain feeds.", +) + + +def _parse_addresses(raw: str | None) -> list[str]: + if not raw: + return [] + out: list[str] = [] + seen: set[str] = set() + for part in raw.split(","): + addr = part.strip().lower() + if len(addr) == 42 and addr.startswith("0x") and addr not in seen: + seen.add(addr) + out.append(addr) + if len(out) >= 120: + break + return out + + +@app.get("/health") +async def health() -> dict[str, Any]: + eth = get_eth_usd_on_chain() + return { + "status": "ok", + "chainId": CHAIN_ID, + "service": "chain138-oracle-python", + "ethUsdOnChain": eth.price_usd if eth else None, + "ethUsdSource": eth.source if eth else None, + } + + +@app.get("/api/v1/prices/metamask/v2/chains/{chain_id}/spot-prices") +async def metamask_spot_prices_v2( + chain_id: int, + tokenAddresses: str = Query(..., alias="tokenAddresses"), + vsCurrency: str = Query("usd", alias="vsCurrency"), +) -> dict[str, dict[str, float]]: + if chain_id != CHAIN_ID: + raise HTTPException(400, "Only chain 138 is supported by this oracle service") + addresses_list = _parse_addresses(tokenAddresses) + if not addresses_list: + raise HTTPException(400, "tokenAddresses query parameter is required") + + refresh_live_reference_prices() + await ensure_fx_primed() + vs = vsCurrency.lower() or "usd" + out: dict[str, dict[str, float]] = {} + for addr in addresses_list: + spot = await resolve_spot_price(chain_id, addr) + if spot.price_usd and spot.price_usd > 0: + out[addr] = {vs: spot.price_usd} + return out + + +@app.get("/api/v1/prices/metamask/v1/chains/{chain_id}/spot-prices/{token_address}") +async def metamask_spot_price_v1(chain_id: int, token_address: str, vsCurrency: str = Query("usd")) -> dict[str, Any]: + if chain_id != CHAIN_ID: + raise HTTPException(400, "Only chain 138 is supported") + refresh_live_reference_prices() + await ensure_fx_primed() + spot = await resolve_spot_price(chain_id, token_address) + if not spot.price_usd: + raise HTTPException(404, "Price unavailable") + return { + "tokenAddress": token_address.lower(), + "vsCurrency": vsCurrency.lower(), + "price": spot.price_usd, + "symbol": spot.symbol, + "source": spot.source, + } + + +@app.get("/api/v1/oracle/tokens") +async def all_token_prices() -> dict[str, Any]: + await ensure_fx_primed() + refresh_live_reference_prices() + tokens = load_registry() + rows = [] + unresolved = [] + for spec in tokens: + resolution = canonical_price.resolve_for_address(spec) + rows.append( + { + "symbol": spec.symbol, + "address": spec.address, + "decimals": spec.decimals, + "currencyCode": spec.currency_code, + "omnlLine": spec.omnl_line, + "priceUsd": resolution.price_usd, + "referenceSymbol": resolution.reference_symbol, + "source": resolution.source, + } + ) + if not resolution.price_usd: + unresolved.append(spec.symbol) + liq = liquidity_by_token() + for row in rows: + row["liquidityUsd"] = liq.get(row["address"], 0.0) + return { + "chainId": CHAIN_ID, + "tokenCount": len(rows), + "unresolvedSymbols": unresolved, + "referenceRates": reference_cache.snapshot(), + "tokens": rows, + } + + +@app.get("/api/v1/oracle/liquidity/pools") +async def pool_liquidity() -> dict[str, Any]: + await ensure_fx_primed() + refresh_live_reference_prices() + pools = fetch_all_pool_liquidity() + return { + "chainId": CHAIN_ID, + "poolCount": len(pools), + "totalLiquidityUsd": sum(p.total_liquidity_usd for p in pools), + "pools": [p.__dict__ for p in pools], + } + + +@app.get("/api/v1/oracle/reference-rates") +async def reference_rates() -> dict[str, Any]: + await refresh_fx_rates() + eth = get_eth_usd_on_chain() + return { + "chainId": CHAIN_ID, + "onChainEthUsd": eth.__dict__ if eth else None, + "rates": reference_cache.snapshot(), + "contracts": addresses().__dict__, + } + + +def run_server() -> None: + import uvicorn + + uvicorn.run("chain138_oracle.api:app", host="0.0.0.0", port=api_port(), reload=False) diff --git a/services/chain138-oracle/chain138_oracle/canonical_price.py b/services/chain138-oracle/chain138_oracle/canonical_price.py new file mode 100644 index 0000000..249bdfe --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/canonical_price.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from typing import Optional + +from . import reference_cache as cache +from .registry import TokenSpec + +REPO_FALLBACK_PRICE_USD: dict[str, float] = { + "USD": 1.0, + "EUR": 1.1780, + "GBP": 1.3550353712543854, + "AUD": 0.7136366390016357, + "CAD": 0.7255928549430243, + "CHF": 1.2776572668112798, + "JPY": 0.006285683794888213, + "XAU": 5163.3401260328355, + "ETH": 1680.0, + "BTC": 90000.0, + "LINK": 8.0, + "BNB": 610.0, + "POL": 0.78, + "AVAX": 48.0, + "CELO": 0.72, + "CRO": 0.14, + "XDAI": 1.0, + "WEMIX": 0.5, + "LIPMG": 1100.0, + "LIBMG1": 4.25, + "LIBMG2": 18.5, + "LIBMG3": 0.12, +} + + +@dataclass(frozen=True) +class PriceResolution: + price_usd: Optional[float] + reference_symbol: Optional[str] + source: str + + +def eth_oracle_drift_bps(indexer_price: float, oracle_price: float) -> float: + if oracle_price <= 0 or indexer_price <= 0: + return 0.0 + return abs(indexer_price - oracle_price) / oracle_price * 10000 + + +def read_drift_bps() -> int: + raw = os.environ.get("TOKEN_AGG_CHAIN138_ETH_ORACLE_DRIFT_BPS", "300") + try: + n = int(raw) + return max(0, n) + except ValueError: + return 300 + + +def _read_env_price(keys: list[str]) -> Optional[float]: + for key in keys: + raw = os.environ.get(key, "").strip() + if not raw: + continue + try: + val = float(raw) + if val > 0: + return val + except ValueError: + continue + return None + + +def resolve_reference_symbol(spec: TokenSpec) -> Optional[str]: + symbol = spec.symbol.upper() + currency = spec.currency_code.upper() + + if symbol in {"WETH", "WETH9", "WETH10", "CETH", "CETHL2", "CWETH", "CWETHL2"}: + return "ETH" + if symbol in {"CBTC", "CWBTC"}: + return "BTC" + if symbol in {"CXAUC", "CXAUT", "CAXAUC", "CAXAUT", "CWAXAUC", "CWAXAUT", "LIXAU"}: + return "XAU" + if symbol == "LIPMG": + return "LIPMG" + if symbol == "LIBMG1": + return "LIBMG1" + if symbol == "LIBMG2": + return "LIBMG2" + if symbol == "LIBMG3": + return "LIBMG3" + if currency: + return currency + return None + + +def _env_keys(reference: str) -> list[str]: + ref = reference.upper() + if ref == "XAU": + return [ + "CHAIN138_CANONICAL_PRICE_USD_XAU", + "CANONICAL_PRICE_USD_XAU", + "XAU_SPOT_USD", + "GOLD_USD_PRICE", + ] + if ref == "ETH": + return [ + "CHAIN138_CANONICAL_PRICE_USD_ETH", + "CANONICAL_PRICE_USD_ETH", + "ETH_PRICE_USD", + "CHAIN138_D3_PILOT_WETH_USD", + ] + return [ + f"CHAIN138_CANONICAL_PRICE_USD_{ref}", + f"CANONICAL_PRICE_USD_{ref}", + f"{ref}_PRICE_USD", + ] + + +def resolve_for_spec(spec: TokenSpec) -> PriceResolution: + reference = resolve_reference_symbol(spec) + if not reference: + return PriceResolution(None, None, "unresolved") + + env_price = _read_env_price(_env_keys(reference)) + if env_price is not None: + return PriceResolution(env_price, reference, "env") + + live = cache.get(reference) + if live is not None: + return PriceResolution(live, reference, "on-chain") + + require_live_btc = reference == "BTC" and ( + os.environ.get("OMNL_REQUIRE_LIVE_BTC_PRICE") == "1" + or (os.environ.get("NODE_ENV") == "production" and os.environ.get("OMNL_REQUIRE_LIVE_BTC_PRICE") != "0") + ) + if require_live_btc: + return PriceResolution(None, reference, "unresolved") + + fallback = REPO_FALLBACK_PRICE_USD.get(reference) + if fallback is not None: + return PriceResolution(fallback, reference, "repo-fallback") + + return PriceResolution(None, reference, "unresolved") + + +def resolve_for_address(spec: Optional[TokenSpec]) -> PriceResolution: + if spec is None: + return PriceResolution(None, None, "unresolved") + base = resolve_for_spec(spec) + if base.reference_symbol != "ETH": + return base + + live_eth = cache.get("ETH") + if live_eth is None: + return base + + if base.source == "env" and base.price_usd is not None: + if eth_oracle_drift_bps(base.price_usd, live_eth) > read_drift_bps(): + return PriceResolution(live_eth, "ETH", "on-chain") + return base + + if base.source in {"repo-fallback", "unresolved"} or base.price_usd is None: + return PriceResolution(live_eth, "ETH", "on-chain") + + return base + + +def price_usd(spec: Optional[TokenSpec]) -> Optional[float]: + return resolve_for_address(spec).price_usd diff --git a/services/chain138-oracle/chain138_oracle/config.py b/services/chain138-oracle/chain138_oracle/config.py new file mode 100644 index 0000000..1776cc1 --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/config.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +def repo_root() -> Path: + env_root = os.environ.get("OMNL_REPO_ROOT", "").strip() + if env_root: + return Path(env_root) + candidate = Path(__file__).resolve().parents[3] + if (candidate / "config" / "omnl-m2-token-registry.v1.json").is_file(): + return candidate + docker_app = Path(__file__).resolve().parents[1] + if (docker_app / "config" / "omnl-m2-token-registry.v1.json").is_file(): + return docker_app + return candidate + + +@dataclass(frozen=True) +class Chain138Addresses: + aggregator: str = "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506" + proxy: str = "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6" + oracle_price_feed: str = "0x8918eE0819fD687f4eb3e8b9B7D0ef7557493cfa" + reserve_system: str = "0x607e97cD626f209facfE48c1464815DDE15B5093" + price_feed_keeper: str = "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04" + weth_mock_price_feed: str = "0x3e8725b8De386feF3eFE5678c92eA6aDB41992B2" + dodo_pmm_integration: str = "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895" + weth9: str = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + + +CANONICAL_LIVE_POOLS: tuple[str, ...] = ( + "0x9e89bAe009adf128782E19e8341996c596ac40dC", + "0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66", + "0xc39B7D0F40838cbFb54649d327f49a6DAC964062", + "0x67049e7333481e2cac91af61403ac7bddfab7bcd", + "0x72f1a0794153c3b8a1e8a731f1d8e1a52cb10dc5", + "0xb53a0508940b1ff90f1aad4f6cb50a7012fe5593", + "0xe227f6c0520c0c6e8786fe56fa76c4914f861533", + "0xf3e8a07d419b61f002114e64d79f7cf8f7989433", +) + + +def load_dotenv() -> None: + try: + from dotenv import load_dotenv as _load + + root = repo_root() + for path in ( + root / ".env", + root / "services" / "chain138-oracle" / ".env", + Path(os.environ.get("ORACLE_PUBLISHER_ENV", "/opt/oracle-publisher/.env")), + ): + if path.is_file(): + _load(path, override=False) + except ImportError: + pass + + +def chain138_rpc_url() -> str: + for key in ("RPC_URL_138", "CHAIN138_SERVER_RPC_URL", "RPC_URL"): + value = os.environ.get(key, "").strip() + if value: + return value.rstrip("/") + return "https://rpc-http-pub.d-bis.org" + + +def addresses() -> Chain138Addresses: + load_dotenv() + return Chain138Addresses( + aggregator=_addr("AGGREGATOR_ADDRESS", "ORACLE_AGGREGATOR_ADDRESS", default=Chain138Addresses.aggregator), + proxy=_addr("ORACLE_PROXY_ADDRESS", "ORACLE_PROXY", default=Chain138Addresses.proxy), + oracle_price_feed=_addr("ORACLE_PRICE_FEED", default=Chain138Addresses.oracle_price_feed), + reserve_system=_addr("RESERVE_SYSTEM", default=Chain138Addresses.reserve_system), + price_feed_keeper=_addr("PRICE_FEED_KEEPER_ADDRESS", default=Chain138Addresses.price_feed_keeper), + weth_mock_price_feed=_addr("CHAIN138_WETH_MOCK_PRICE_FEED", default=Chain138Addresses.weth_mock_price_feed), + dodo_pmm_integration=_addr( + "DODO_PMM_INTEGRATION", + "CHAIN_138_DODO_PMM_INTEGRATION", + default=Chain138Addresses.dodo_pmm_integration, + ), + weth9=_addr("WETH9_ADDRESS", default=Chain138Addresses.weth9), + ) + + +def _addr(*keys: str, default: str = "") -> str: + for key in keys: + value = os.environ.get(key, "").strip() + if value: + return value + return default + + +def api_port() -> int: + raw = os.environ.get("CHAIN138_ORACLE_PORT", os.environ.get("ORACLE_PUBLISHER_PORT", "3500")) + try: + return int(raw) + except ValueError: + return 3500 + + +def mesh_interval_sec() -> int: + raw = os.environ.get("PMM_MESH_INTERVAL_SEC", "6") + try: + return max(1, int(raw)) + except ValueError: + return 6 diff --git a/services/chain138-oracle/chain138_oracle/fx_refresh.py b/services/chain138-oracle/chain138_oracle/fx_refresh.py new file mode 100644 index 0000000..b81426b --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/fx_refresh.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import asyncio +import os +import time +from typing import Optional + +import httpx + +from . import reference_cache as cache + +FRANKFURTER = ("EUR", "GBP", "AUD", "CAD", "CHF", "JPY") +COINGECKO_IDS = { + "ETH": "ethereum", + "BTC": "bitcoin", + "LINK": "chainlink", + "XAU": "pax-gold", + "BNB": "binancecoin", + "POL": "matic-network", + "AVAX": "avalanche-2", + "CELO": "celo", + "CRO": "crypto-com-chain", +} +COMMODITY_IDS = { + "platinum": "platinum", + "palladium": "palladium", + "copper": "copper", + "nickel": "nickel", + "cobalt": "cobalt", +} + +_last_refresh = 0.0 +_refresh_lock = asyncio.Lock() + + +def _coingecko_base() -> str: + return "https://pro-api.coingecko.com/api/v3" if os.environ.get("COINGECKO_API_KEY") else "https://api.coingecko.com/api/v3" + + +def _coingecko_headers() -> dict[str, str]: + key = os.environ.get("COINGECKO_API_KEY", "").strip() + if not key: + return {"User-Agent": os.environ.get("COINGECKO_USER_AGENT", "chain138-oracle/1.0")} + if key.startswith("CG-"): + return {"x-cg-demo-api-key": key, "User-Agent": os.environ.get("COINGECKO_USER_AGENT", "chain138-oracle/1.0")} + return {"x-cg-pro-api-key": key, "User-Agent": os.environ.get("COINGECKO_USER_AGENT", "chain138-oracle/1.0")} + + +async def _fetch_frankfurter(client: httpx.AsyncClient, currency: str) -> Optional[float]: + try: + resp = await client.get( + f"https://api.frankfurter.app/latest?from={currency}&to=USD", + timeout=8.0, + ) + resp.raise_for_status() + usd = resp.json().get("rates", {}).get("USD") + if usd and float(usd) > 0: + return float(usd) + except Exception: + pass + return None + + +async def _fetch_binance_usd(symbol: str) -> Optional[float]: + pair = f"{symbol.upper()}USDT" + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"https://api.binance.com/api/v3/ticker/price?symbol={pair}", + timeout=8.0, + ) + resp.raise_for_status() + price = float(resp.json()["price"]) + return price if price > 0 else None + except Exception: + return None + + +async def _fetch_coingecko(client: httpx.AsyncClient, coin_id: str) -> Optional[float]: + try: + resp = await client.get( + f"{_coingecko_base()}/simple/price", + params={"ids": coin_id, "vs_currencies": "usd"}, + headers=_coingecko_headers(), + timeout=8.0, + ) + resp.raise_for_status() + usd = resp.json().get(coin_id, {}).get("usd") + if usd and float(usd) > 0: + return float(usd) + except Exception: + pass + return None + + +async def refresh_fx_rates(ttl_sec: float = 300) -> dict[str, float]: + global _last_refresh + updated: dict[str, float] = {} + + cache.prime("USD", 1.0, ttl_sec) + + async with httpx.AsyncClient() as client: + for currency in FRANKFURTER: + price = await _fetch_frankfurter(client, currency) + if price: + cache.prime(currency, price, ttl_sec) + updated[currency] = price + + for symbol, coin_id in COINGECKO_IDS.items(): + price = await _fetch_coingecko(client, coin_id) + if not price and symbol in {"ETH", "BTC"}: + price = await _fetch_binance_usd(symbol) + if price: + cache.prime(symbol, price, ttl_sec) + updated[symbol] = price + + commodities: dict[str, float] = {} + for key, coin_id in COMMODITY_IDS.items(): + price = await _fetch_coingecko(client, coin_id) + if price: + commodities[key] = price + + if "platinum" in commodities and "palladium" in commodities: + lipmg = (commodities["platinum"] + commodities["palladium"]) / 2 + cache.prime("LIPMG", lipmg, ttl_sec) + updated["LIPMG"] = lipmg + if "copper" in commodities: + cache.prime("LIBMG1", commodities["copper"], ttl_sec) + updated["LIBMG1"] = commodities["copper"] + if "nickel" in commodities and "cobalt" in commodities: + libmg2 = commodities["nickel"] * 0.8 + commodities["cobalt"] * 0.2 + cache.prime("LIBMG2", libmg2, ttl_sec) + updated["LIBMG2"] = libmg2 + elif "nickel" in commodities: + cache.prime("LIBMG2", commodities["nickel"], ttl_sec) + updated["LIBMG2"] = commodities["nickel"] + + _last_refresh = time.time() + return updated + + +async def ensure_fx_primed(ttl_sec: float = 300) -> None: + global _last_refresh + now = time.time() + if now - _last_refresh < ttl_sec: + return + async with _refresh_lock: + now = time.time() + if now - _last_refresh < ttl_sec: + return + await refresh_fx_rates(ttl_sec) + + +def refresh_fx_rates_sync(ttl_sec: float = 300) -> dict[str, float]: + return asyncio.run(refresh_fx_rates(ttl_sec)) diff --git a/services/chain138-oracle/chain138_oracle/keeper.py b/services/chain138-oracle/chain138_oracle/keeper.py new file mode 100644 index 0000000..53dff6e --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/keeper.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import asyncio +import os +import time +from dataclasses import dataclass +from typing import Optional + +from web3 import Web3 + +from .config import addresses, chain138_rpc_url + +KEEPER_ABI = [ + { + "inputs": [], + "name": "checkUpkeep", + "outputs": [ + {"name": "needsUpdate", "type": "bool"}, + {"name": "assets", "type": "address[]"}, + ], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "performUpkeep", + "outputs": [ + {"name": "success", "type": "bool"}, + {"name": "updatedAssets", "type": "address[]"}, + ], + "stateMutability": "nonpayable", + "type": "function", + }, +] + + +@dataclass(frozen=True) +class KeeperResult: + performed: bool + tx_hash: Optional[str] + reason: str + + +def _keeper_key() -> Optional[str]: + raw = os.environ.get("KEEPER_PRIVATE_KEY", "").strip() + if not raw: + return None + return raw if raw.startswith("0x") else f"0x{raw}" + + +def perform_upkeep_once() -> KeeperResult: + pk = _keeper_key() + if not pk: + return KeeperResult(False, None, "KEEPER_PRIVATE_KEY not set") + + w3 = Web3(Web3.HTTPProvider(chain138_rpc_url(), request_kwargs={"timeout": 30})) + account = w3.eth.account.from_key(pk) + keeper_addr = addresses().price_feed_keeper + contract = w3.eth.contract(address=Web3.to_checksum_address(keeper_addr), abi=KEEPER_ABI) + + needs, assets = contract.functions.checkUpkeep().call() + if not needs: + return KeeperResult(False, None, "no upkeep needed") + + gas_price = int(os.environ.get("MESH_CAST_GAS_PRICE", os.environ.get("ORACLE_UPDATE_GAS_PRICE_WEI", "1000000000"))) + gas_limit = int(os.environ.get("KEEPER_GAS_LIMIT", "800000")) + nonce = w3.eth.get_transaction_count(account.address) + tx = contract.functions.performUpkeep().build_transaction( + { + "from": account.address, + "nonce": nonce, + "gas": gas_limit, + "gasPrice": gas_price, + "chainId": w3.eth.chain_id, + } + ) + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + return KeeperResult(True, tx_hash.hex(), f"updated {len(assets)} assets") + + +async def keeper_loop(interval_sec: int = 6) -> None: + while True: + try: + result = perform_upkeep_once() + if result.performed: + print(f"[keeper] {result.reason} tx={result.tx_hash}") + except Exception as exc: + print(f"[keeper] error: {exc}") + await asyncio.sleep(interval_sec) diff --git a/services/chain138-oracle/chain138_oracle/liquidity.py b/services/chain138-oracle/chain138_oracle/liquidity.py new file mode 100644 index 0000000..baf3102 --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/liquidity.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from web3 import Web3 + +from . import canonical_price +from .config import CANONICAL_LIVE_POOLS, addresses, chain138_rpc_url +from .registry import TokenSpec, merge_pmm_tokens, load_pmm_token_map, load_registry + +PMM_ABI = [ + { + "inputs": [{"name": "pool", "type": "address"}], + "name": "getPoolReserves", + "outputs": [ + {"name": "baseToken", "type": "address"}, + {"name": "quoteToken", "type": "address"}, + {"name": "baseReserve", "type": "uint256"}, + {"name": "quoteReserve", "type": "uint256"}, + ], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"name": "pool", "type": "address"}], + "name": "getPoolPriceOrOracle", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, +] + + +@dataclass(frozen=True) +class PoolLiquidity: + pool: str + base_token: str + quote_token: str + base_symbol: str + quote_symbol: str + base_reserve: float + quote_reserve: float + pool_price: float + base_usd: float + quote_usd: float + total_liquidity_usd: float + + +def _amount(raw: int, decimals: int) -> float: + if raw <= 0: + return 0.0 + return raw / (10**decimals) + + +def _price(raw: int) -> float: + if raw <= 0: + return 0.0 + return raw / 1e18 + + +def estimate_pool_liquidity( + pool: str, + base_token: str, + quote_token: str, + base_reserve: int, + quote_reserve: int, + pool_price: int, + token_map: dict[str, TokenSpec], +) -> PoolLiquidity: + base_spec = token_map.get(base_token.lower()) + quote_spec = token_map.get(quote_token.lower()) + base_dec = base_spec.decimals if base_spec else 18 + quote_dec = quote_spec.decimals if quote_spec else 18 + base_amt = _amount(base_reserve, base_dec) + quote_amt = _amount(quote_reserve, quote_dec) + price = _price(pool_price) + + base_usd = canonical_price.price_usd(base_spec) or 0.0 + quote_usd = canonical_price.price_usd(quote_spec) or 0.0 + + if price > 0: + if quote_usd == 1.0 and base_usd != 1.0: + base_usd = price + if base_usd == 1.0 and quote_usd != 1.0: + quote_usd = price + + reserve0_usd = base_amt * base_usd if base_usd > 0 else 0.0 + reserve1_usd = quote_amt * quote_usd if quote_usd > 0 else 0.0 + total = reserve0_usd + reserve1_usd + + return PoolLiquidity( + pool=pool, + base_token=base_token, + quote_token=quote_token, + base_symbol=base_spec.symbol if base_spec else base_token[:10], + quote_symbol=quote_spec.symbol if quote_spec else quote_token[:10], + base_reserve=base_amt, + quote_reserve=quote_amt, + pool_price=price, + base_usd=base_usd, + quote_usd=quote_usd, + total_liquidity_usd=total, + ) + + +def fetch_all_pool_liquidity() -> list[PoolLiquidity]: + w3 = Web3(Web3.HTTPProvider(chain138_rpc_url(), request_kwargs={"timeout": 30})) + integration = addresses().dodo_pmm_integration + contract = w3.eth.contract(address=Web3.to_checksum_address(integration), abi=PMM_ABI) + + tokens = merge_pmm_tokens(load_registry(), load_pmm_token_map()) + out: list[PoolLiquidity] = [] + + for pool in CANONICAL_LIVE_POOLS: + try: + reserves = contract.functions.getPoolReserves(Web3.to_checksum_address(pool)).call() + price = contract.functions.getPoolPriceOrOracle(Web3.to_checksum_address(pool)).call() + liq = estimate_pool_liquidity( + pool.lower(), + reserves[0].lower(), + reserves[1].lower(), + int(reserves[2]), + int(reserves[3]), + int(price), + tokens, + ) + out.append(liq) + except Exception: + continue + return out + + +def liquidity_by_token() -> dict[str, float]: + totals: dict[str, float] = {} + for pool in fetch_all_pool_liquidity(): + for addr, usd_part in ( + (pool.base_token, pool.base_reserve * pool.base_usd), + (pool.quote_token, pool.quote_reserve * pool.quote_usd), + ): + if usd_part <= 0: + continue + totals[addr.lower()] = totals.get(addr.lower(), 0.0) + usd_part + return totals diff --git a/services/chain138-oracle/chain138_oracle/mesh.py b/services/chain138-oracle/chain138_oracle/mesh.py new file mode 100644 index 0000000..8513982 --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/mesh.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import asyncio +import time + +from .config import CANONICAL_LIVE_POOLS, mesh_interval_sec +from .fx_refresh import refresh_fx_rates +from .keeper import perform_upkeep_once +from .liquidity import fetch_all_pool_liquidity +from .onchain_eth import get_eth_usd_on_chain +from .onchain_oracle import fetch_eth_usd_market, update_eth_usd_oracle + + +async def mesh_tick(tick: int) -> None: + refresh_live = get_eth_usd_on_chain() + if refresh_live: + print(f"[mesh:{tick}] on-chain ETH/USD ${refresh_live.price_usd:.2f} ({refresh_live.source})") + + try: + market = await fetch_eth_usd_market() + result = update_eth_usd_oracle(market) + if result.skipped: + print(f"[mesh:{tick}] oracle skip: {result.reason}") + else: + print(f"[mesh:{tick}] oracle tx {result.tx_hash} @ ${result.eth_usd:.2f}") + except PermissionError as exc: + print(f"[mesh:{tick}] oracle skip: {exc}") + except Exception as exc: + print(f"[mesh:{tick}] oracle error: {exc}") + + try: + keeper = perform_upkeep_once() + if keeper.performed: + print(f"[mesh:{tick}] keeper {keeper.reason} tx={keeper.tx_hash}") + except Exception as exc: + print(f"[mesh:{tick}] keeper error: {exc}") + + if tick % 10 == 0: + pools = fetch_all_pool_liquidity() + total_tvl = sum(p.total_liquidity_usd for p in pools) + print(f"[mesh:{tick}] PMM pools={len(pools)}/{len(CANONICAL_LIVE_POOLS)} TVL=${total_tvl:,.2f}") + + +async def mesh_loop() -> None: + await refresh_fx_rates() + interval = mesh_interval_sec() + tick = 0 + print(f"[mesh] starting {interval}s loop for Chain 138 oracle/keeper/PMM") + while True: + tick += 1 + started = time.time() + await mesh_tick(tick) + if tick % 50 == 0: + await refresh_fx_rates() + elapsed = time.time() - started + await asyncio.sleep(max(0.0, interval - elapsed)) diff --git a/services/chain138-oracle/chain138_oracle/onchain_eth.py b/services/chain138-oracle/chain138_oracle/onchain_eth.py new file mode 100644 index 0000000..65a0c6c --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/onchain_eth.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Optional + +from web3 import Web3 + +from . import reference_cache as cache +from .config import addresses, chain138_rpc_url + +ROUND_DATA_ABI = [ + { + "inputs": [], + "name": "latestRoundData", + "outputs": [ + {"name": "roundId", "type": "uint80"}, + {"name": "answer", "type": "int256"}, + {"name": "startedAt", "type": "uint256"}, + {"name": "updatedAt", "type": "uint256"}, + {"name": "answeredInRound", "type": "uint80"}, + ], + "stateMutability": "view", + "type": "function", + } +] + +_cached: Optional[tuple[float, str, float]] = None + + +@dataclass(frozen=True) +class EthUsdOnChain: + price_usd: float + source: str + + +def _web3() -> Web3: + return Web3(Web3.HTTPProvider(chain138_rpc_url(), request_kwargs={"timeout": 20})) + + +def _read_eth_usd(w3: Web3, address: str) -> Optional[float]: + contract = w3.eth.contract(address=Web3.to_checksum_address(address), abi=ROUND_DATA_ABI) + result = contract.functions.latestRoundData().call() + answer = int(result[1]) + if answer <= 0: + return None + return answer / 1e8 + + +def get_eth_usd_on_chain() -> Optional[EthUsdOnChain]: + global _cached + now = time.time() + if _cached and _cached[2] > now: + return EthUsdOnChain(_cached[0], _cached[1]) + + addrs = addresses() + w3 = _web3() + for address, source in ((addrs.aggregator, "aggregator"), (addrs.proxy, "proxy")): + try: + price = _read_eth_usd(w3, address) + if price and price > 0: + _cached = (price, source, now + 60) + cache.prime("ETH", price, 120) + return EthUsdOnChain(price, source) + except Exception: + continue + return None + + +def refresh_live_reference_prices() -> None: + get_eth_usd_on_chain() diff --git a/services/chain138-oracle/chain138_oracle/onchain_oracle.py b/services/chain138-oracle/chain138_oracle/onchain_oracle.py new file mode 100644 index 0000000..f35401c --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/onchain_oracle.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + +import httpx +from web3 import Web3 + +from .config import addresses, chain138_rpc_url + +AGGREGATOR_ABI = [ + { + "inputs": [{"name": "candidate", "type": "address"}], + "name": "isTransmitter", + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"name": "answer", "type": "int256"}], + "name": "transmit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{"name": "answer", "type": "int256"}], + "name": "updateAnswer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [], + "name": "latestRoundData", + "outputs": [ + {"name": "roundId", "type": "uint80"}, + {"name": "answer", "type": "int256"}, + {"name": "startedAt", "type": "uint256"}, + {"name": "updatedAt", "type": "uint256"}, + {"name": "answeredInRound", "type": "uint80"}, + ], + "stateMutability": "view", + "type": "function", + }, +] + + +@dataclass(frozen=True) +class OracleUpdateResult: + skipped: bool + tx_hash: Optional[str] + eth_usd: float + reason: str + + +def _private_key() -> str: + for key in ("PRIVATE_KEY", "DEPLOYER_PRIVATE_KEY", "ORACLE_TRANSMITTER_PRIVATE_KEY"): + value = os.environ.get(key, "").strip() + if value: + return value if value.startswith("0x") else f"0x{value}" + raise ValueError("Set PRIVATE_KEY or DEPLOYER_PRIVATE_KEY for oracle updates") + + +async def fetch_eth_usd_market() -> float: + headers = {"User-Agent": os.environ.get("COINGECKO_USER_AGENT", "chain138-oracle/1.0")} + key = os.environ.get("COINGECKO_API_KEY", "").strip() + async with httpx.AsyncClient() as client: + if key: + demo_headers = {**headers, "x-cg-demo-api-key": key} + try: + resp = await client.get( + "https://api.coingecko.com/api/v3/simple/price", + params={"ids": "ethereum", "vs_currencies": "usd"}, + headers=demo_headers, + timeout=20, + ) + val = resp.json().get("ethereum", {}).get("usd") + if val: + return float(val) + except Exception: + pass + pro_headers = {**headers, "x-cg-pro-api-key": key} + try: + resp = await client.get( + "https://pro-api.coingecko.com/api/v3/simple/price", + params={"ids": "ethereum", "vs_currencies": "usd"}, + headers=pro_headers, + timeout=20, + ) + val = resp.json().get("ethereum", {}).get("usd") + if val: + return float(val) + except Exception: + pass + resp = await client.get( + "https://api.coingecko.com/api/v3/simple/price", + params={"ids": "ethereum", "vs_currencies": "usd"}, + headers=headers, + timeout=20, + ) + val = resp.json().get("ethereum", {}).get("usd") + if val: + return float(val) + resp = await client.get("https://api.binance.com/api/v3/ticker/price?symbol=ETHUSDT", timeout=20) + return float(resp.json()["price"]) + + +def update_eth_usd_oracle(eth_usd: float, min_drift_pct: float = 1.0) -> OracleUpdateResult: + w3 = Web3(Web3.HTTPProvider(chain138_rpc_url(), request_kwargs={"timeout": 60})) + pk = _private_key() + account = w3.eth.account.from_key(pk) + agg = addresses().aggregator + contract = w3.eth.contract(address=Web3.to_checksum_address(agg), abi=AGGREGATOR_ABI) + + current = contract.functions.latestRoundData().call() + current_price = int(current[1]) / 1e8 if int(current[1]) > 0 else 0.0 + if current_price > 0: + drift = abs(eth_usd - current_price) / current_price * 100 + if drift < min_drift_pct: + return OracleUpdateResult(True, None, eth_usd, f"drift {drift:.2f}% < {min_drift_pct}%") + + if not contract.functions.isTransmitter(account.address).call(): + raise PermissionError(f"{account.address} is not an authorized transmitter on {agg}") + + answer = int(eth_usd * 1e8) + fn = contract.functions.transmit(answer) + try: + fn.estimate_gas({"from": account.address}) + except Exception: + fn = contract.functions.updateAnswer(answer) + + gas_price = int(os.environ.get("ORACLE_UPDATE_GAS_PRICE_WEI", "1000000000")) + gas_limit = int(os.environ.get("ORACLE_UPDATE_GAS_LIMIT", "400000")) + nonce = w3.eth.get_transaction_count(account.address) + tx = fn.build_transaction( + { + "from": account.address, + "nonce": nonce, + "gas": gas_limit, + "gasPrice": gas_price, + "chainId": w3.eth.chain_id, + } + ) + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + return OracleUpdateResult(False, tx_hash.hex(), eth_usd, "submitted") diff --git a/services/chain138-oracle/chain138_oracle/reference_cache.py b/services/chain138-oracle/chain138_oracle/reference_cache.py new file mode 100644 index 0000000..09c1844 --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/reference_cache.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import time +from threading import Lock +from typing import Optional + +_lock = Lock() +_prices: dict[str, tuple[float, float]] = {} + + +def prime(symbol: str, price_usd: float, ttl_sec: float = 300) -> None: + if not price_usd or price_usd <= 0: + return + key = symbol.upper() + with _lock: + _prices[key] = (price_usd, time.time() + ttl_sec) + + +def get(symbol: str) -> Optional[float]: + key = symbol.upper() + with _lock: + row = _prices.get(key) + if not row: + return None + price, expires = row + if expires <= time.time(): + return None + return price + + +def snapshot() -> dict[str, float]: + now = time.time() + with _lock: + return {k: v[0] for k, v in _prices.items() if v[1] > now} diff --git a/services/chain138-oracle/chain138_oracle/registry.py b/services/chain138-oracle/chain138_oracle/registry.py new file mode 100644 index 0000000..362958f --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/registry.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from .config import repo_root + +CHAIN_ID = 138 +NATIVE_ETH = "0x0000000000000000000000000000000000000000" +NATIVE_ETH_SENTINEL = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + + +@dataclass(frozen=True) +class TokenSpec: + symbol: str + name: str + address: str + decimals: int + currency_code: str + omnl_line: str + native: bool = False + + +def _norm(addr: str) -> str: + return addr.strip().lower() + + +def load_registry() -> list[TokenSpec]: + path = repo_root() / "config" / "omnl-m2-token-registry.v1.json" + data = json.loads(path.read_text(encoding="utf-8")) + tokens: list[TokenSpec] = [] + for row in data.get("tokens", []): + addr = str(row.get("address", "")).strip() + if not addr: + continue + tokens.append( + TokenSpec( + symbol=str(row.get("symbol", "")), + name=str(row.get("name", "")), + address=_norm(addr), + decimals=int(row.get("decimals", 18)), + currency_code=str(row.get("currencyCode", "USD")).upper(), + omnl_line=str(row.get("omnlLine", "")), + native=bool(row.get("native", False)), + ) + ) + return tokens + + +def load_pmm_token_map() -> dict[str, str]: + path = repo_root() / "config" / "chain138-pmm-pools.json" + data = json.loads(path.read_text(encoding="utf-8")) + raw = data.get("tokens", {}) + return {str(k): _norm(str(v)) for k, v in raw.items()} + + +def by_address(tokens: list[TokenSpec]) -> dict[str, TokenSpec]: + out: dict[str, TokenSpec] = {} + for t in tokens: + out[t.address] = t + return out + + +def by_symbol(tokens: list[TokenSpec]) -> dict[str, TokenSpec]: + out: dict[str, TokenSpec] = {} + for t in tokens: + out[t.symbol.upper()] = t + return out + + +def merge_pmm_tokens(tokens: list[TokenSpec], pmm_map: dict[str, str]) -> dict[str, TokenSpec]: + """Ensure PMM pool tokens are address-indexed even if absent from M2 registry.""" + addr_map = by_address(tokens) + sym_map = by_symbol(tokens) + for symbol, address in pmm_map.items(): + if address in addr_map: + continue + match = sym_map.get(symbol.upper()) + if match: + addr_map[address] = match + else: + addr_map[address] = TokenSpec( + symbol=symbol, + name=symbol, + address=address, + decimals=18, + currency_code="USD", + omnl_line="USD-M2", + ) + return addr_map diff --git a/services/chain138-oracle/chain138_oracle/spot_price.py b/services/chain138-oracle/chain138_oracle/spot_price.py new file mode 100644 index 0000000..eddc4d7 --- /dev/null +++ b/services/chain138-oracle/chain138_oracle/spot_price.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Optional + +from . import canonical_price +from .onchain_eth import get_eth_usd_on_chain +from .registry import NATIVE_ETH, NATIVE_ETH_SENTINEL, TokenSpec, by_address, load_registry +from .fx_refresh import ensure_fx_primed + +AGGREGATOR = "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506" +PROXY = "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6" +WETH9 = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + + +@dataclass(frozen=True) +class SpotPrice: + price_usd: Optional[float] + symbol: Optional[str] + currency_code: Optional[str] + reference_symbol: Optional[str] + source: str + + +def _is_eth_peg(spec: Optional[TokenSpec], address: str) -> bool: + if address == WETH9: + return True + sym = (spec.symbol if spec else "").upper() + return sym in {"WETH", "WETH9", "WETH10", "CETH", "CETHL2"} + + +async def resolve_spot_price(chain_id: int, address: str) -> SpotPrice: + await ensure_fx_primed() + refresh_live = get_eth_usd_on_chain() + + normalized = address.strip().lower() + tokens = by_address(load_registry()) + spec = tokens.get(normalized) + + if normalized in {NATIVE_ETH, NATIVE_ETH_SENTINEL}: + if chain_id == 138 and refresh_live: + return SpotPrice(refresh_live.price_usd, "ETH", "ETH", "ETH", "native-oracle") + base = canonical_price.resolve_for_address(spec) + return SpotPrice(base.price_usd, "ETH", "ETH", "ETH", "iso4217-oracle" if base.price_usd else "unresolved") + + if chain_id == 138 and normalized in {AGGREGATOR, PROXY}: + if refresh_live: + return SpotPrice(refresh_live.price_usd, "ETH-USD", "USD", "ETH", "native-oracle") + + if chain_id == 138 and _is_eth_peg(spec, normalized): + if refresh_live: + return SpotPrice( + refresh_live.price_usd, + spec.symbol if spec else "WETH", + spec.currency_code if spec else "ETH", + "ETH", + "native-oracle", + ) + + base = canonical_price.resolve_for_address(spec) + return SpotPrice( + base.price_usd, + spec.symbol if spec else None, + spec.currency_code if spec else None, + base.reference_symbol, + "iso4217-oracle" if base.price_usd else "unresolved", + ) + + +def resolve_spot_price_sync(chain_id: int, address: str) -> SpotPrice: + return asyncio.run(resolve_spot_price(chain_id, address)) diff --git a/services/chain138-oracle/requirements.txt b/services/chain138-oracle/requirements.txt new file mode 100644 index 0000000..4d73de1 --- /dev/null +++ b/services/chain138-oracle/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +web3>=7.0.0 +httpx>=0.27.0 +python-dotenv>=1.0.0 +pydantic>=2.0.0 diff --git a/services/chain138-oracle/scripts/validate_coverage.py b/services/chain138-oracle/scripts/validate_coverage.py new file mode 100644 index 0000000..81162e2 --- /dev/null +++ b/services/chain138-oracle/scripts/validate_coverage.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +"""Validate Chain 138 oracle coverage — every M2 registry token must resolve a USD price.""" + +from chain138_oracle.__main__ import cmd_validate + +if __name__ == "__main__": + raise SystemExit(cmd_validate()) diff --git a/services/chain138-oracle/systemd/chain138-oracle-api.service.example b/services/chain138-oracle/systemd/chain138-oracle-api.service.example new file mode 100644 index 0000000..8f0b4c9 --- /dev/null +++ b/services/chain138-oracle/systemd/chain138-oracle-api.service.example @@ -0,0 +1,15 @@ +[Unit] +Description=Chain 138 Python Oracle API +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/smom-dbis-138/services/chain138-oracle +EnvironmentFile=/opt/smom-dbis-138/.env +Environment=CHAIN138_ORACLE_PORT=3500 +ExecStart=/opt/smom-dbis-138/services/chain138-oracle/.venv/bin/python -m chain138_oracle serve +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/services/chain138-oracle/systemd/chain138-oracle-mesh.service.example b/services/chain138-oracle/systemd/chain138-oracle-mesh.service.example new file mode 100644 index 0000000..5167c0a --- /dev/null +++ b/services/chain138-oracle/systemd/chain138-oracle-mesh.service.example @@ -0,0 +1,14 @@ +[Unit] +Description=Chain 138 Python Oracle Mesh (6s tick) +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/smom-dbis-138/services/chain138-oracle +EnvironmentFile=/opt/smom-dbis-138/.env +ExecStart=/opt/smom-dbis-138/services/chain138-oracle/.venv/bin/python -m chain138_oracle mesh +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/services/settlement-middleware/dist/adapters/hybx-production.js b/services/settlement-middleware/dist/adapters/hybx-production.js index 2bb825e..bb1bf50 100644 --- a/services/settlement-middleware/dist/adapters/hybx-production.js +++ b/services/settlement-middleware/dist/adapters/hybx-production.js @@ -4,9 +4,66 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); exports.HybxProductionRail = void 0; +exports.resolveHybxPaymentsUrl = resolveHybxPaymentsUrl; +exports.buildHybxRtgsHeaders = buildHybxRtgsHeaders; const axios_1 = __importDefault(require("axios")); +const crypto_1 = require("crypto"); const config_1 = require("../config"); const integration_foundation_1 = require("@dbis/integration-foundation"); +/** RFC1918 / loopback hosts — not reachable from public VPS deploys (e.g. secure.omdnl.org). */ +function isPrivateSidecarHost(url) { + try { + const host = new URL(url).hostname.toLowerCase(); + if (host === 'localhost' || host.endsWith('.local')) + return true; + if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host)) + return true; + const m = /^172\.(\d+)\./.exec(host); + if (m) { + const second = parseInt(m[1], 10); + if (second >= 16 && second <= 31) + return true; + } + return false; + } + catch { + return false; + } +} +/** Resolve outbound payment URL — public HYBX RTGS API, not LAN sidecar unless explicitly allowed. */ +function resolveHybxPaymentsUrl(baseUrl) { + const explicit = process.env.HYBX_PAYMENTS_URL?.trim(); + if (explicit) + return explicit.replace(/\/$/, ''); + const hybxApi = process.env.HYBX_API?.trim().replace(/\/$/, ''); + if (hybxApi) + return `${hybxApi}/rtgs/payments`; + const sidecar = process.env.HYBX_MIFOS_FINERACT_SIDECAR_URL?.trim(); + const allowLan = process.env.HYBX_ALLOW_LAN_SIDECAR === '1'; + if (sidecar && (allowLan || !isPrivateSidecarHost(sidecar))) { + return `${sidecar.replace(/\/$/, '')}/v1/rtgs/payments`; + } + return `${baseUrl.replace(/\/$/, '')}/v1/rtgs/payments`; +} +function hybxWebhookSecret() { + return (process.env.HYBX_WEBHOOK_SECRET?.trim() || + process.env.HYBX_SIGNING_SECRET?.trim() || + ''); +} +/** HYBX RTGS API: X-API-Key + X-Timestamp + X-Signature = HMAC-SHA256(secret, raw JSON body). */ +function buildHybxRtgsHeaders(bodyJson, apiKey) { + const secret = hybxWebhookSecret(); + if (!secret) { + throw new Error('HYBX_WEBHOOK_SECRET required for signed RTGS dispatch'); + } + const timestamp = Math.floor(Date.now() / 1000).toString(); + return { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + 'X-Timestamp': timestamp, + 'X-Signature': (0, crypto_1.createHmac)('sha256', secret).update(bodyJson).digest('hex'), + }; +} /** Production HYBX rail client for real settlement dispatch */ class HybxProductionRail { baseUrl; @@ -25,9 +82,8 @@ class HybxProductionRail { this.clientSecret = cfg.clientSecret; } async dispatchPayment(payload) { - const sidecar = process.env.HYBX_MIFOS_FINERACT_SIDECAR_URL?.replace(/\/$/, ''); - const url = sidecar ? `${sidecar}/v1/rtgs/payments` : `${this.baseUrl}/v1/payments`; - const { data } = await axios_1.default.post(url, { + const url = resolveHybxPaymentsUrl(this.baseUrl); + const body = { externalId: payload.idempotencyKey, amount: payload.amount, currency: payload.currency, @@ -38,18 +94,35 @@ class HybxProductionRail { }, remittanceInformation: payload.remittanceInfo, rail: payload.rail, - }, { - headers: { + }; + if (payload.debtorIban) + body.debtorIban = payload.debtorIban; + const bodyJson = JSON.stringify(body); + const useRtgsSignedApi = Boolean(process.env.HYBX_API?.trim() || process.env.HYBX_PAYMENTS_URL?.trim()); + const headers = useRtgsSignedApi + ? buildHybxRtgsHeaders(bodyJson, this.apiKey) + : { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey, Authorization: `Bearer ${this.clientSecret}`, - }, - timeout: 120000, - }); - return { - paymentId: String(data.paymentId ?? data.id ?? payload.idempotencyKey), - status: String(data.status ?? 'DISPATCHED'), - }; + }; + if (useRtgsSignedApi && !hybxWebhookSecret()) { + throw new Error('HYBX_WEBHOOK_SECRET required for signed RTGS dispatch (HYBX_API set)'); + } + try { + const { data } = await axios_1.default.post(url, bodyJson, { headers, timeout: 120000 }); + return { + paymentId: String(data.paymentId ?? data.id ?? payload.idempotencyKey), + status: String(data.status ?? 'DISPATCHED'), + }; + } + catch (e) { + const ax = e; + const detail = ax.response?.data != null + ? JSON.stringify(ax.response.data).slice(0, 400) + : ax.message ?? String(e); + throw new Error(`HYBX payment failed (${url}): ${detail}`); + } } } exports.HybxProductionRail = HybxProductionRail; diff --git a/services/settlement-middleware/dist/adapters/omnl.js b/services/settlement-middleware/dist/adapters/omnl.js index c960542..3e3355b 100644 --- a/services/settlement-middleware/dist/adapters/omnl.js +++ b/services/settlement-middleware/dist/adapters/omnl.js @@ -8,8 +8,9 @@ exports.requestTokenMint = requestTokenMint; exports.requestTokenTransfer = requestTokenTransfer; exports.requestFiatLpToLiquidity = requestFiatLpToLiquidity; const axios_1 = __importDefault(require("axios")); +const price_service_1 = require("./price-service"); async function archiveIso20022(payload) { - const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); + const base = (0, price_service_1.resolveTokenAggregationBase)(); const key = process.env.OMNL_API_KEY || ''; const headers = { 'Content-Type': 'application/json' }; if (key) @@ -23,7 +24,7 @@ async function archiveIso20022(payload) { return { messageId: String(data.messageId ?? data.id ?? payload.settlementRef) }; } async function requestTokenMint(payload) { - const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); + const base = (0, price_service_1.resolveTokenAggregationBase)(); const key = process.env.OMNL_API_KEY || ''; const headers = { 'Content-Type': 'application/json' }; if (key) @@ -45,7 +46,7 @@ async function requestTokenMint(payload) { } } async function requestTokenTransfer(payload) { - const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); + const base = (0, price_service_1.resolveTokenAggregationBase)(); const key = process.env.OMNL_API_KEY || ''; const headers = { 'Content-Type': 'application/json' }; if (key) @@ -67,7 +68,7 @@ async function requestTokenTransfer(payload) { } } async function requestFiatLpToLiquidity(payload) { - const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); + const base = (0, price_service_1.resolveTokenAggregationBase)(); const key = process.env.OMNL_API_KEY || ''; const headers = { 'Content-Type': 'application/json' }; if (key) diff --git a/services/settlement-middleware/dist/adapters/price-service.js b/services/settlement-middleware/dist/adapters/price-service.js new file mode 100644 index 0000000..37bd563 --- /dev/null +++ b/services/settlement-middleware/dist/adapters/price-service.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.resolveChain138OracleBase = resolveChain138OracleBase; +exports.resolveTokenAggregationBase = resolveTokenAggregationBase; +/** + * Chain 138 spot-price / oracle HTTP base. + * Pricing uses CHAIN138_ORACLE_URL (Python oracle, port 3500) when set; + * falls back to TOKEN_AGGREGATION_URL for gradual cutover. + */ +function resolveChain138OracleBase() { + const oracle = (process.env.CHAIN138_ORACLE_URL || process.env.ORACLE_PUBLISHER_URL || '').replace(/\/$/, ''); + if (oracle) + return oracle; + return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3500').replace(/\/$/, ''); +} +/** token-aggregation base for OMNL mint/transfer/ISO20022 (not spot prices). */ +function resolveTokenAggregationBase() { + return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); +} diff --git a/services/settlement-middleware/dist/adapters/pricing.js b/services/settlement-middleware/dist/adapters/pricing.js index 3744637..c9cd29e 100644 --- a/services/settlement-middleware/dist/adapters/pricing.js +++ b/services/settlement-middleware/dist/adapters/pricing.js @@ -5,23 +5,29 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); exports.resolveLiveBtcUsd = resolveLiveBtcUsd; const axios_1 = __importDefault(require("axios")); -const CBTC_ADDRESS = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e'; +const price_service_1 = require("./price-service"); +const btc_ledger_config_1 = require("../btc-ledger-config"); const CHAIN_138 = 138; -function tokenAggregationBase() { - return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); +function cbtcAddress() { + try { + return (0, btc_ledger_config_1.loadBtcLedgerConfig)().token.address.toLowerCase(); + } + catch { + return '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e'; + } } /** * Resolve live BTC/USD for settlement. Rejects unresolved/stale prices — * production must not fall back to the $90k repo snapshot. */ async function resolveLiveBtcUsd() { - const base = tokenAggregationBase(); + const base = (0, price_service_1.resolveChain138OracleBase)(); const key = process.env.OMNL_API_KEY || ''; const headers = { Accept: 'application/json' }; if (key) headers.Authorization = `Bearer ${key}`; const url = `${base}/api/v1/prices/metamask/v2/chains/${CHAIN_138}/spot-prices` + - `?tokenAddresses=${CBTC_ADDRESS}&vsCurrency=usd`; + `?tokenAddresses=${cbtcAddress()}&vsCurrency=usd`; let data; try { const res = await axios_1.default.get(url, { headers, timeout: 30000 }); @@ -41,11 +47,12 @@ async function resolveLiveBtcUsd() { throw err; } const map = (data ?? {}); - const row = map[CBTC_ADDRESS.toLowerCase()] ?? map[CBTC_ADDRESS]; + const addr = cbtcAddress(); + const row = map[addr] ?? map[addr.toLowerCase()]; const rate = row?.usd ?? row?.USD; if (!(typeof rate === 'number' && Number.isFinite(rate) && rate > 0)) { throw new Error('Live BTC/USD price unresolved — settlement blocked (OMNL_REQUIRE_LIVE_BTC_PRICE). ' + - 'Ensure token-aggregation FX refresh / CoinGecko is reachable.'); + 'Ensure chain138-oracle (CHAIN138_ORACLE_URL) or token-aggregation FX refresh is reachable.'); } // Guard against known static repo fallback used in non-production const requireLive = process.env.OMNL_REQUIRE_LIVE_BTC_PRICE === '1' || process.env.NODE_ENV === 'production'; @@ -61,8 +68,8 @@ async function resolveLiveBtcUsd() { } return { btcUsdRate: rate, - source: 'token-aggregation/metamask-spot-prices', + source: 'chain138-oracle/metamask-spot-prices', asOf: new Date().toISOString(), - tokenAddress: CBTC_ADDRESS, + tokenAddress: addr, }; } diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index a92e6f5..88d17dc 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -15,6 +15,7 @@ const fiat_lp_to_liquidity_1 = require("../../workflows/fiat-lp-to-liquidity"); const btc_l1_settlement_1 = require("../../workflows/btc-l1-settlement"); const fineract_1 = require("../../adapters/fineract"); const pricing_1 = require("../../adapters/pricing"); +const btc_ledger_config_1 = require("../../btc-ledger-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"); @@ -637,10 +638,21 @@ function createSettlementRouter() { .slice(0, limit); res.json({ count: rows.length, settlements: rows }); }); + router.get('/btc/contracts', (_req, res) => { + if (!(0, auth_1.requireApiKey)(_req, res)) + return; + try { + res.json((0, btc_ledger_config_1.btcLedgerContractsPayload)()); + } + 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; try { + const btcCfg = (0, btc_ledger_config_1.loadBtcLedgerConfig)(); const [balancesHo, balancesO24, quote] = await Promise.all([ (0, fineract_1.fetchGlBalances)(1), (0, fineract_1.fetchGlBalances)(officeId), @@ -657,6 +669,9 @@ function createSettlementRouter() { const settledSats = btcSettlements .filter((r) => r.phase === 'SETTLED') .reduce((sum, r) => sum + (r.btcSettlement?.amountSats ?? 0), 0); + const m2 = parseFloat(balancesO24['2200'] ?? '0'); + const m3 = parseFloat(balancesO24['2300'] ?? '0'); + const headroomUsd = m2 - m3; res.json({ asOf: new Date().toISOString(), gl: { @@ -665,6 +680,16 @@ function createSettlementRouter() { m2Broad2200: balancesO24['2200'] ?? '0', m3Token2300: balancesO24['2300'] ?? '0', }, + headroomUsd: headroomUsd.toFixed(2), + requiredHeadroomUsd: btcCfg.requiredHeadroomUsd, + mint1000BtcAllowed: headroomUsd >= btcCfg.requiredHeadroomUsd, + contracts: { + cbtc: btcCfg.token.address, + lineId: btcCfg.token.lineId, + primaryRecipient: btcCfg.recipients.primaryMintTarget, + dodoPmmIntegration: btcCfg.dodoEvm.pmmIntegration, + pmmPools: btcCfg.pmmPools, + }, btcUsd: 'btcUsdRate' in quote ? { rate: quote.btcUsdRate, source: quote.source, asOf: quote.asOf } : { error: quote.error }, diff --git a/services/settlement-middleware/dist/btc-ledger-config.js b/services/settlement-middleware/dist/btc-ledger-config.js new file mode 100644 index 0000000..6121a3a --- /dev/null +++ b/services/settlement-middleware/dist/btc-ledger-config.js @@ -0,0 +1,91 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loadBtcLedgerConfig = loadBtcLedgerConfig; +exports.btcLedgerContractsPayload = btcLedgerContractsPayload; +exports.clearBtcLedgerConfigCache = clearBtcLedgerConfigCache; +const fs_1 = __importDefault(require("fs")); +const path_1 = __importDefault(require("path")); +let cached = null; +function repoRoot() { + return path_1.default.resolve(__dirname, '../../../'); +} +function configPath() { + const rel = process.env.OMNL_BTC_LEDGER_CONFIG || + process.env.BTC_L1_LEDGER_CONFIG || + 'config/btc-l1-ledger-1000.chain138.v1.json'; + return path_1.default.isAbsolute(rel) ? rel : path_1.default.join(repoRoot(), rel.replace(/^\//, '')); +} +function loadBtcLedgerConfig() { + if (cached) + return cached; + const raw = JSON.parse(fs_1.default.readFileSync(configPath(), 'utf8')); + cached = applyEnvOverrides(raw); + return cached; +} +function envAddr(key, fallback) { + const v = process.env[key]?.trim(); + return v || fallback; +} +/** Env overrides for deployment without editing JSON. */ +function applyEnvOverrides(cfg) { + return { + ...cfg, + token: { + ...cfg.token, + address: envAddr('CBTC_ADDRESS_138', cfg.token.address), + lineId: process.env.OMNL_BTC_LINE_ID?.trim() || cfg.token.lineId, + }, + recipients: { + primaryMintTarget: envAddr('BTC_L1_RECIPIENT_ADDRESS', envAddr('BTC_E2E_RECIPIENT', cfg.recipients.primaryMintTarget)), + existingMintHolder1000: envAddr('BTC_EXISTING_MINT_HOLDER', cfg.recipients.existingMintHolder1000), + }, + oracle: { + ...cfg.oracle, + aggregator: envAddr('AGGREGATOR_ADDRESS', envAddr('ORACLE_AGGREGATOR_ADDRESS', cfg.oracle.aggregator)), + proxy: envAddr('ORACLE_PROXY_ADDRESS', cfg.oracle.proxy), + oraclePriceFeed: envAddr('ORACLE_PRICE_FEED', cfg.oracle.oraclePriceFeed), + reserveSystem: envAddr('RESERVE_SYSTEM', cfg.oracle.reserveSystem), + priceFeedKeeper: envAddr('PRICE_FEED_KEEPER_ADDRESS', cfg.oracle.priceFeedKeeper), + }, + dodoEvm: { + ...cfg.dodoEvm, + pmmIntegration: envAddr('DODO_PMM_INTEGRATION', envAddr('CHAIN_138_DODO_PMM_INTEGRATION', cfg.dodoEvm.pmmIntegration)), + }, + pmmPools: { + cBTC_cUSDT: envAddr('CHAIN138_POOL_CBTC_CUSDT', cfg.pmmPools.cBTC_cUSDT), + cBTC_cUSDC: envAddr('CHAIN138_POOL_CBTC_CUSDC', cfg.pmmPools.cBTC_cUSDC), + cBTC_cXAUC: envAddr('CHAIN138_POOL_CBTC_CXAUC', cfg.pmmPools.cBTC_cXAUC), + }, + }; +} +function btcLedgerContractsPayload() { + const cfg = loadBtcLedgerConfig(); + return { + configPath: configPath(), + chainId: cfg.chainId, + officeId: cfg.officeId, + amountBtc: cfg.amountBtc, + amountSats: cfg.amountSats, + requiredHeadroomUsd: cfg.requiredHeadroomUsd, + token: cfg.token, + recipients: cfg.recipients, + fineractGl: cfg.fineractGl, + oracle: cfg.oracle, + dodoEvm: cfg.dodoEvm, + pmmPools: cfg.pmmPools, + quoteTokens: cfg.quoteTokens, + rpc: cfg.rpc, + explorerLinks: { + cbtc: `${cfg.rpc.explorer}/address/${cfg.token.address}`, + pmmIntegration: `${cfg.rpc.explorer}/address/${cfg.dodoEvm.pmmIntegration}`, + primaryRecipient: `${cfg.rpc.explorer}/address/${cfg.recipients.primaryMintTarget}`, + poolCbtcCusdc: `${cfg.rpc.explorer}/address/${cfg.pmmPools.cBTC_cUSDC}`, + }, + }; +} +function clearBtcLedgerConfigCache() { + 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 d7b51d6..216b7a4 100644 --- a/services/settlement-middleware/dist/workflows/btc-l1-settlement.js +++ b/services/settlement-middleware/dist/workflows/btc-l1-settlement.js @@ -14,6 +14,7 @@ const config_1 = require("../config"); 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 settlement_store_1 = require("../store/settlement-store"); const MIN_CONFIRMATIONS = Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6); /** Env SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE overrides production JSON when set. */ @@ -183,13 +184,15 @@ 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: 'BTC-M2', + lineId: btcLedger.token.lineId, amount: btcAmount, recipient: req.recipientAddress, settlementRef: record.settlementId, dryRun: !allowChainMintExecute(), - symbol: 'cBTC', + symbol: btcLedger.token.symbol, + tokenAddress: btcLedger.token.address, }); advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash }); advance('SETTLED'); diff --git a/services/settlement-middleware/dist/workflows/external-transfer.js b/services/settlement-middleware/dist/workflows/external-transfer.js index 6dbe3db..a95059d 100644 --- a/services/settlement-middleware/dist/workflows/external-transfer.js +++ b/services/settlement-middleware/dist/workflows/external-transfer.js @@ -81,6 +81,7 @@ async function processExternalTransfer(input) { beneficiaryName: req.beneficiaryName, remittanceInfo: req.remittanceInfo, rail: req.rail, + debtorIban: req.debtorIban, }); advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId }); } diff --git a/services/settlement-middleware/dist/workflows/z-bank-m0-m4.js b/services/settlement-middleware/dist/workflows/z-bank-m0-m4.js index 37a66f8..0a9d9b7 100644 --- a/services/settlement-middleware/dist/workflows/z-bank-m0-m4.js +++ b/services/settlement-middleware/dist/workflows/z-bank-m0-m4.js @@ -141,6 +141,7 @@ async function processZBankM0ToM4(input) { beneficiaryName: req.beneficiaryName, remittanceInfo: req.remittanceInfo, rail: req.rail, + debtorIban: req.debtorIban, }); advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId }); } diff --git a/services/settlement-middleware/src/adapters/hybx-production.ts b/services/settlement-middleware/src/adapters/hybx-production.ts index 9b13e4e..3af7656 100644 --- a/services/settlement-middleware/src/adapters/hybx-production.ts +++ b/services/settlement-middleware/src/adapters/hybx-production.ts @@ -1,7 +1,64 @@ import axios from 'axios'; +import { createHmac } from 'crypto'; import { loadConfig } from '../config'; import { loadHybxConfig } from '@dbis/integration-foundation'; +/** RFC1918 / loopback hosts — not reachable from public VPS deploys (e.g. secure.omdnl.org). */ +function isPrivateSidecarHost(url: string): boolean { + try { + const host = new URL(url).hostname.toLowerCase(); + if (host === 'localhost' || host.endsWith('.local')) return true; + if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host)) return true; + const m = /^172\.(\d+)\./.exec(host); + if (m) { + const second = parseInt(m[1], 10); + if (second >= 16 && second <= 31) return true; + } + return false; + } catch { + return false; + } +} + +/** Resolve outbound payment URL — public HYBX RTGS API, not LAN sidecar unless explicitly allowed. */ +export function resolveHybxPaymentsUrl(baseUrl: string): string { + const explicit = process.env.HYBX_PAYMENTS_URL?.trim(); + if (explicit) return explicit.replace(/\/$/, ''); + + const hybxApi = process.env.HYBX_API?.trim().replace(/\/$/, ''); + if (hybxApi) return `${hybxApi}/rtgs/payments`; + + const sidecar = process.env.HYBX_MIFOS_FINERACT_SIDECAR_URL?.trim(); + const allowLan = process.env.HYBX_ALLOW_LAN_SIDECAR === '1'; + if (sidecar && (allowLan || !isPrivateSidecarHost(sidecar))) { + return `${sidecar.replace(/\/$/, '')}/v1/rtgs/payments`; + } + return `${baseUrl.replace(/\/$/, '')}/v1/rtgs/payments`; +} + +function hybxWebhookSecret(): string { + return ( + process.env.HYBX_WEBHOOK_SECRET?.trim() || + process.env.HYBX_SIGNING_SECRET?.trim() || + '' + ); +} + +/** HYBX RTGS API: X-API-Key + X-Timestamp + X-Signature = HMAC-SHA256(secret, raw JSON body). */ +export function buildHybxRtgsHeaders(bodyJson: string, apiKey: string): Record { + const secret = hybxWebhookSecret(); + if (!secret) { + throw new Error('HYBX_WEBHOOK_SECRET required for signed RTGS dispatch'); + } + const timestamp = Math.floor(Date.now() / 1000).toString(); + return { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + 'X-Timestamp': timestamp, + 'X-Signature': createHmac('sha256', secret).update(bodyJson).digest('hex'), + }; +} + /** Production HYBX rail client for real settlement dispatch */ export class HybxProductionRail { private readonly baseUrl: string; @@ -18,7 +75,8 @@ export class HybxProductionRail { throw new Error( 'HYBX production requires SETTLEMENT_ALLOW_HYBX_PRODUCTION=1 or production.allowHybxProduction in config', ); - } this.baseUrl = cfg.baseUrl.replace(/\/$/, ''); + } + this.baseUrl = cfg.baseUrl.replace(/\/$/, ''); this.apiKey = cfg.apiKey; this.clientSecret = cfg.clientSecret; } @@ -32,35 +90,50 @@ export class HybxProductionRail { beneficiaryName: string; remittanceInfo: string; rail: string; + debtorIban?: string; }): Promise<{ paymentId: string; status: string }> { - const sidecar = process.env.HYBX_MIFOS_FINERACT_SIDECAR_URL?.replace(/\/$/, ''); - const url = sidecar ? `${sidecar}/v1/rtgs/payments` : `${this.baseUrl}/v1/payments`; - const { data } = await axios.post( - url, - { - externalId: payload.idempotencyKey, - amount: payload.amount, - currency: payload.currency, - beneficiary: { - iban: payload.creditorIban, - bic: payload.creditorBic, - name: payload.beneficiaryName, - }, - remittanceInformation: payload.remittanceInfo, - rail: payload.rail, + const url = resolveHybxPaymentsUrl(this.baseUrl); + const body: Record = { + externalId: payload.idempotencyKey, + amount: payload.amount, + currency: payload.currency, + beneficiary: { + iban: payload.creditorIban, + bic: payload.creditorBic, + name: payload.beneficiaryName, }, - { - headers: { + remittanceInformation: payload.remittanceInfo, + rail: payload.rail, + }; + if (payload.debtorIban) body.debtorIban = payload.debtorIban; + + const bodyJson = JSON.stringify(body); + const useRtgsSignedApi = Boolean(process.env.HYBX_API?.trim() || process.env.HYBX_PAYMENTS_URL?.trim()); + const headers = useRtgsSignedApi + ? buildHybxRtgsHeaders(bodyJson, this.apiKey) + : { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey, Authorization: `Bearer ${this.clientSecret}`, - }, - timeout: 120000, - }, - ); - return { - paymentId: String(data.paymentId ?? data.id ?? payload.idempotencyKey), - status: String(data.status ?? 'DISPATCHED'), - }; + }; + + if (useRtgsSignedApi && !hybxWebhookSecret()) { + throw new Error('HYBX_WEBHOOK_SECRET required for signed RTGS dispatch (HYBX_API set)'); + } + + try { + const { data } = await axios.post(url, bodyJson, { headers, timeout: 120000 }); + return { + paymentId: String(data.paymentId ?? data.id ?? payload.idempotencyKey), + status: String(data.status ?? 'DISPATCHED'), + }; + } catch (e) { + const ax = e as { response?: { status?: number; data?: unknown }; message?: string }; + const detail = + ax.response?.data != null + ? JSON.stringify(ax.response.data).slice(0, 400) + : ax.message ?? String(e); + throw new Error(`HYBX payment failed (${url}): ${detail}`); + } } } diff --git a/services/settlement-middleware/src/adapters/omnl.ts b/services/settlement-middleware/src/adapters/omnl.ts index 4eb4116..bc2f1a8 100644 --- a/services/settlement-middleware/src/adapters/omnl.ts +++ b/services/settlement-middleware/src/adapters/omnl.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { resolveTokenAggregationBase } from './price-service'; export async function archiveIso20022(payload: { messageType: string; @@ -6,7 +7,7 @@ export async function archiveIso20022(payload: { raw: string; settlementRef: string; }): Promise<{ messageId: string }> { - const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); + const base = resolveTokenAggregationBase(); const key = process.env.OMNL_API_KEY || ''; const headers: Record = { 'Content-Type': 'application/json' }; if (key) headers.Authorization = `Bearer ${key}`; @@ -33,7 +34,7 @@ export async function requestTokenMint(payload: { symbol?: string; tokenAddress?: string; }): Promise<{ txHash?: string; status: string }> { - const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); + const base = resolveTokenAggregationBase(); const key = process.env.OMNL_API_KEY || ''; const headers: Record = { 'Content-Type': 'application/json' }; if (key) headers.Authorization = `Bearer ${key}`; @@ -66,7 +67,7 @@ export async function requestTokenTransfer(payload: { dryRun: boolean; symbol?: string; }): Promise<{ txHash?: string; status: string }> { - const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); + const base = resolveTokenAggregationBase(); const key = process.env.OMNL_API_KEY || ''; const headers: Record = { 'Content-Type': 'application/json' }; if (key) headers.Authorization = `Bearer ${key}`; @@ -117,7 +118,7 @@ export async function requestFiatLpToLiquidity(payload: { settlementRef: string; dryRun: boolean; }): Promise { - const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); + const base = resolveTokenAggregationBase(); const key = process.env.OMNL_API_KEY || ''; const headers: Record = { 'Content-Type': 'application/json' }; if (key) headers.Authorization = `Bearer ${key}`; diff --git a/services/settlement-middleware/src/adapters/price-service.ts b/services/settlement-middleware/src/adapters/price-service.ts new file mode 100644 index 0000000..f9964e7 --- /dev/null +++ b/services/settlement-middleware/src/adapters/price-service.ts @@ -0,0 +1,15 @@ +/** + * Chain 138 spot-price / oracle HTTP base. + * Pricing uses CHAIN138_ORACLE_URL (Python oracle, port 3500) when set; + * falls back to TOKEN_AGGREGATION_URL for gradual cutover. + */ +export function resolveChain138OracleBase(): string { + const oracle = (process.env.CHAIN138_ORACLE_URL || process.env.ORACLE_PUBLISHER_URL || '').replace(/\/$/, ''); + if (oracle) return oracle; + return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3500').replace(/\/$/, ''); +} + +/** token-aggregation base for OMNL mint/transfer/ISO20022 (not spot prices). */ +export function resolveTokenAggregationBase(): string { + return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); +} diff --git a/services/settlement-middleware/src/adapters/pricing.ts b/services/settlement-middleware/src/adapters/pricing.ts index a144b8f..9fec287 100644 --- a/services/settlement-middleware/src/adapters/pricing.ts +++ b/services/settlement-middleware/src/adapters/pricing.ts @@ -1,6 +1,7 @@ import axios from 'axios'; +import { resolveChain138OracleBase } from './price-service'; +import { loadBtcLedgerConfig } from '../btc-ledger-config'; -const CBTC_ADDRESS = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e'; const CHAIN_138 = 138; export type LiveBtcUsdQuote = { @@ -10,8 +11,12 @@ export type LiveBtcUsdQuote = { tokenAddress: string; }; -function tokenAggregationBase(): string { - return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, ''); +function cbtcAddress(): string { + try { + return loadBtcLedgerConfig().token.address.toLowerCase(); + } catch { + return '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e'; + } } /** @@ -19,14 +24,14 @@ function tokenAggregationBase(): string { * production must not fall back to the $90k repo snapshot. */ export async function resolveLiveBtcUsd(): Promise { - const base = tokenAggregationBase(); + const base = resolveChain138OracleBase(); const key = process.env.OMNL_API_KEY || ''; const headers: Record = { Accept: 'application/json' }; if (key) headers.Authorization = `Bearer ${key}`; const url = `${base}/api/v1/prices/metamask/v2/chains/${CHAIN_138}/spot-prices` + - `?tokenAddresses=${CBTC_ADDRESS}&vsCurrency=usd`; + `?tokenAddresses=${cbtcAddress()}&vsCurrency=usd`; let data: unknown; try { @@ -49,13 +54,14 @@ export async function resolveLiveBtcUsd(): Promise { throw err; } const map = (data ?? {}) as Record>; - const row = map[CBTC_ADDRESS.toLowerCase()] ?? map[CBTC_ADDRESS]; + const addr = cbtcAddress(); + const row = map[addr] ?? map[addr.toLowerCase()]; const rate = row?.usd ?? row?.USD; if (!(typeof rate === 'number' && Number.isFinite(rate) && rate > 0)) { throw new Error( 'Live BTC/USD price unresolved — settlement blocked (OMNL_REQUIRE_LIVE_BTC_PRICE). ' + - 'Ensure token-aggregation FX refresh / CoinGecko is reachable.', + 'Ensure chain138-oracle (CHAIN138_ORACLE_URL) or token-aggregation FX refresh is reachable.', ); } @@ -79,8 +85,8 @@ export async function resolveLiveBtcUsd(): Promise { return { btcUsdRate: rate, - source: 'token-aggregation/metamask-spot-prices', + source: 'chain138-oracle/metamask-spot-prices', asOf: new Date().toISOString(), - tokenAddress: CBTC_ADDRESS, + tokenAddress: addr, }; } diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts index 0687edf..d9e98d7 100644 --- a/services/settlement-middleware/src/api/routes/settlement.ts +++ b/services/settlement-middleware/src/api/routes/settlement.ts @@ -29,6 +29,7 @@ import { } from '../../workflows/btc-l1-settlement'; import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract'; import { resolveLiveBtcUsd } from '../../adapters/pricing'; +import { btcLedgerContractsPayload, loadBtcLedgerConfig } from '../../btc-ledger-config'; import { dispatchExternalBankOrExchange, getExternalRailsStatus, @@ -670,9 +671,19 @@ export function createSettlementRouter(): Router { res.json({ count: rows.length, settlements: rows }); }); + router.get('/btc/contracts', (_req, res) => { + if (!requireApiKey(_req, res)) return; + try { + res.json(btcLedgerContractsPayload()); + } 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 { + const btcCfg = loadBtcLedgerConfig(); const [balancesHo, balancesO24, quote] = await Promise.all([ fetchGlBalances(1), fetchGlBalances(officeId), @@ -690,6 +701,10 @@ export function createSettlementRouter(): Router { .filter((r) => r.phase === 'SETTLED') .reduce((sum, r) => sum + (r.btcSettlement?.amountSats ?? 0), 0); + const m2 = parseFloat(balancesO24['2200'] ?? '0'); + const m3 = parseFloat(balancesO24['2300'] ?? '0'); + const headroomUsd = m2 - m3; + res.json({ asOf: new Date().toISOString(), gl: { @@ -698,6 +713,16 @@ export function createSettlementRouter(): Router { m2Broad2200: balancesO24['2200'] ?? '0', m3Token2300: balancesO24['2300'] ?? '0', }, + headroomUsd: headroomUsd.toFixed(2), + requiredHeadroomUsd: btcCfg.requiredHeadroomUsd, + mint1000BtcAllowed: headroomUsd >= btcCfg.requiredHeadroomUsd, + contracts: { + cbtc: btcCfg.token.address, + lineId: btcCfg.token.lineId, + primaryRecipient: btcCfg.recipients.primaryMintTarget, + dodoPmmIntegration: btcCfg.dodoEvm.pmmIntegration, + pmmPools: btcCfg.pmmPools, + }, btcUsd: 'btcUsdRate' in quote ? { rate: quote.btcUsdRate, source: quote.source, asOf: quote.asOf } diff --git a/services/settlement-middleware/src/btc-ledger-config.ts b/services/settlement-middleware/src/btc-ledger-config.ts new file mode 100644 index 0000000..6780477 --- /dev/null +++ b/services/settlement-middleware/src/btc-ledger-config.ts @@ -0,0 +1,128 @@ +import fs from 'fs'; +import path from 'path'; + +export type BtcLedgerConfig = { + version: string; + chainId: number; + officeId: number; + amountBtc: number; + amountSats: number; + btcUsdPolicyRate: number; + requiredHeadroomUsd: number; + token: { + symbol: string; + address: string; + decimals: number; + omnlLine: string; + lineId: string; + }; + recipients: { + primaryMintTarget: string; + existingMintHolder1000: string; + }; + fineractGl: Record; + oracle: Record; + dodoEvm: Record; + pmmPools: Record; + quoteTokens: Record; + rpc: { envVar: string; public: string; explorer: string }; + settlementApi: Record; +}; + +let cached: BtcLedgerConfig | null = null; + +function repoRoot(): string { + return path.resolve(__dirname, '../../../'); +} + +function configPath(): string { + const rel = + process.env.OMNL_BTC_LEDGER_CONFIG || + process.env.BTC_L1_LEDGER_CONFIG || + 'config/btc-l1-ledger-1000.chain138.v1.json'; + return path.isAbsolute(rel) ? rel : path.join(repoRoot(), rel.replace(/^\//, '')); +} + +export function loadBtcLedgerConfig(): BtcLedgerConfig { + if (cached) return cached; + const raw = JSON.parse(fs.readFileSync(configPath(), 'utf8')) as BtcLedgerConfig; + cached = applyEnvOverrides(raw); + return cached; +} + +function envAddr(key: string, fallback: string): string { + const v = process.env[key]?.trim(); + return v || fallback; +} + +/** Env overrides for deployment without editing JSON. */ +function applyEnvOverrides(cfg: BtcLedgerConfig): BtcLedgerConfig { + return { + ...cfg, + token: { + ...cfg.token, + address: envAddr('CBTC_ADDRESS_138', cfg.token.address), + lineId: process.env.OMNL_BTC_LINE_ID?.trim() || cfg.token.lineId, + }, + recipients: { + primaryMintTarget: envAddr( + 'BTC_L1_RECIPIENT_ADDRESS', + envAddr('BTC_E2E_RECIPIENT', cfg.recipients.primaryMintTarget), + ), + existingMintHolder1000: envAddr( + 'BTC_EXISTING_MINT_HOLDER', + cfg.recipients.existingMintHolder1000, + ), + }, + oracle: { + ...cfg.oracle, + aggregator: envAddr('AGGREGATOR_ADDRESS', envAddr('ORACLE_AGGREGATOR_ADDRESS', cfg.oracle.aggregator)), + proxy: envAddr('ORACLE_PROXY_ADDRESS', cfg.oracle.proxy), + oraclePriceFeed: envAddr('ORACLE_PRICE_FEED', cfg.oracle.oraclePriceFeed), + reserveSystem: envAddr('RESERVE_SYSTEM', cfg.oracle.reserveSystem), + priceFeedKeeper: envAddr('PRICE_FEED_KEEPER_ADDRESS', cfg.oracle.priceFeedKeeper), + }, + dodoEvm: { + ...cfg.dodoEvm, + pmmIntegration: envAddr( + 'DODO_PMM_INTEGRATION', + envAddr('CHAIN_138_DODO_PMM_INTEGRATION', cfg.dodoEvm.pmmIntegration), + ), + }, + pmmPools: { + cBTC_cUSDT: envAddr('CHAIN138_POOL_CBTC_CUSDT', cfg.pmmPools.cBTC_cUSDT), + cBTC_cUSDC: envAddr('CHAIN138_POOL_CBTC_CUSDC', cfg.pmmPools.cBTC_cUSDC), + cBTC_cXAUC: envAddr('CHAIN138_POOL_CBTC_CXAUC', cfg.pmmPools.cBTC_cXAUC), + }, + }; +} + +export function btcLedgerContractsPayload(): Record { + const cfg = loadBtcLedgerConfig(); + return { + configPath: configPath(), + chainId: cfg.chainId, + officeId: cfg.officeId, + amountBtc: cfg.amountBtc, + amountSats: cfg.amountSats, + requiredHeadroomUsd: cfg.requiredHeadroomUsd, + token: cfg.token, + recipients: cfg.recipients, + fineractGl: cfg.fineractGl, + oracle: cfg.oracle, + dodoEvm: cfg.dodoEvm, + pmmPools: cfg.pmmPools, + quoteTokens: cfg.quoteTokens, + rpc: cfg.rpc, + explorerLinks: { + cbtc: `${cfg.rpc.explorer}/address/${cfg.token.address}`, + pmmIntegration: `${cfg.rpc.explorer}/address/${cfg.dodoEvm.pmmIntegration}`, + primaryRecipient: `${cfg.rpc.explorer}/address/${cfg.recipients.primaryMintTarget}`, + poolCbtcCusdc: `${cfg.rpc.explorer}/address/${cfg.pmmPools.cBTC_cUSDC}`, + }, + }; +} + +export function clearBtcLedgerConfigCache(): void { + cached = null; +} diff --git a/services/settlement-middleware/src/config.ts b/services/settlement-middleware/src/config.ts index 155da7f..ea84b0b 100644 --- a/services/settlement-middleware/src/config.ts +++ b/services/settlement-middleware/src/config.ts @@ -42,7 +42,7 @@ export type MiddlewareConfig = { defaultCurrency: string; rails: { swift: { enabled: boolean; listenerUrl: string }; - hybx: { enabled: boolean; sidecarUrl: string }; + hybx: { enabled: boolean; sidecarUrl?: string; publicBaseUrl?: string }; chain138: { enabled: boolean; rpcEnv: string; defaultLineId: string }; multiChain?: { enabled: boolean; diff --git a/services/settlement-middleware/src/workflows/btc-l1-settlement.ts b/services/settlement-middleware/src/workflows/btc-l1-settlement.ts index 4470727..29527e1 100644 --- a/services/settlement-middleware/src/workflows/btc-l1-settlement.ts +++ b/services/settlement-middleware/src/workflows/btc-l1-settlement.ts @@ -21,6 +21,7 @@ import { } from '../adapters/fineract'; import { requestTokenMint } from '../adapters/omnl'; import { resolveLiveBtcUsd } from '../adapters/pricing'; +import { loadBtcLedgerConfig } from '../btc-ledger-config'; import { settlementStore } from '../store/settlement-store'; const MIN_CONFIRMATIONS = Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6); @@ -214,13 +215,15 @@ export async function processBtcL1Settlement( advance('ISO20022_ARCHIVED'); // 3) Mint cBTC in satoshi-accurate BTC decimal amount + const btcLedger = loadBtcLedgerConfig(); const mint = await requestTokenMint({ - lineId: 'BTC-M2', + lineId: btcLedger.token.lineId, amount: btcAmount, recipient: req.recipientAddress, settlementRef: record.settlementId, dryRun: !allowChainMintExecute(), - symbol: 'cBTC', + symbol: btcLedger.token.symbol, + tokenAddress: btcLedger.token.address, }); advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash }); advance('SETTLED'); diff --git a/services/settlement-middleware/src/workflows/external-transfer.ts b/services/settlement-middleware/src/workflows/external-transfer.ts index 699659e..5a67ed8 100644 --- a/services/settlement-middleware/src/workflows/external-transfer.ts +++ b/services/settlement-middleware/src/workflows/external-transfer.ts @@ -97,6 +97,7 @@ export async function processExternalTransfer( beneficiaryName: req.beneficiaryName, remittanceInfo: req.remittanceInfo, rail: req.rail, + debtorIban: req.debtorIban, }); advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId }); } else { diff --git a/services/settlement-middleware/src/workflows/z-bank-m0-m4.ts b/services/settlement-middleware/src/workflows/z-bank-m0-m4.ts index 38eee5f..46765ae 100644 --- a/services/settlement-middleware/src/workflows/z-bank-m0-m4.ts +++ b/services/settlement-middleware/src/workflows/z-bank-m0-m4.ts @@ -168,6 +168,7 @@ export async function processZBankM0ToM4(input: ZBankM0M4Request): Promise