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
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:
82
config/btc-l1-ledger-1000.chain138.v1.json
Normal file
82
config/btc-l1-ledger-1000.chain138.v1.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
30
config/offices/btc-m2-headroom-adjustment-20260720.v1.json
Normal file
30
config/offices/btc-m2-headroom-adjustment-20260720.v1.json
Normal 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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
172
scripts/deployment/cutover-chain138-oracle-python.sh
Normal file
172
scripts/deployment/cutover-chain138-oracle-python.sh
Normal 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"
|
||||
134
scripts/deployment/deploy-btc-ledger-production.sh
Normal file
134
scripts/deployment/deploy-btc-ledger-production.sh
Normal 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"
|
||||
@@ -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:-}" \
|
||||
|
||||
114
scripts/deployment/post-btc-m2-headroom-adjustment.mjs
Normal file
114
scripts/deployment/post-btc-m2-headroom-adjustment.mjs
Normal 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));
|
||||
@@ -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');
|
||||
|
||||
31
scripts/deployment/smoke-chain138-oracle-pricing.mjs
Normal file
31
scripts/deployment/smoke-chain138-oracle-pricing.mjs
Normal 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);
|
||||
});
|
||||
43
scripts/deployment/sync-btc-ledger-contract-env.mjs
Normal file
43
scripts/deployment/sync-btc-ledger-contract-env.mjs
Normal 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'));
|
||||
}
|
||||
19
services/chain138-oracle/Dockerfile
Normal file
19
services/chain138-oracle/Dockerfile
Normal 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"]
|
||||
149
services/chain138-oracle/README.md
Normal file
149
services/chain138-oracle/README.md
Normal 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`
|
||||
3
services/chain138-oracle/chain138_oracle/__init__.py
Normal file
3
services/chain138-oracle/chain138_oracle/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Chain 138 Python oracle — prices, liquidity, on-chain feeds, keeper mesh."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
104
services/chain138-oracle/chain138_oracle/__main__.py
Normal file
104
services/chain138-oracle/chain138_oracle/__main__.py
Normal 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())
|
||||
157
services/chain138-oracle/chain138_oracle/api.py
Normal file
157
services/chain138-oracle/chain138_oracle/api.py
Normal 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)
|
||||
169
services/chain138-oracle/chain138_oracle/canonical_price.py
Normal file
169
services/chain138-oracle/chain138_oracle/canonical_price.py
Normal 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
|
||||
108
services/chain138-oracle/chain138_oracle/config.py
Normal file
108
services/chain138-oracle/chain138_oracle/config.py
Normal 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
|
||||
155
services/chain138-oracle/chain138_oracle/fx_refresh.py
Normal file
155
services/chain138-oracle/chain138_oracle/fx_refresh.py
Normal 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))
|
||||
90
services/chain138-oracle/chain138_oracle/keeper.py
Normal file
90
services/chain138-oracle/chain138_oracle/keeper.py
Normal 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)
|
||||
144
services/chain138-oracle/chain138_oracle/liquidity.py
Normal file
144
services/chain138-oracle/chain138_oracle/liquidity.py
Normal 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
|
||||
56
services/chain138-oracle/chain138_oracle/mesh.py
Normal file
56
services/chain138-oracle/chain138_oracle/mesh.py
Normal 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))
|
||||
71
services/chain138-oracle/chain138_oracle/onchain_eth.py
Normal file
71
services/chain138-oracle/chain138_oracle/onchain_eth.py
Normal 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()
|
||||
148
services/chain138-oracle/chain138_oracle/onchain_oracle.py
Normal file
148
services/chain138-oracle/chain138_oracle/onchain_oracle.py
Normal 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")
|
||||
34
services/chain138-oracle/chain138_oracle/reference_cache.py
Normal file
34
services/chain138-oracle/chain138_oracle/reference_cache.py
Normal 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}
|
||||
91
services/chain138-oracle/chain138_oracle/registry.py
Normal file
91
services/chain138-oracle/chain138_oracle/registry.py
Normal 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
|
||||
72
services/chain138-oracle/chain138_oracle/spot_price.py
Normal file
72
services/chain138-oracle/chain138_oracle/spot_price.py
Normal 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))
|
||||
6
services/chain138-oracle/requirements.txt
Normal file
6
services/chain138-oracle/requirements.txt
Normal 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
|
||||
7
services/chain138-oracle/scripts/validate_coverage.py
Normal file
7
services/chain138-oracle/scripts/validate_coverage.py
Normal 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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
19
services/settlement-middleware/dist/adapters/price-service.js
vendored
Normal file
19
services/settlement-middleware/dist/adapters/price-service.js
vendored
Normal 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(/\/$/, '');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
91
services/settlement-middleware/dist/btc-ledger-config.js
vendored
Normal file
91
services/settlement-middleware/dist/btc-ledger-config.js
vendored
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
15
services/settlement-middleware/src/adapters/price-service.ts
Normal file
15
services/settlement-middleware/src/adapters/price-service.ts
Normal 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(/\/$/, '');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
128
services/settlement-middleware/src/btc-ledger-config.ts
Normal file
128
services/settlement-middleware/src/btc-ledger-config.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user