feat(settlement): wire real Chain 138 contracts for 1,000 BTC ledger
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 52s
CI/CD Pipeline / Security Scanning (push) Successful in 2m40s
CI/CD Pipeline / Lint and Format (push) Failing after 49s
CI/CD Pipeline / Terraform Validation (push) Failing after 24s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 27s
Deploy ChainID 138 / Deploy ChainID 138 (push) Has been cancelled
Validation / validate-genesis (push) Has been cancelled
Validation / validate-terraform (push) Has been cancelled
Validation / validate-kubernetes (push) Has been cancelled
Verify Deployment / Verify Deployment (push) Has been cancelled
Validation / validate-smart-contracts (push) Failing after 14s
Validation / validate-security (push) Failing after 1m20s
Validation / validate-documentation (push) Failing after 18s

Add canonical btc-l1-ledger config, /btc/contracts API, Python chain138-oracle
service, and production deploy scripts so secure.omdnl.org can serve live cBTC,
DODO, and PMM pool addresses with headroom-aware mint gating.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-21 02:26:20 -07:00
parent 4e04d9ca51
commit 54ca9bea3b
54 changed files with 2895 additions and 82 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ set -u
set +a
FRAG="$(mktemp)"
grep -E '^(OMNL_FINERACT_|OMNL_PUBLIC_MONEY_SUPPLY=|OMNL_REQUIRE_API_KEY=|OMNL_CUSTOMER_SECURITY_PRODUCTION=|OMNL_CUSTOMER_API_KEYS=|OFFICE24_OPENING_M1_USD=|OMNL_PORTAL_INTERNAL_SECRET=|JWT_SECRET=|DATABASE_URL=|RPC_URL_138=|CHAIN_138_RPC_URL=|SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=|SETTLEMENT_ALLOW_HYBX_PRODUCTION=|OMNL_ALLOW_CHAIN_MINT_EXECUTE=|OMNL_MINT_OPERATOR_PRIVATE_KEY=|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');

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
"""Chain 138 Python oracle — prices, liquidity, on-chain feeds, keeper mesh."""
__version__ = "1.0.0"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(/\/$/, '');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, string> = { '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<string, string> = { '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<string, string> = { 'Content-Type': 'application/json' };
if (key) headers.Authorization = `Bearer ${key}`;
@@ -117,7 +118,7 @@ export async function requestFiatLpToLiquidity(payload: {
settlementRef: string;
dryRun: boolean;
}): Promise<FiatLpLiquidityAdapterResult> {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const base = resolveTokenAggregationBase();
const key = process.env.OMNL_API_KEY || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (key) headers.Authorization = `Bearer ${key}`;

View File

@@ -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(/\/$/, '');
}

View File

@@ -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<LiveBtcUsdQuote> {
const base = tokenAggregationBase();
const base = resolveChain138OracleBase();
const key = process.env.OMNL_API_KEY || '';
const headers: Record<string, string> = { Accept: 'application/json' };
if (key) headers.Authorization = `Bearer ${key}`;
const url =
`${base}/api/v1/prices/metamask/v2/chains/${CHAIN_138}/spot-prices` +
`?tokenAddresses=${CBTC_ADDRESS}&vsCurrency=usd`;
`?tokenAddresses=${cbtcAddress()}&vsCurrency=usd`;
let data: unknown;
try {
@@ -49,13 +54,14 @@ export async function resolveLiveBtcUsd(): Promise<LiveBtcUsdQuote> {
throw err;
}
const map = (data ?? {}) as Record<string, Record<string, number>>;
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<LiveBtcUsdQuote> {
return {
btcUsdRate: rate,
source: 'token-aggregation/metamask-spot-prices',
source: 'chain138-oracle/metamask-spot-prices',
asOf: new Date().toISOString(),
tokenAddress: CBTC_ADDRESS,
tokenAddress: addr,
};
}

View File

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

View File

@@ -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<string, { glCode: string; officeId: number; memo?: string }>;
oracle: Record<string, string>;
dodoEvm: Record<string, string>;
pmmPools: Record<string, string>;
quoteTokens: Record<string, string>;
rpc: { envVar: string; public: string; explorer: string };
settlementApi: Record<string, string>;
};
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<string, unknown> {
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;
}

View File

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

View File

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

View File

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

View File

@@ -168,6 +168,7 @@ export async function processZBankM0ToM4(input: ZBankM0M4Request): Promise<Settl
beneficiaryName: req.beneficiaryName,
remittanceInfo: req.remittanceInfo,
rail: req.rail,
debtorIban: req.debtorIban,
});
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
} else {