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

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

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

View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
COPY services/chain138-oracle/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY services/chain138-oracle/chain138_oracle /app/chain138_oracle
COPY config /app/config
ENV PYTHONPATH=/app
ENV CHAIN138_ORACLE_PORT=3500
EXPOSE 3500
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:3500/health')"
CMD ["python", "-m", "chain138_oracle", "serve"]

View File

@@ -0,0 +1,149 @@
# Chain 138 Python Oracle
Unified Python replacement for the Chain 138 oracle stack:
- **Off-chain pricing** (Frankfurter FX + CoinGecko + on-chain ETH/USD aggregator)
- **MetaMask-compatible spot price API** (drop-in for token-aggregation routes)
- **PMM liquidity** (8 canonical DODO pools via `DODOPMMIntegration`)
- **On-chain oracle push** (replaces `scripts/update-oracle-price.sh`)
- **Keeper mesh** (replaces `scripts/reserve/keeper-service.js` + `pmm-mesh-6s-automation.sh`)
Token coverage is driven by `config/omnl-m2-token-registry.v1.json` (all M2 symbols) plus `config/chain138-pmm-pools.json` (PMM pool tokens).
## Install
```bash
cd services/chain138-oracle
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
```
Load repo root `.env` (RPC, CoinGecko, keeper/oracle keys).
## Commands
| Command | Replaces | Purpose |
|---------|----------|---------|
| `python -m chain138_oracle serve` | token-aggregation price routes + oracle-publisher | HTTP API on port **3500** (override `CHAIN138_ORACLE_PORT`) |
| `python -m chain138_oracle mesh` | `pmm-mesh-6s-automation.sh` | 6s oracle + keeper + PMM TVL loop |
| `python -m chain138_oracle keeper` | `keeper-service.js` | Keeper-only loop |
| `python -m chain138_oracle refresh-fx` | `iso4217-fx-refresh.ts` | Refresh FX/crypto reference rates |
| `python -m chain138_oracle update-oracle` | `update-oracle-price.sh` | Push ETH/USD to aggregator |
| `python -m chain138_oracle liquidity` | `chain138-dodo-liquidity.ts` | PMM pool TVL snapshot |
| `python -m chain138_oracle validate` | — | Fail if any registry token lacks USD price |
## API endpoints
| Endpoint | Description |
|----------|-------------|
| `GET /health` | Service + on-chain ETH/USD status |
| `GET /api/v1/prices/metamask/v2/chains/138/spot-prices?tokenAddresses=0x…` | MetaMask Codefi-compatible spot prices |
| `GET /api/v1/oracle/tokens` | All registry tokens with price + PMM liquidity USD |
| `GET /api/v1/oracle/liquidity/pools` | Canonical PMM pool reserves and TVL |
| `GET /api/v1/oracle/reference-rates` | Live FX/crypto cache + contract addresses |
## Settlement / token-aggregation integration
Point **spot prices** at the Python oracle; keep **OMNL mint/transfer** on token-aggregation:
```env
CHAIN138_ORACLE_URL=http://127.0.0.1:3500
TOKEN_AGGREGATION_URL=http://127.0.0.1:3000
```
Settlement middleware reads `CHAIN138_ORACLE_URL` for live BTC/USD (`resolveLiveBtcUsd`).
### Production cutover (one command)
On the Chain 138 / OMNL host (Linux, systemd):
```bash
chmod +x scripts/deployment/cutover-chain138-oracle-python.sh
sudo RESTART_SETTLEMENT=1 ./scripts/deployment/cutover-chain138-oracle-python.sh
```
Dry-run first:
```bash
./scripts/deployment/cutover-chain138-oracle-python.sh --dry-run
```
This script:
1. Stops legacy `pmm-mesh-6s-automation.sh`, `keeper-service.js`, and old systemd units
2. Installs Python venv + validates all 37 M2 tokens
3. Sets `CHAIN138_ORACLE_URL` in `.env`
4. Enables `chain138-oracle-api.service` + `chain138-oracle-mesh.service`
5. Smoke-tests cBTC spot price and restarts settlement-middleware
Smoke test only:
```bash
CHAIN138_ORACLE_URL=http://127.0.0.1:3500 node scripts/deployment/smoke-chain138-oracle-pricing.mjs
```
Docker Compose (includes `chain138-oracle` on port 3500):
```bash
docker compose -f docker-compose.omnl-production.yml up -d chain138-oracle settlement-middleware
```
## Required environment
| Variable | Purpose |
|----------|---------|
| `RPC_URL_138` | Chain 138 JSON-RPC |
| `COINGECKO_API_KEY` | FX/crypto refresh (optional but recommended) |
| `PRIVATE_KEY` / `DEPLOYER_PRIVATE_KEY` | On-chain aggregator transmitter |
| `KEEPER_PRIVATE_KEY` | `PriceFeedKeeper.performUpkeep` |
| `PRICE_FEED_KEEPER_ADDRESS` | Keeper contract (default in code) |
| `AGGREGATOR_ADDRESS` | ETH/USD aggregator |
| `OMNL_REQUIRE_LIVE_BTC_PRICE=1` | Block repo BTC fallback (production) |
## systemd example
```ini
[Unit]
Description=Chain 138 Python Oracle Mesh
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/smom-dbis-138/services/chain138-oracle
EnvironmentFile=/opt/smom-dbis-138/.env
ExecStart=/opt/smom-dbis-138/services/chain138-oracle/.venv/bin/python -m chain138_oracle mesh
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
## Coverage validation
```bash
python -m chain138_oracle validate
# or
python scripts/validate_coverage.py
```
Exits **0** when every token in `omnl-m2-token-registry.v1.json` resolves a positive USD price.
## Architecture
```mermaid
flowchart LR
CG[CoinGecko] --> FX[fx_refresh.py]
FF[Frankfurter] --> FX
FX --> CACHE[reference_cache]
AGG[On-chain Aggregator] --> ETH[onchain_eth.py]
ETH --> CACHE
CACHE --> CANON[canonical_price.py]
CANON --> API[FastAPI api.py]
CANON --> LIQ[liquidity.py]
PMM[DODO PMM Integration] --> LIQ
MESH[mesh.py] --> ORA[onchain_oracle.py]
MESH --> KEEP[keeper.py]
```
See also: `docs/integration/ORACLE_AND_KEEPER_CHAIN138.md`

View File

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

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from .config import load_dotenv
from .fx_refresh import refresh_fx_rates
from .keeper import keeper_loop
from .mesh import mesh_loop
from .onchain_oracle import fetch_eth_usd_market, update_eth_usd_oracle
from .registry import load_registry
from . import canonical_price
from .liquidity import fetch_all_pool_liquidity
def cmd_validate() -> int:
load_dotenv()
asyncio.run(refresh_fx_rates())
from .onchain_eth import refresh_live_reference_prices
refresh_live_reference_prices()
tokens = load_registry()
missing = []
priced = []
for spec in tokens:
res = canonical_price.resolve_for_address(spec)
if res.price_usd and res.price_usd > 0:
priced.append(spec.symbol)
else:
missing.append({"symbol": spec.symbol, "address": spec.address, "reference": res.reference_symbol})
print(json.dumps({"priced": len(priced), "missing": missing, "total": len(tokens)}, indent=2))
return 1 if missing else 0
def cmd_refresh_fx() -> int:
load_dotenv()
rates = asyncio.run(refresh_fx_rates())
print(json.dumps(rates, indent=2))
return 0
async def cmd_update_oracle() -> int:
load_dotenv()
eth = await fetch_eth_usd_market()
result = update_eth_usd_oracle(eth)
print(json.dumps({"ethUsd": eth, "skipped": result.skipped, "txHash": result.tx_hash, "reason": result.reason}, indent=2))
return 0
def cmd_liquidity() -> int:
load_dotenv()
asyncio.run(refresh_fx_rates())
pools = fetch_all_pool_liquidity()
payload = {
"poolCount": len(pools),
"totalLiquidityUsd": sum(p.total_liquidity_usd for p in pools),
"pools": [p.__dict__ for p in pools],
}
print(json.dumps(payload, indent=2))
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Chain 138 Python oracle system")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("serve", help="Run FastAPI oracle + MetaMask price API")
sub.add_parser("mesh", help="Run 6s PMM/oracle/keeper mesh loop")
sub.add_parser("keeper", help="Run keeper performUpkeep loop only")
sub.add_parser("refresh-fx", help="Refresh Frankfurter + CoinGecko reference rates")
sub.add_parser("update-oracle", help="Push ETH/USD to on-chain aggregator")
sub.add_parser("liquidity", help="Print PMM pool liquidity snapshot")
sub.add_parser("validate", help="Validate all M2 registry tokens have USD prices")
args = parser.parse_args(argv)
load_dotenv()
if args.command == "serve":
from .api import run_server
run_server()
return 0
if args.command == "mesh":
asyncio.run(mesh_loop())
return 0
if args.command == "keeper":
asyncio.run(keeper_loop())
return 0
if args.command == "refresh-fx":
return cmd_refresh_fx()
if args.command == "update-oracle":
return asyncio.run(cmd_update_oracle())
if args.command == "liquidity":
return cmd_liquidity()
if args.command == "validate":
return cmd_validate()
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
import asyncio
from typing import Any
from fastapi import FastAPI, HTTPException, Query
from . import reference_cache
from .config import addresses, api_port, load_dotenv
from .fx_refresh import ensure_fx_primed, refresh_fx_rates
from .liquidity import fetch_all_pool_liquidity, liquidity_by_token
from .onchain_eth import get_eth_usd_on_chain, refresh_live_reference_prices
from .registry import CHAIN_ID, load_registry, merge_pmm_tokens, load_pmm_token_map
from .spot_price import resolve_spot_price
from . import canonical_price
load_dotenv()
app = FastAPI(
title="Chain 138 Oracle (Python)",
version="1.0.0",
description="Python oracle for Chain 138 — MetaMask spot prices, token coverage, PMM liquidity, on-chain feeds.",
)
def _parse_addresses(raw: str | None) -> list[str]:
if not raw:
return []
out: list[str] = []
seen: set[str] = set()
for part in raw.split(","):
addr = part.strip().lower()
if len(addr) == 42 and addr.startswith("0x") and addr not in seen:
seen.add(addr)
out.append(addr)
if len(out) >= 120:
break
return out
@app.get("/health")
async def health() -> dict[str, Any]:
eth = get_eth_usd_on_chain()
return {
"status": "ok",
"chainId": CHAIN_ID,
"service": "chain138-oracle-python",
"ethUsdOnChain": eth.price_usd if eth else None,
"ethUsdSource": eth.source if eth else None,
}
@app.get("/api/v1/prices/metamask/v2/chains/{chain_id}/spot-prices")
async def metamask_spot_prices_v2(
chain_id: int,
tokenAddresses: str = Query(..., alias="tokenAddresses"),
vsCurrency: str = Query("usd", alias="vsCurrency"),
) -> dict[str, dict[str, float]]:
if chain_id != CHAIN_ID:
raise HTTPException(400, "Only chain 138 is supported by this oracle service")
addresses_list = _parse_addresses(tokenAddresses)
if not addresses_list:
raise HTTPException(400, "tokenAddresses query parameter is required")
refresh_live_reference_prices()
await ensure_fx_primed()
vs = vsCurrency.lower() or "usd"
out: dict[str, dict[str, float]] = {}
for addr in addresses_list:
spot = await resolve_spot_price(chain_id, addr)
if spot.price_usd and spot.price_usd > 0:
out[addr] = {vs: spot.price_usd}
return out
@app.get("/api/v1/prices/metamask/v1/chains/{chain_id}/spot-prices/{token_address}")
async def metamask_spot_price_v1(chain_id: int, token_address: str, vsCurrency: str = Query("usd")) -> dict[str, Any]:
if chain_id != CHAIN_ID:
raise HTTPException(400, "Only chain 138 is supported")
refresh_live_reference_prices()
await ensure_fx_primed()
spot = await resolve_spot_price(chain_id, token_address)
if not spot.price_usd:
raise HTTPException(404, "Price unavailable")
return {
"tokenAddress": token_address.lower(),
"vsCurrency": vsCurrency.lower(),
"price": spot.price_usd,
"symbol": spot.symbol,
"source": spot.source,
}
@app.get("/api/v1/oracle/tokens")
async def all_token_prices() -> dict[str, Any]:
await ensure_fx_primed()
refresh_live_reference_prices()
tokens = load_registry()
rows = []
unresolved = []
for spec in tokens:
resolution = canonical_price.resolve_for_address(spec)
rows.append(
{
"symbol": spec.symbol,
"address": spec.address,
"decimals": spec.decimals,
"currencyCode": spec.currency_code,
"omnlLine": spec.omnl_line,
"priceUsd": resolution.price_usd,
"referenceSymbol": resolution.reference_symbol,
"source": resolution.source,
}
)
if not resolution.price_usd:
unresolved.append(spec.symbol)
liq = liquidity_by_token()
for row in rows:
row["liquidityUsd"] = liq.get(row["address"], 0.0)
return {
"chainId": CHAIN_ID,
"tokenCount": len(rows),
"unresolvedSymbols": unresolved,
"referenceRates": reference_cache.snapshot(),
"tokens": rows,
}
@app.get("/api/v1/oracle/liquidity/pools")
async def pool_liquidity() -> dict[str, Any]:
await ensure_fx_primed()
refresh_live_reference_prices()
pools = fetch_all_pool_liquidity()
return {
"chainId": CHAIN_ID,
"poolCount": len(pools),
"totalLiquidityUsd": sum(p.total_liquidity_usd for p in pools),
"pools": [p.__dict__ for p in pools],
}
@app.get("/api/v1/oracle/reference-rates")
async def reference_rates() -> dict[str, Any]:
await refresh_fx_rates()
eth = get_eth_usd_on_chain()
return {
"chainId": CHAIN_ID,
"onChainEthUsd": eth.__dict__ if eth else None,
"rates": reference_cache.snapshot(),
"contracts": addresses().__dict__,
}
def run_server() -> None:
import uvicorn
uvicorn.run("chain138_oracle.api:app", host="0.0.0.0", port=api_port(), reload=False)

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import Optional
from . import reference_cache as cache
from .registry import TokenSpec
REPO_FALLBACK_PRICE_USD: dict[str, float] = {
"USD": 1.0,
"EUR": 1.1780,
"GBP": 1.3550353712543854,
"AUD": 0.7136366390016357,
"CAD": 0.7255928549430243,
"CHF": 1.2776572668112798,
"JPY": 0.006285683794888213,
"XAU": 5163.3401260328355,
"ETH": 1680.0,
"BTC": 90000.0,
"LINK": 8.0,
"BNB": 610.0,
"POL": 0.78,
"AVAX": 48.0,
"CELO": 0.72,
"CRO": 0.14,
"XDAI": 1.0,
"WEMIX": 0.5,
"LIPMG": 1100.0,
"LIBMG1": 4.25,
"LIBMG2": 18.5,
"LIBMG3": 0.12,
}
@dataclass(frozen=True)
class PriceResolution:
price_usd: Optional[float]
reference_symbol: Optional[str]
source: str
def eth_oracle_drift_bps(indexer_price: float, oracle_price: float) -> float:
if oracle_price <= 0 or indexer_price <= 0:
return 0.0
return abs(indexer_price - oracle_price) / oracle_price * 10000
def read_drift_bps() -> int:
raw = os.environ.get("TOKEN_AGG_CHAIN138_ETH_ORACLE_DRIFT_BPS", "300")
try:
n = int(raw)
return max(0, n)
except ValueError:
return 300
def _read_env_price(keys: list[str]) -> Optional[float]:
for key in keys:
raw = os.environ.get(key, "").strip()
if not raw:
continue
try:
val = float(raw)
if val > 0:
return val
except ValueError:
continue
return None
def resolve_reference_symbol(spec: TokenSpec) -> Optional[str]:
symbol = spec.symbol.upper()
currency = spec.currency_code.upper()
if symbol in {"WETH", "WETH9", "WETH10", "CETH", "CETHL2", "CWETH", "CWETHL2"}:
return "ETH"
if symbol in {"CBTC", "CWBTC"}:
return "BTC"
if symbol in {"CXAUC", "CXAUT", "CAXAUC", "CAXAUT", "CWAXAUC", "CWAXAUT", "LIXAU"}:
return "XAU"
if symbol == "LIPMG":
return "LIPMG"
if symbol == "LIBMG1":
return "LIBMG1"
if symbol == "LIBMG2":
return "LIBMG2"
if symbol == "LIBMG3":
return "LIBMG3"
if currency:
return currency
return None
def _env_keys(reference: str) -> list[str]:
ref = reference.upper()
if ref == "XAU":
return [
"CHAIN138_CANONICAL_PRICE_USD_XAU",
"CANONICAL_PRICE_USD_XAU",
"XAU_SPOT_USD",
"GOLD_USD_PRICE",
]
if ref == "ETH":
return [
"CHAIN138_CANONICAL_PRICE_USD_ETH",
"CANONICAL_PRICE_USD_ETH",
"ETH_PRICE_USD",
"CHAIN138_D3_PILOT_WETH_USD",
]
return [
f"CHAIN138_CANONICAL_PRICE_USD_{ref}",
f"CANONICAL_PRICE_USD_{ref}",
f"{ref}_PRICE_USD",
]
def resolve_for_spec(spec: TokenSpec) -> PriceResolution:
reference = resolve_reference_symbol(spec)
if not reference:
return PriceResolution(None, None, "unresolved")
env_price = _read_env_price(_env_keys(reference))
if env_price is not None:
return PriceResolution(env_price, reference, "env")
live = cache.get(reference)
if live is not None:
return PriceResolution(live, reference, "on-chain")
require_live_btc = reference == "BTC" and (
os.environ.get("OMNL_REQUIRE_LIVE_BTC_PRICE") == "1"
or (os.environ.get("NODE_ENV") == "production" and os.environ.get("OMNL_REQUIRE_LIVE_BTC_PRICE") != "0")
)
if require_live_btc:
return PriceResolution(None, reference, "unresolved")
fallback = REPO_FALLBACK_PRICE_USD.get(reference)
if fallback is not None:
return PriceResolution(fallback, reference, "repo-fallback")
return PriceResolution(None, reference, "unresolved")
def resolve_for_address(spec: Optional[TokenSpec]) -> PriceResolution:
if spec is None:
return PriceResolution(None, None, "unresolved")
base = resolve_for_spec(spec)
if base.reference_symbol != "ETH":
return base
live_eth = cache.get("ETH")
if live_eth is None:
return base
if base.source == "env" and base.price_usd is not None:
if eth_oracle_drift_bps(base.price_usd, live_eth) > read_drift_bps():
return PriceResolution(live_eth, "ETH", "on-chain")
return base
if base.source in {"repo-fallback", "unresolved"} or base.price_usd is None:
return PriceResolution(live_eth, "ETH", "on-chain")
return base
def price_usd(spec: Optional[TokenSpec]) -> Optional[float]:
return resolve_for_address(spec).price_usd

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
def repo_root() -> Path:
env_root = os.environ.get("OMNL_REPO_ROOT", "").strip()
if env_root:
return Path(env_root)
candidate = Path(__file__).resolve().parents[3]
if (candidate / "config" / "omnl-m2-token-registry.v1.json").is_file():
return candidate
docker_app = Path(__file__).resolve().parents[1]
if (docker_app / "config" / "omnl-m2-token-registry.v1.json").is_file():
return docker_app
return candidate
@dataclass(frozen=True)
class Chain138Addresses:
aggregator: str = "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506"
proxy: str = "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6"
oracle_price_feed: str = "0x8918eE0819fD687f4eb3e8b9B7D0ef7557493cfa"
reserve_system: str = "0x607e97cD626f209facfE48c1464815DDE15B5093"
price_feed_keeper: str = "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04"
weth_mock_price_feed: str = "0x3e8725b8De386feF3eFE5678c92eA6aDB41992B2"
dodo_pmm_integration: str = "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895"
weth9: str = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
CANONICAL_LIVE_POOLS: tuple[str, ...] = (
"0x9e89bAe009adf128782E19e8341996c596ac40dC",
"0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66",
"0xc39B7D0F40838cbFb54649d327f49a6DAC964062",
"0x67049e7333481e2cac91af61403ac7bddfab7bcd",
"0x72f1a0794153c3b8a1e8a731f1d8e1a52cb10dc5",
"0xb53a0508940b1ff90f1aad4f6cb50a7012fe5593",
"0xe227f6c0520c0c6e8786fe56fa76c4914f861533",
"0xf3e8a07d419b61f002114e64d79f7cf8f7989433",
)
def load_dotenv() -> None:
try:
from dotenv import load_dotenv as _load
root = repo_root()
for path in (
root / ".env",
root / "services" / "chain138-oracle" / ".env",
Path(os.environ.get("ORACLE_PUBLISHER_ENV", "/opt/oracle-publisher/.env")),
):
if path.is_file():
_load(path, override=False)
except ImportError:
pass
def chain138_rpc_url() -> str:
for key in ("RPC_URL_138", "CHAIN138_SERVER_RPC_URL", "RPC_URL"):
value = os.environ.get(key, "").strip()
if value:
return value.rstrip("/")
return "https://rpc-http-pub.d-bis.org"
def addresses() -> Chain138Addresses:
load_dotenv()
return Chain138Addresses(
aggregator=_addr("AGGREGATOR_ADDRESS", "ORACLE_AGGREGATOR_ADDRESS", default=Chain138Addresses.aggregator),
proxy=_addr("ORACLE_PROXY_ADDRESS", "ORACLE_PROXY", default=Chain138Addresses.proxy),
oracle_price_feed=_addr("ORACLE_PRICE_FEED", default=Chain138Addresses.oracle_price_feed),
reserve_system=_addr("RESERVE_SYSTEM", default=Chain138Addresses.reserve_system),
price_feed_keeper=_addr("PRICE_FEED_KEEPER_ADDRESS", default=Chain138Addresses.price_feed_keeper),
weth_mock_price_feed=_addr("CHAIN138_WETH_MOCK_PRICE_FEED", default=Chain138Addresses.weth_mock_price_feed),
dodo_pmm_integration=_addr(
"DODO_PMM_INTEGRATION",
"CHAIN_138_DODO_PMM_INTEGRATION",
default=Chain138Addresses.dodo_pmm_integration,
),
weth9=_addr("WETH9_ADDRESS", default=Chain138Addresses.weth9),
)
def _addr(*keys: str, default: str = "") -> str:
for key in keys:
value = os.environ.get(key, "").strip()
if value:
return value
return default
def api_port() -> int:
raw = os.environ.get("CHAIN138_ORACLE_PORT", os.environ.get("ORACLE_PUBLISHER_PORT", "3500"))
try:
return int(raw)
except ValueError:
return 3500
def mesh_interval_sec() -> int:
raw = os.environ.get("PMM_MESH_INTERVAL_SEC", "6")
try:
return max(1, int(raw))
except ValueError:
return 6

View File

@@ -0,0 +1,155 @@
from __future__ import annotations
import asyncio
import os
import time
from typing import Optional
import httpx
from . import reference_cache as cache
FRANKFURTER = ("EUR", "GBP", "AUD", "CAD", "CHF", "JPY")
COINGECKO_IDS = {
"ETH": "ethereum",
"BTC": "bitcoin",
"LINK": "chainlink",
"XAU": "pax-gold",
"BNB": "binancecoin",
"POL": "matic-network",
"AVAX": "avalanche-2",
"CELO": "celo",
"CRO": "crypto-com-chain",
}
COMMODITY_IDS = {
"platinum": "platinum",
"palladium": "palladium",
"copper": "copper",
"nickel": "nickel",
"cobalt": "cobalt",
}
_last_refresh = 0.0
_refresh_lock = asyncio.Lock()
def _coingecko_base() -> str:
return "https://pro-api.coingecko.com/api/v3" if os.environ.get("COINGECKO_API_KEY") else "https://api.coingecko.com/api/v3"
def _coingecko_headers() -> dict[str, str]:
key = os.environ.get("COINGECKO_API_KEY", "").strip()
if not key:
return {"User-Agent": os.environ.get("COINGECKO_USER_AGENT", "chain138-oracle/1.0")}
if key.startswith("CG-"):
return {"x-cg-demo-api-key": key, "User-Agent": os.environ.get("COINGECKO_USER_AGENT", "chain138-oracle/1.0")}
return {"x-cg-pro-api-key": key, "User-Agent": os.environ.get("COINGECKO_USER_AGENT", "chain138-oracle/1.0")}
async def _fetch_frankfurter(client: httpx.AsyncClient, currency: str) -> Optional[float]:
try:
resp = await client.get(
f"https://api.frankfurter.app/latest?from={currency}&to=USD",
timeout=8.0,
)
resp.raise_for_status()
usd = resp.json().get("rates", {}).get("USD")
if usd and float(usd) > 0:
return float(usd)
except Exception:
pass
return None
async def _fetch_binance_usd(symbol: str) -> Optional[float]:
pair = f"{symbol.upper()}USDT"
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"https://api.binance.com/api/v3/ticker/price?symbol={pair}",
timeout=8.0,
)
resp.raise_for_status()
price = float(resp.json()["price"])
return price if price > 0 else None
except Exception:
return None
async def _fetch_coingecko(client: httpx.AsyncClient, coin_id: str) -> Optional[float]:
try:
resp = await client.get(
f"{_coingecko_base()}/simple/price",
params={"ids": coin_id, "vs_currencies": "usd"},
headers=_coingecko_headers(),
timeout=8.0,
)
resp.raise_for_status()
usd = resp.json().get(coin_id, {}).get("usd")
if usd and float(usd) > 0:
return float(usd)
except Exception:
pass
return None
async def refresh_fx_rates(ttl_sec: float = 300) -> dict[str, float]:
global _last_refresh
updated: dict[str, float] = {}
cache.prime("USD", 1.0, ttl_sec)
async with httpx.AsyncClient() as client:
for currency in FRANKFURTER:
price = await _fetch_frankfurter(client, currency)
if price:
cache.prime(currency, price, ttl_sec)
updated[currency] = price
for symbol, coin_id in COINGECKO_IDS.items():
price = await _fetch_coingecko(client, coin_id)
if not price and symbol in {"ETH", "BTC"}:
price = await _fetch_binance_usd(symbol)
if price:
cache.prime(symbol, price, ttl_sec)
updated[symbol] = price
commodities: dict[str, float] = {}
for key, coin_id in COMMODITY_IDS.items():
price = await _fetch_coingecko(client, coin_id)
if price:
commodities[key] = price
if "platinum" in commodities and "palladium" in commodities:
lipmg = (commodities["platinum"] + commodities["palladium"]) / 2
cache.prime("LIPMG", lipmg, ttl_sec)
updated["LIPMG"] = lipmg
if "copper" in commodities:
cache.prime("LIBMG1", commodities["copper"], ttl_sec)
updated["LIBMG1"] = commodities["copper"]
if "nickel" in commodities and "cobalt" in commodities:
libmg2 = commodities["nickel"] * 0.8 + commodities["cobalt"] * 0.2
cache.prime("LIBMG2", libmg2, ttl_sec)
updated["LIBMG2"] = libmg2
elif "nickel" in commodities:
cache.prime("LIBMG2", commodities["nickel"], ttl_sec)
updated["LIBMG2"] = commodities["nickel"]
_last_refresh = time.time()
return updated
async def ensure_fx_primed(ttl_sec: float = 300) -> None:
global _last_refresh
now = time.time()
if now - _last_refresh < ttl_sec:
return
async with _refresh_lock:
now = time.time()
if now - _last_refresh < ttl_sec:
return
await refresh_fx_rates(ttl_sec)
def refresh_fx_rates_sync(ttl_sec: float = 300) -> dict[str, float]:
return asyncio.run(refresh_fx_rates(ttl_sec))

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import asyncio
import os
import time
from dataclasses import dataclass
from typing import Optional
from web3 import Web3
from .config import addresses, chain138_rpc_url
KEEPER_ABI = [
{
"inputs": [],
"name": "checkUpkeep",
"outputs": [
{"name": "needsUpdate", "type": "bool"},
{"name": "assets", "type": "address[]"},
],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [],
"name": "performUpkeep",
"outputs": [
{"name": "success", "type": "bool"},
{"name": "updatedAssets", "type": "address[]"},
],
"stateMutability": "nonpayable",
"type": "function",
},
]
@dataclass(frozen=True)
class KeeperResult:
performed: bool
tx_hash: Optional[str]
reason: str
def _keeper_key() -> Optional[str]:
raw = os.environ.get("KEEPER_PRIVATE_KEY", "").strip()
if not raw:
return None
return raw if raw.startswith("0x") else f"0x{raw}"
def perform_upkeep_once() -> KeeperResult:
pk = _keeper_key()
if not pk:
return KeeperResult(False, None, "KEEPER_PRIVATE_KEY not set")
w3 = Web3(Web3.HTTPProvider(chain138_rpc_url(), request_kwargs={"timeout": 30}))
account = w3.eth.account.from_key(pk)
keeper_addr = addresses().price_feed_keeper
contract = w3.eth.contract(address=Web3.to_checksum_address(keeper_addr), abi=KEEPER_ABI)
needs, assets = contract.functions.checkUpkeep().call()
if not needs:
return KeeperResult(False, None, "no upkeep needed")
gas_price = int(os.environ.get("MESH_CAST_GAS_PRICE", os.environ.get("ORACLE_UPDATE_GAS_PRICE_WEI", "1000000000")))
gas_limit = int(os.environ.get("KEEPER_GAS_LIMIT", "800000"))
nonce = w3.eth.get_transaction_count(account.address)
tx = contract.functions.performUpkeep().build_transaction(
{
"from": account.address,
"nonce": nonce,
"gas": gas_limit,
"gasPrice": gas_price,
"chainId": w3.eth.chain_id,
}
)
signed = account.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
return KeeperResult(True, tx_hash.hex(), f"updated {len(assets)} assets")
async def keeper_loop(interval_sec: int = 6) -> None:
while True:
try:
result = perform_upkeep_once()
if result.performed:
print(f"[keeper] {result.reason} tx={result.tx_hash}")
except Exception as exc:
print(f"[keeper] error: {exc}")
await asyncio.sleep(interval_sec)

View File

@@ -0,0 +1,144 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from web3 import Web3
from . import canonical_price
from .config import CANONICAL_LIVE_POOLS, addresses, chain138_rpc_url
from .registry import TokenSpec, merge_pmm_tokens, load_pmm_token_map, load_registry
PMM_ABI = [
{
"inputs": [{"name": "pool", "type": "address"}],
"name": "getPoolReserves",
"outputs": [
{"name": "baseToken", "type": "address"},
{"name": "quoteToken", "type": "address"},
{"name": "baseReserve", "type": "uint256"},
{"name": "quoteReserve", "type": "uint256"},
],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [{"name": "pool", "type": "address"}],
"name": "getPoolPriceOrOracle",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function",
},
]
@dataclass(frozen=True)
class PoolLiquidity:
pool: str
base_token: str
quote_token: str
base_symbol: str
quote_symbol: str
base_reserve: float
quote_reserve: float
pool_price: float
base_usd: float
quote_usd: float
total_liquidity_usd: float
def _amount(raw: int, decimals: int) -> float:
if raw <= 0:
return 0.0
return raw / (10**decimals)
def _price(raw: int) -> float:
if raw <= 0:
return 0.0
return raw / 1e18
def estimate_pool_liquidity(
pool: str,
base_token: str,
quote_token: str,
base_reserve: int,
quote_reserve: int,
pool_price: int,
token_map: dict[str, TokenSpec],
) -> PoolLiquidity:
base_spec = token_map.get(base_token.lower())
quote_spec = token_map.get(quote_token.lower())
base_dec = base_spec.decimals if base_spec else 18
quote_dec = quote_spec.decimals if quote_spec else 18
base_amt = _amount(base_reserve, base_dec)
quote_amt = _amount(quote_reserve, quote_dec)
price = _price(pool_price)
base_usd = canonical_price.price_usd(base_spec) or 0.0
quote_usd = canonical_price.price_usd(quote_spec) or 0.0
if price > 0:
if quote_usd == 1.0 and base_usd != 1.0:
base_usd = price
if base_usd == 1.0 and quote_usd != 1.0:
quote_usd = price
reserve0_usd = base_amt * base_usd if base_usd > 0 else 0.0
reserve1_usd = quote_amt * quote_usd if quote_usd > 0 else 0.0
total = reserve0_usd + reserve1_usd
return PoolLiquidity(
pool=pool,
base_token=base_token,
quote_token=quote_token,
base_symbol=base_spec.symbol if base_spec else base_token[:10],
quote_symbol=quote_spec.symbol if quote_spec else quote_token[:10],
base_reserve=base_amt,
quote_reserve=quote_amt,
pool_price=price,
base_usd=base_usd,
quote_usd=quote_usd,
total_liquidity_usd=total,
)
def fetch_all_pool_liquidity() -> list[PoolLiquidity]:
w3 = Web3(Web3.HTTPProvider(chain138_rpc_url(), request_kwargs={"timeout": 30}))
integration = addresses().dodo_pmm_integration
contract = w3.eth.contract(address=Web3.to_checksum_address(integration), abi=PMM_ABI)
tokens = merge_pmm_tokens(load_registry(), load_pmm_token_map())
out: list[PoolLiquidity] = []
for pool in CANONICAL_LIVE_POOLS:
try:
reserves = contract.functions.getPoolReserves(Web3.to_checksum_address(pool)).call()
price = contract.functions.getPoolPriceOrOracle(Web3.to_checksum_address(pool)).call()
liq = estimate_pool_liquidity(
pool.lower(),
reserves[0].lower(),
reserves[1].lower(),
int(reserves[2]),
int(reserves[3]),
int(price),
tokens,
)
out.append(liq)
except Exception:
continue
return out
def liquidity_by_token() -> dict[str, float]:
totals: dict[str, float] = {}
for pool in fetch_all_pool_liquidity():
for addr, usd_part in (
(pool.base_token, pool.base_reserve * pool.base_usd),
(pool.quote_token, pool.quote_reserve * pool.quote_usd),
):
if usd_part <= 0:
continue
totals[addr.lower()] = totals.get(addr.lower(), 0.0) + usd_part
return totals

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import asyncio
import time
from .config import CANONICAL_LIVE_POOLS, mesh_interval_sec
from .fx_refresh import refresh_fx_rates
from .keeper import perform_upkeep_once
from .liquidity import fetch_all_pool_liquidity
from .onchain_eth import get_eth_usd_on_chain
from .onchain_oracle import fetch_eth_usd_market, update_eth_usd_oracle
async def mesh_tick(tick: int) -> None:
refresh_live = get_eth_usd_on_chain()
if refresh_live:
print(f"[mesh:{tick}] on-chain ETH/USD ${refresh_live.price_usd:.2f} ({refresh_live.source})")
try:
market = await fetch_eth_usd_market()
result = update_eth_usd_oracle(market)
if result.skipped:
print(f"[mesh:{tick}] oracle skip: {result.reason}")
else:
print(f"[mesh:{tick}] oracle tx {result.tx_hash} @ ${result.eth_usd:.2f}")
except PermissionError as exc:
print(f"[mesh:{tick}] oracle skip: {exc}")
except Exception as exc:
print(f"[mesh:{tick}] oracle error: {exc}")
try:
keeper = perform_upkeep_once()
if keeper.performed:
print(f"[mesh:{tick}] keeper {keeper.reason} tx={keeper.tx_hash}")
except Exception as exc:
print(f"[mesh:{tick}] keeper error: {exc}")
if tick % 10 == 0:
pools = fetch_all_pool_liquidity()
total_tvl = sum(p.total_liquidity_usd for p in pools)
print(f"[mesh:{tick}] PMM pools={len(pools)}/{len(CANONICAL_LIVE_POOLS)} TVL=${total_tvl:,.2f}")
async def mesh_loop() -> None:
await refresh_fx_rates()
interval = mesh_interval_sec()
tick = 0
print(f"[mesh] starting {interval}s loop for Chain 138 oracle/keeper/PMM")
while True:
tick += 1
started = time.time()
await mesh_tick(tick)
if tick % 50 == 0:
await refresh_fx_rates()
elapsed = time.time() - started
await asyncio.sleep(max(0.0, interval - elapsed))

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
from web3 import Web3
from . import reference_cache as cache
from .config import addresses, chain138_rpc_url
ROUND_DATA_ABI = [
{
"inputs": [],
"name": "latestRoundData",
"outputs": [
{"name": "roundId", "type": "uint80"},
{"name": "answer", "type": "int256"},
{"name": "startedAt", "type": "uint256"},
{"name": "updatedAt", "type": "uint256"},
{"name": "answeredInRound", "type": "uint80"},
],
"stateMutability": "view",
"type": "function",
}
]
_cached: Optional[tuple[float, str, float]] = None
@dataclass(frozen=True)
class EthUsdOnChain:
price_usd: float
source: str
def _web3() -> Web3:
return Web3(Web3.HTTPProvider(chain138_rpc_url(), request_kwargs={"timeout": 20}))
def _read_eth_usd(w3: Web3, address: str) -> Optional[float]:
contract = w3.eth.contract(address=Web3.to_checksum_address(address), abi=ROUND_DATA_ABI)
result = contract.functions.latestRoundData().call()
answer = int(result[1])
if answer <= 0:
return None
return answer / 1e8
def get_eth_usd_on_chain() -> Optional[EthUsdOnChain]:
global _cached
now = time.time()
if _cached and _cached[2] > now:
return EthUsdOnChain(_cached[0], _cached[1])
addrs = addresses()
w3 = _web3()
for address, source in ((addrs.aggregator, "aggregator"), (addrs.proxy, "proxy")):
try:
price = _read_eth_usd(w3, address)
if price and price > 0:
_cached = (price, source, now + 60)
cache.prime("ETH", price, 120)
return EthUsdOnChain(price, source)
except Exception:
continue
return None
def refresh_live_reference_prices() -> None:
get_eth_usd_on_chain()

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
import httpx
from web3 import Web3
from .config import addresses, chain138_rpc_url
AGGREGATOR_ABI = [
{
"inputs": [{"name": "candidate", "type": "address"}],
"name": "isTransmitter",
"outputs": [{"name": "", "type": "bool"}],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [{"name": "answer", "type": "int256"}],
"name": "transmit",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function",
},
{
"inputs": [{"name": "answer", "type": "int256"}],
"name": "updateAnswer",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function",
},
{
"inputs": [],
"name": "latestRoundData",
"outputs": [
{"name": "roundId", "type": "uint80"},
{"name": "answer", "type": "int256"},
{"name": "startedAt", "type": "uint256"},
{"name": "updatedAt", "type": "uint256"},
{"name": "answeredInRound", "type": "uint80"},
],
"stateMutability": "view",
"type": "function",
},
]
@dataclass(frozen=True)
class OracleUpdateResult:
skipped: bool
tx_hash: Optional[str]
eth_usd: float
reason: str
def _private_key() -> str:
for key in ("PRIVATE_KEY", "DEPLOYER_PRIVATE_KEY", "ORACLE_TRANSMITTER_PRIVATE_KEY"):
value = os.environ.get(key, "").strip()
if value:
return value if value.startswith("0x") else f"0x{value}"
raise ValueError("Set PRIVATE_KEY or DEPLOYER_PRIVATE_KEY for oracle updates")
async def fetch_eth_usd_market() -> float:
headers = {"User-Agent": os.environ.get("COINGECKO_USER_AGENT", "chain138-oracle/1.0")}
key = os.environ.get("COINGECKO_API_KEY", "").strip()
async with httpx.AsyncClient() as client:
if key:
demo_headers = {**headers, "x-cg-demo-api-key": key}
try:
resp = await client.get(
"https://api.coingecko.com/api/v3/simple/price",
params={"ids": "ethereum", "vs_currencies": "usd"},
headers=demo_headers,
timeout=20,
)
val = resp.json().get("ethereum", {}).get("usd")
if val:
return float(val)
except Exception:
pass
pro_headers = {**headers, "x-cg-pro-api-key": key}
try:
resp = await client.get(
"https://pro-api.coingecko.com/api/v3/simple/price",
params={"ids": "ethereum", "vs_currencies": "usd"},
headers=pro_headers,
timeout=20,
)
val = resp.json().get("ethereum", {}).get("usd")
if val:
return float(val)
except Exception:
pass
resp = await client.get(
"https://api.coingecko.com/api/v3/simple/price",
params={"ids": "ethereum", "vs_currencies": "usd"},
headers=headers,
timeout=20,
)
val = resp.json().get("ethereum", {}).get("usd")
if val:
return float(val)
resp = await client.get("https://api.binance.com/api/v3/ticker/price?symbol=ETHUSDT", timeout=20)
return float(resp.json()["price"])
def update_eth_usd_oracle(eth_usd: float, min_drift_pct: float = 1.0) -> OracleUpdateResult:
w3 = Web3(Web3.HTTPProvider(chain138_rpc_url(), request_kwargs={"timeout": 60}))
pk = _private_key()
account = w3.eth.account.from_key(pk)
agg = addresses().aggregator
contract = w3.eth.contract(address=Web3.to_checksum_address(agg), abi=AGGREGATOR_ABI)
current = contract.functions.latestRoundData().call()
current_price = int(current[1]) / 1e8 if int(current[1]) > 0 else 0.0
if current_price > 0:
drift = abs(eth_usd - current_price) / current_price * 100
if drift < min_drift_pct:
return OracleUpdateResult(True, None, eth_usd, f"drift {drift:.2f}% < {min_drift_pct}%")
if not contract.functions.isTransmitter(account.address).call():
raise PermissionError(f"{account.address} is not an authorized transmitter on {agg}")
answer = int(eth_usd * 1e8)
fn = contract.functions.transmit(answer)
try:
fn.estimate_gas({"from": account.address})
except Exception:
fn = contract.functions.updateAnswer(answer)
gas_price = int(os.environ.get("ORACLE_UPDATE_GAS_PRICE_WEI", "1000000000"))
gas_limit = int(os.environ.get("ORACLE_UPDATE_GAS_LIMIT", "400000"))
nonce = w3.eth.get_transaction_count(account.address)
tx = fn.build_transaction(
{
"from": account.address,
"nonce": nonce,
"gas": gas_limit,
"gasPrice": gas_price,
"chainId": w3.eth.chain_id,
}
)
signed = account.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
return OracleUpdateResult(False, tx_hash.hex(), eth_usd, "submitted")

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
import time
from threading import Lock
from typing import Optional
_lock = Lock()
_prices: dict[str, tuple[float, float]] = {}
def prime(symbol: str, price_usd: float, ttl_sec: float = 300) -> None:
if not price_usd or price_usd <= 0:
return
key = symbol.upper()
with _lock:
_prices[key] = (price_usd, time.time() + ttl_sec)
def get(symbol: str) -> Optional[float]:
key = symbol.upper()
with _lock:
row = _prices.get(key)
if not row:
return None
price, expires = row
if expires <= time.time():
return None
return price
def snapshot() -> dict[str, float]:
now = time.time()
with _lock:
return {k: v[0] for k, v in _prices.items() if v[1] > now}

View File

@@ -0,0 +1,91 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from .config import repo_root
CHAIN_ID = 138
NATIVE_ETH = "0x0000000000000000000000000000000000000000"
NATIVE_ETH_SENTINEL = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
@dataclass(frozen=True)
class TokenSpec:
symbol: str
name: str
address: str
decimals: int
currency_code: str
omnl_line: str
native: bool = False
def _norm(addr: str) -> str:
return addr.strip().lower()
def load_registry() -> list[TokenSpec]:
path = repo_root() / "config" / "omnl-m2-token-registry.v1.json"
data = json.loads(path.read_text(encoding="utf-8"))
tokens: list[TokenSpec] = []
for row in data.get("tokens", []):
addr = str(row.get("address", "")).strip()
if not addr:
continue
tokens.append(
TokenSpec(
symbol=str(row.get("symbol", "")),
name=str(row.get("name", "")),
address=_norm(addr),
decimals=int(row.get("decimals", 18)),
currency_code=str(row.get("currencyCode", "USD")).upper(),
omnl_line=str(row.get("omnlLine", "")),
native=bool(row.get("native", False)),
)
)
return tokens
def load_pmm_token_map() -> dict[str, str]:
path = repo_root() / "config" / "chain138-pmm-pools.json"
data = json.loads(path.read_text(encoding="utf-8"))
raw = data.get("tokens", {})
return {str(k): _norm(str(v)) for k, v in raw.items()}
def by_address(tokens: list[TokenSpec]) -> dict[str, TokenSpec]:
out: dict[str, TokenSpec] = {}
for t in tokens:
out[t.address] = t
return out
def by_symbol(tokens: list[TokenSpec]) -> dict[str, TokenSpec]:
out: dict[str, TokenSpec] = {}
for t in tokens:
out[t.symbol.upper()] = t
return out
def merge_pmm_tokens(tokens: list[TokenSpec], pmm_map: dict[str, str]) -> dict[str, TokenSpec]:
"""Ensure PMM pool tokens are address-indexed even if absent from M2 registry."""
addr_map = by_address(tokens)
sym_map = by_symbol(tokens)
for symbol, address in pmm_map.items():
if address in addr_map:
continue
match = sym_map.get(symbol.upper())
if match:
addr_map[address] = match
else:
addr_map[address] = TokenSpec(
symbol=symbol,
name=symbol,
address=address,
decimals=18,
currency_code="USD",
omnl_line="USD-M2",
)
return addr_map

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Optional
from . import canonical_price
from .onchain_eth import get_eth_usd_on_chain
from .registry import NATIVE_ETH, NATIVE_ETH_SENTINEL, TokenSpec, by_address, load_registry
from .fx_refresh import ensure_fx_primed
AGGREGATOR = "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506"
PROXY = "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6"
WETH9 = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
@dataclass(frozen=True)
class SpotPrice:
price_usd: Optional[float]
symbol: Optional[str]
currency_code: Optional[str]
reference_symbol: Optional[str]
source: str
def _is_eth_peg(spec: Optional[TokenSpec], address: str) -> bool:
if address == WETH9:
return True
sym = (spec.symbol if spec else "").upper()
return sym in {"WETH", "WETH9", "WETH10", "CETH", "CETHL2"}
async def resolve_spot_price(chain_id: int, address: str) -> SpotPrice:
await ensure_fx_primed()
refresh_live = get_eth_usd_on_chain()
normalized = address.strip().lower()
tokens = by_address(load_registry())
spec = tokens.get(normalized)
if normalized in {NATIVE_ETH, NATIVE_ETH_SENTINEL}:
if chain_id == 138 and refresh_live:
return SpotPrice(refresh_live.price_usd, "ETH", "ETH", "ETH", "native-oracle")
base = canonical_price.resolve_for_address(spec)
return SpotPrice(base.price_usd, "ETH", "ETH", "ETH", "iso4217-oracle" if base.price_usd else "unresolved")
if chain_id == 138 and normalized in {AGGREGATOR, PROXY}:
if refresh_live:
return SpotPrice(refresh_live.price_usd, "ETH-USD", "USD", "ETH", "native-oracle")
if chain_id == 138 and _is_eth_peg(spec, normalized):
if refresh_live:
return SpotPrice(
refresh_live.price_usd,
spec.symbol if spec else "WETH",
spec.currency_code if spec else "ETH",
"ETH",
"native-oracle",
)
base = canonical_price.resolve_for_address(spec)
return SpotPrice(
base.price_usd,
spec.symbol if spec else None,
spec.currency_code if spec else None,
base.reference_symbol,
"iso4217-oracle" if base.price_usd else "unresolved",
)
def resolve_spot_price_sync(chain_id: int, address: str) -> SpotPrice:
return asyncio.run(resolve_spot_price(chain_id, address))

View File

@@ -0,0 +1,6 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
web3>=7.0.0
httpx>=0.27.0
python-dotenv>=1.0.0
pydantic>=2.0.0

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env python3
"""Validate Chain 138 oracle coverage — every M2 registry token must resolve a USD price."""
from chain138_oracle.__main__ import cmd_validate
if __name__ == "__main__":
raise SystemExit(cmd_validate())

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Chain 138 Python Oracle API
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/smom-dbis-138/services/chain138-oracle
EnvironmentFile=/opt/smom-dbis-138/.env
Environment=CHAIN138_ORACLE_PORT=3500
ExecStart=/opt/smom-dbis-138/services/chain138-oracle/.venv/bin/python -m chain138_oracle serve
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Chain 138 Python Oracle Mesh (6s tick)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/smom-dbis-138/services/chain138-oracle
EnvironmentFile=/opt/smom-dbis-138/.env
ExecStart=/opt/smom-dbis-138/services/chain138-oracle/.venv/bin/python -m chain138_oracle mesh
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@@ -4,9 +4,66 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.HybxProductionRail = void 0;
exports.resolveHybxPaymentsUrl = resolveHybxPaymentsUrl;
exports.buildHybxRtgsHeaders = buildHybxRtgsHeaders;
const axios_1 = __importDefault(require("axios"));
const crypto_1 = require("crypto");
const config_1 = require("../config");
const integration_foundation_1 = require("@dbis/integration-foundation");
/** RFC1918 / loopback hosts — not reachable from public VPS deploys (e.g. secure.omdnl.org). */
function isPrivateSidecarHost(url) {
try {
const host = new URL(url).hostname.toLowerCase();
if (host === 'localhost' || host.endsWith('.local'))
return true;
if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host))
return true;
const m = /^172\.(\d+)\./.exec(host);
if (m) {
const second = parseInt(m[1], 10);
if (second >= 16 && second <= 31)
return true;
}
return false;
}
catch {
return false;
}
}
/** Resolve outbound payment URL — public HYBX RTGS API, not LAN sidecar unless explicitly allowed. */
function resolveHybxPaymentsUrl(baseUrl) {
const explicit = process.env.HYBX_PAYMENTS_URL?.trim();
if (explicit)
return explicit.replace(/\/$/, '');
const hybxApi = process.env.HYBX_API?.trim().replace(/\/$/, '');
if (hybxApi)
return `${hybxApi}/rtgs/payments`;
const sidecar = process.env.HYBX_MIFOS_FINERACT_SIDECAR_URL?.trim();
const allowLan = process.env.HYBX_ALLOW_LAN_SIDECAR === '1';
if (sidecar && (allowLan || !isPrivateSidecarHost(sidecar))) {
return `${sidecar.replace(/\/$/, '')}/v1/rtgs/payments`;
}
return `${baseUrl.replace(/\/$/, '')}/v1/rtgs/payments`;
}
function hybxWebhookSecret() {
return (process.env.HYBX_WEBHOOK_SECRET?.trim() ||
process.env.HYBX_SIGNING_SECRET?.trim() ||
'');
}
/** HYBX RTGS API: X-API-Key + X-Timestamp + X-Signature = HMAC-SHA256(secret, raw JSON body). */
function buildHybxRtgsHeaders(bodyJson, apiKey) {
const secret = hybxWebhookSecret();
if (!secret) {
throw new Error('HYBX_WEBHOOK_SECRET required for signed RTGS dispatch');
}
const timestamp = Math.floor(Date.now() / 1000).toString();
return {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
'X-Timestamp': timestamp,
'X-Signature': (0, crypto_1.createHmac)('sha256', secret).update(bodyJson).digest('hex'),
};
}
/** Production HYBX rail client for real settlement dispatch */
class HybxProductionRail {
baseUrl;
@@ -25,9 +82,8 @@ class HybxProductionRail {
this.clientSecret = cfg.clientSecret;
}
async dispatchPayment(payload) {
const sidecar = process.env.HYBX_MIFOS_FINERACT_SIDECAR_URL?.replace(/\/$/, '');
const url = sidecar ? `${sidecar}/v1/rtgs/payments` : `${this.baseUrl}/v1/payments`;
const { data } = await axios_1.default.post(url, {
const url = resolveHybxPaymentsUrl(this.baseUrl);
const body = {
externalId: payload.idempotencyKey,
amount: payload.amount,
currency: payload.currency,
@@ -38,18 +94,35 @@ class HybxProductionRail {
},
remittanceInformation: payload.remittanceInfo,
rail: payload.rail,
}, {
headers: {
};
if (payload.debtorIban)
body.debtorIban = payload.debtorIban;
const bodyJson = JSON.stringify(body);
const useRtgsSignedApi = Boolean(process.env.HYBX_API?.trim() || process.env.HYBX_PAYMENTS_URL?.trim());
const headers = useRtgsSignedApi
? buildHybxRtgsHeaders(bodyJson, this.apiKey)
: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
Authorization: `Bearer ${this.clientSecret}`,
},
timeout: 120000,
});
return {
paymentId: String(data.paymentId ?? data.id ?? payload.idempotencyKey),
status: String(data.status ?? 'DISPATCHED'),
};
};
if (useRtgsSignedApi && !hybxWebhookSecret()) {
throw new Error('HYBX_WEBHOOK_SECRET required for signed RTGS dispatch (HYBX_API set)');
}
try {
const { data } = await axios_1.default.post(url, bodyJson, { headers, timeout: 120000 });
return {
paymentId: String(data.paymentId ?? data.id ?? payload.idempotencyKey),
status: String(data.status ?? 'DISPATCHED'),
};
}
catch (e) {
const ax = e;
const detail = ax.response?.data != null
? JSON.stringify(ax.response.data).slice(0, 400)
: ax.message ?? String(e);
throw new Error(`HYBX payment failed (${url}): ${detail}`);
}
}
}
exports.HybxProductionRail = HybxProductionRail;

View File

@@ -8,8 +8,9 @@ exports.requestTokenMint = requestTokenMint;
exports.requestTokenTransfer = requestTokenTransfer;
exports.requestFiatLpToLiquidity = requestFiatLpToLiquidity;
const axios_1 = __importDefault(require("axios"));
const price_service_1 = require("./price-service");
async function archiveIso20022(payload) {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const base = (0, price_service_1.resolveTokenAggregationBase)();
const key = process.env.OMNL_API_KEY || '';
const headers = { 'Content-Type': 'application/json' };
if (key)
@@ -23,7 +24,7 @@ async function archiveIso20022(payload) {
return { messageId: String(data.messageId ?? data.id ?? payload.settlementRef) };
}
async function requestTokenMint(payload) {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const base = (0, price_service_1.resolveTokenAggregationBase)();
const key = process.env.OMNL_API_KEY || '';
const headers = { 'Content-Type': 'application/json' };
if (key)
@@ -45,7 +46,7 @@ async function requestTokenMint(payload) {
}
}
async function requestTokenTransfer(payload) {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const base = (0, price_service_1.resolveTokenAggregationBase)();
const key = process.env.OMNL_API_KEY || '';
const headers = { 'Content-Type': 'application/json' };
if (key)
@@ -67,7 +68,7 @@ async function requestTokenTransfer(payload) {
}
}
async function requestFiatLpToLiquidity(payload) {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const base = (0, price_service_1.resolveTokenAggregationBase)();
const key = process.env.OMNL_API_KEY || '';
const headers = { 'Content-Type': 'application/json' };
if (key)

View File

@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveChain138OracleBase = resolveChain138OracleBase;
exports.resolveTokenAggregationBase = resolveTokenAggregationBase;
/**
* Chain 138 spot-price / oracle HTTP base.
* Pricing uses CHAIN138_ORACLE_URL (Python oracle, port 3500) when set;
* falls back to TOKEN_AGGREGATION_URL for gradual cutover.
*/
function resolveChain138OracleBase() {
const oracle = (process.env.CHAIN138_ORACLE_URL || process.env.ORACLE_PUBLISHER_URL || '').replace(/\/$/, '');
if (oracle)
return oracle;
return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3500').replace(/\/$/, '');
}
/** token-aggregation base for OMNL mint/transfer/ISO20022 (not spot prices). */
function resolveTokenAggregationBase() {
return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
}

View File

@@ -5,23 +5,29 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveLiveBtcUsd = resolveLiveBtcUsd;
const axios_1 = __importDefault(require("axios"));
const CBTC_ADDRESS = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
const price_service_1 = require("./price-service");
const btc_ledger_config_1 = require("../btc-ledger-config");
const CHAIN_138 = 138;
function tokenAggregationBase() {
return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
function cbtcAddress() {
try {
return (0, btc_ledger_config_1.loadBtcLedgerConfig)().token.address.toLowerCase();
}
catch {
return '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
}
}
/**
* Resolve live BTC/USD for settlement. Rejects unresolved/stale prices —
* production must not fall back to the $90k repo snapshot.
*/
async function resolveLiveBtcUsd() {
const base = tokenAggregationBase();
const base = (0, price_service_1.resolveChain138OracleBase)();
const key = process.env.OMNL_API_KEY || '';
const headers = { Accept: 'application/json' };
if (key)
headers.Authorization = `Bearer ${key}`;
const url = `${base}/api/v1/prices/metamask/v2/chains/${CHAIN_138}/spot-prices` +
`?tokenAddresses=${CBTC_ADDRESS}&vsCurrency=usd`;
`?tokenAddresses=${cbtcAddress()}&vsCurrency=usd`;
let data;
try {
const res = await axios_1.default.get(url, { headers, timeout: 30000 });
@@ -41,11 +47,12 @@ async function resolveLiveBtcUsd() {
throw err;
}
const map = (data ?? {});
const row = map[CBTC_ADDRESS.toLowerCase()] ?? map[CBTC_ADDRESS];
const addr = cbtcAddress();
const row = map[addr] ?? map[addr.toLowerCase()];
const rate = row?.usd ?? row?.USD;
if (!(typeof rate === 'number' && Number.isFinite(rate) && rate > 0)) {
throw new Error('Live BTC/USD price unresolved — settlement blocked (OMNL_REQUIRE_LIVE_BTC_PRICE). ' +
'Ensure token-aggregation FX refresh / CoinGecko is reachable.');
'Ensure chain138-oracle (CHAIN138_ORACLE_URL) or token-aggregation FX refresh is reachable.');
}
// Guard against known static repo fallback used in non-production
const requireLive = process.env.OMNL_REQUIRE_LIVE_BTC_PRICE === '1' || process.env.NODE_ENV === 'production';
@@ -61,8 +68,8 @@ async function resolveLiveBtcUsd() {
}
return {
btcUsdRate: rate,
source: 'token-aggregation/metamask-spot-prices',
source: 'chain138-oracle/metamask-spot-prices',
asOf: new Date().toISOString(),
tokenAddress: CBTC_ADDRESS,
tokenAddress: addr,
};
}

View File

@@ -15,6 +15,7 @@ const fiat_lp_to_liquidity_1 = require("../../workflows/fiat-lp-to-liquidity");
const btc_l1_settlement_1 = require("../../workflows/btc-l1-settlement");
const fineract_1 = require("../../adapters/fineract");
const pricing_1 = require("../../adapters/pricing");
const btc_ledger_config_1 = require("../../btc-ledger-config");
const external_banks_hub_1 = require("../../adapters/external-banks-hub");
const card_issuers_hub_1 = require("../../adapters/card-issuers-hub");
const auth_1 = require("../../middleware/auth");
@@ -637,10 +638,21 @@ function createSettlementRouter() {
.slice(0, limit);
res.json({ count: rows.length, settlements: rows });
});
router.get('/btc/contracts', (_req, res) => {
if (!(0, auth_1.requireApiKey)(_req, res))
return;
try {
res.json((0, btc_ledger_config_1.btcLedgerContractsPayload)());
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/btc/ledger', async (_req, res) => {
if (!(0, auth_1.requireApiKey)(_req, res))
return;
try {
const btcCfg = (0, btc_ledger_config_1.loadBtcLedgerConfig)();
const [balancesHo, balancesO24, quote] = await Promise.all([
(0, fineract_1.fetchGlBalances)(1),
(0, fineract_1.fetchGlBalances)(officeId),
@@ -657,6 +669,9 @@ function createSettlementRouter() {
const settledSats = btcSettlements
.filter((r) => r.phase === 'SETTLED')
.reduce((sum, r) => sum + (r.btcSettlement?.amountSats ?? 0), 0);
const m2 = parseFloat(balancesO24['2200'] ?? '0');
const m3 = parseFloat(balancesO24['2300'] ?? '0');
const headroomUsd = m2 - m3;
res.json({
asOf: new Date().toISOString(),
gl: {
@@ -665,6 +680,16 @@ function createSettlementRouter() {
m2Broad2200: balancesO24['2200'] ?? '0',
m3Token2300: balancesO24['2300'] ?? '0',
},
headroomUsd: headroomUsd.toFixed(2),
requiredHeadroomUsd: btcCfg.requiredHeadroomUsd,
mint1000BtcAllowed: headroomUsd >= btcCfg.requiredHeadroomUsd,
contracts: {
cbtc: btcCfg.token.address,
lineId: btcCfg.token.lineId,
primaryRecipient: btcCfg.recipients.primaryMintTarget,
dodoPmmIntegration: btcCfg.dodoEvm.pmmIntegration,
pmmPools: btcCfg.pmmPools,
},
btcUsd: 'btcUsdRate' in quote
? { rate: quote.btcUsdRate, source: quote.source, asOf: quote.asOf }
: { error: quote.error },

View File

@@ -0,0 +1,91 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadBtcLedgerConfig = loadBtcLedgerConfig;
exports.btcLedgerContractsPayload = btcLedgerContractsPayload;
exports.clearBtcLedgerConfigCache = clearBtcLedgerConfigCache;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
let cached = null;
function repoRoot() {
return path_1.default.resolve(__dirname, '../../../');
}
function configPath() {
const rel = process.env.OMNL_BTC_LEDGER_CONFIG ||
process.env.BTC_L1_LEDGER_CONFIG ||
'config/btc-l1-ledger-1000.chain138.v1.json';
return path_1.default.isAbsolute(rel) ? rel : path_1.default.join(repoRoot(), rel.replace(/^\//, ''));
}
function loadBtcLedgerConfig() {
if (cached)
return cached;
const raw = JSON.parse(fs_1.default.readFileSync(configPath(), 'utf8'));
cached = applyEnvOverrides(raw);
return cached;
}
function envAddr(key, fallback) {
const v = process.env[key]?.trim();
return v || fallback;
}
/** Env overrides for deployment without editing JSON. */
function applyEnvOverrides(cfg) {
return {
...cfg,
token: {
...cfg.token,
address: envAddr('CBTC_ADDRESS_138', cfg.token.address),
lineId: process.env.OMNL_BTC_LINE_ID?.trim() || cfg.token.lineId,
},
recipients: {
primaryMintTarget: envAddr('BTC_L1_RECIPIENT_ADDRESS', envAddr('BTC_E2E_RECIPIENT', cfg.recipients.primaryMintTarget)),
existingMintHolder1000: envAddr('BTC_EXISTING_MINT_HOLDER', cfg.recipients.existingMintHolder1000),
},
oracle: {
...cfg.oracle,
aggregator: envAddr('AGGREGATOR_ADDRESS', envAddr('ORACLE_AGGREGATOR_ADDRESS', cfg.oracle.aggregator)),
proxy: envAddr('ORACLE_PROXY_ADDRESS', cfg.oracle.proxy),
oraclePriceFeed: envAddr('ORACLE_PRICE_FEED', cfg.oracle.oraclePriceFeed),
reserveSystem: envAddr('RESERVE_SYSTEM', cfg.oracle.reserveSystem),
priceFeedKeeper: envAddr('PRICE_FEED_KEEPER_ADDRESS', cfg.oracle.priceFeedKeeper),
},
dodoEvm: {
...cfg.dodoEvm,
pmmIntegration: envAddr('DODO_PMM_INTEGRATION', envAddr('CHAIN_138_DODO_PMM_INTEGRATION', cfg.dodoEvm.pmmIntegration)),
},
pmmPools: {
cBTC_cUSDT: envAddr('CHAIN138_POOL_CBTC_CUSDT', cfg.pmmPools.cBTC_cUSDT),
cBTC_cUSDC: envAddr('CHAIN138_POOL_CBTC_CUSDC', cfg.pmmPools.cBTC_cUSDC),
cBTC_cXAUC: envAddr('CHAIN138_POOL_CBTC_CXAUC', cfg.pmmPools.cBTC_cXAUC),
},
};
}
function btcLedgerContractsPayload() {
const cfg = loadBtcLedgerConfig();
return {
configPath: configPath(),
chainId: cfg.chainId,
officeId: cfg.officeId,
amountBtc: cfg.amountBtc,
amountSats: cfg.amountSats,
requiredHeadroomUsd: cfg.requiredHeadroomUsd,
token: cfg.token,
recipients: cfg.recipients,
fineractGl: cfg.fineractGl,
oracle: cfg.oracle,
dodoEvm: cfg.dodoEvm,
pmmPools: cfg.pmmPools,
quoteTokens: cfg.quoteTokens,
rpc: cfg.rpc,
explorerLinks: {
cbtc: `${cfg.rpc.explorer}/address/${cfg.token.address}`,
pmmIntegration: `${cfg.rpc.explorer}/address/${cfg.dodoEvm.pmmIntegration}`,
primaryRecipient: `${cfg.rpc.explorer}/address/${cfg.recipients.primaryMintTarget}`,
poolCbtcCusdc: `${cfg.rpc.explorer}/address/${cfg.pmmPools.cBTC_cUSDC}`,
},
};
}
function clearBtcLedgerConfigCache() {
cached = null;
}

View File

@@ -14,6 +14,7 @@ const config_1 = require("../config");
const fineract_1 = require("../adapters/fineract");
const omnl_1 = require("../adapters/omnl");
const pricing_1 = require("../adapters/pricing");
const btc_ledger_config_1 = require("../btc-ledger-config");
const settlement_store_1 = require("../store/settlement-store");
const MIN_CONFIRMATIONS = Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6);
/** Env SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE overrides production JSON when set. */
@@ -183,13 +184,15 @@ async function processBtcL1Settlement(input) {
advance('HYBX_RAIL_DISPATCHED');
advance('ISO20022_ARCHIVED');
// 3) Mint cBTC in satoshi-accurate BTC decimal amount
const btcLedger = (0, btc_ledger_config_1.loadBtcLedgerConfig)();
const mint = await (0, omnl_1.requestTokenMint)({
lineId: 'BTC-M2',
lineId: btcLedger.token.lineId,
amount: btcAmount,
recipient: req.recipientAddress,
settlementRef: record.settlementId,
dryRun: !allowChainMintExecute(),
symbol: 'cBTC',
symbol: btcLedger.token.symbol,
tokenAddress: btcLedger.token.address,
});
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
advance('SETTLED');

View File

@@ -81,6 +81,7 @@ async function processExternalTransfer(input) {
beneficiaryName: req.beneficiaryName,
remittanceInfo: req.remittanceInfo,
rail: req.rail,
debtorIban: req.debtorIban,
});
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
}

View File

@@ -141,6 +141,7 @@ async function processZBankM0ToM4(input) {
beneficiaryName: req.beneficiaryName,
remittanceInfo: req.remittanceInfo,
rail: req.rail,
debtorIban: req.debtorIban,
});
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
}

View File

@@ -1,7 +1,64 @@
import axios from 'axios';
import { createHmac } from 'crypto';
import { loadConfig } from '../config';
import { loadHybxConfig } from '@dbis/integration-foundation';
/** RFC1918 / loopback hosts — not reachable from public VPS deploys (e.g. secure.omdnl.org). */
function isPrivateSidecarHost(url: string): boolean {
try {
const host = new URL(url).hostname.toLowerCase();
if (host === 'localhost' || host.endsWith('.local')) return true;
if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host)) return true;
const m = /^172\.(\d+)\./.exec(host);
if (m) {
const second = parseInt(m[1], 10);
if (second >= 16 && second <= 31) return true;
}
return false;
} catch {
return false;
}
}
/** Resolve outbound payment URL — public HYBX RTGS API, not LAN sidecar unless explicitly allowed. */
export function resolveHybxPaymentsUrl(baseUrl: string): string {
const explicit = process.env.HYBX_PAYMENTS_URL?.trim();
if (explicit) return explicit.replace(/\/$/, '');
const hybxApi = process.env.HYBX_API?.trim().replace(/\/$/, '');
if (hybxApi) return `${hybxApi}/rtgs/payments`;
const sidecar = process.env.HYBX_MIFOS_FINERACT_SIDECAR_URL?.trim();
const allowLan = process.env.HYBX_ALLOW_LAN_SIDECAR === '1';
if (sidecar && (allowLan || !isPrivateSidecarHost(sidecar))) {
return `${sidecar.replace(/\/$/, '')}/v1/rtgs/payments`;
}
return `${baseUrl.replace(/\/$/, '')}/v1/rtgs/payments`;
}
function hybxWebhookSecret(): string {
return (
process.env.HYBX_WEBHOOK_SECRET?.trim() ||
process.env.HYBX_SIGNING_SECRET?.trim() ||
''
);
}
/** HYBX RTGS API: X-API-Key + X-Timestamp + X-Signature = HMAC-SHA256(secret, raw JSON body). */
export function buildHybxRtgsHeaders(bodyJson: string, apiKey: string): Record<string, string> {
const secret = hybxWebhookSecret();
if (!secret) {
throw new Error('HYBX_WEBHOOK_SECRET required for signed RTGS dispatch');
}
const timestamp = Math.floor(Date.now() / 1000).toString();
return {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
'X-Timestamp': timestamp,
'X-Signature': createHmac('sha256', secret).update(bodyJson).digest('hex'),
};
}
/** Production HYBX rail client for real settlement dispatch */
export class HybxProductionRail {
private readonly baseUrl: string;
@@ -18,7 +75,8 @@ export class HybxProductionRail {
throw new Error(
'HYBX production requires SETTLEMENT_ALLOW_HYBX_PRODUCTION=1 or production.allowHybxProduction in config',
);
} this.baseUrl = cfg.baseUrl.replace(/\/$/, '');
}
this.baseUrl = cfg.baseUrl.replace(/\/$/, '');
this.apiKey = cfg.apiKey;
this.clientSecret = cfg.clientSecret;
}
@@ -32,35 +90,50 @@ export class HybxProductionRail {
beneficiaryName: string;
remittanceInfo: string;
rail: string;
debtorIban?: string;
}): Promise<{ paymentId: string; status: string }> {
const sidecar = process.env.HYBX_MIFOS_FINERACT_SIDECAR_URL?.replace(/\/$/, '');
const url = sidecar ? `${sidecar}/v1/rtgs/payments` : `${this.baseUrl}/v1/payments`;
const { data } = await axios.post(
url,
{
externalId: payload.idempotencyKey,
amount: payload.amount,
currency: payload.currency,
beneficiary: {
iban: payload.creditorIban,
bic: payload.creditorBic,
name: payload.beneficiaryName,
},
remittanceInformation: payload.remittanceInfo,
rail: payload.rail,
const url = resolveHybxPaymentsUrl(this.baseUrl);
const body: Record<string, unknown> = {
externalId: payload.idempotencyKey,
amount: payload.amount,
currency: payload.currency,
beneficiary: {
iban: payload.creditorIban,
bic: payload.creditorBic,
name: payload.beneficiaryName,
},
{
headers: {
remittanceInformation: payload.remittanceInfo,
rail: payload.rail,
};
if (payload.debtorIban) body.debtorIban = payload.debtorIban;
const bodyJson = JSON.stringify(body);
const useRtgsSignedApi = Boolean(process.env.HYBX_API?.trim() || process.env.HYBX_PAYMENTS_URL?.trim());
const headers = useRtgsSignedApi
? buildHybxRtgsHeaders(bodyJson, this.apiKey)
: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
Authorization: `Bearer ${this.clientSecret}`,
},
timeout: 120000,
},
);
return {
paymentId: String(data.paymentId ?? data.id ?? payload.idempotencyKey),
status: String(data.status ?? 'DISPATCHED'),
};
};
if (useRtgsSignedApi && !hybxWebhookSecret()) {
throw new Error('HYBX_WEBHOOK_SECRET required for signed RTGS dispatch (HYBX_API set)');
}
try {
const { data } = await axios.post(url, bodyJson, { headers, timeout: 120000 });
return {
paymentId: String(data.paymentId ?? data.id ?? payload.idempotencyKey),
status: String(data.status ?? 'DISPATCHED'),
};
} catch (e) {
const ax = e as { response?: { status?: number; data?: unknown }; message?: string };
const detail =
ax.response?.data != null
? JSON.stringify(ax.response.data).slice(0, 400)
: ax.message ?? String(e);
throw new Error(`HYBX payment failed (${url}): ${detail}`);
}
}
}

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { resolveTokenAggregationBase } from './price-service';
export async function archiveIso20022(payload: {
messageType: string;
@@ -6,7 +7,7 @@ export async function archiveIso20022(payload: {
raw: string;
settlementRef: string;
}): Promise<{ messageId: string }> {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const base = resolveTokenAggregationBase();
const key = process.env.OMNL_API_KEY || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (key) headers.Authorization = `Bearer ${key}`;
@@ -33,7 +34,7 @@ export async function requestTokenMint(payload: {
symbol?: string;
tokenAddress?: string;
}): Promise<{ txHash?: string; status: string }> {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const base = resolveTokenAggregationBase();
const key = process.env.OMNL_API_KEY || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (key) headers.Authorization = `Bearer ${key}`;
@@ -66,7 +67,7 @@ export async function requestTokenTransfer(payload: {
dryRun: boolean;
symbol?: string;
}): Promise<{ txHash?: string; status: string }> {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const base = resolveTokenAggregationBase();
const key = process.env.OMNL_API_KEY || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (key) headers.Authorization = `Bearer ${key}`;
@@ -117,7 +118,7 @@ export async function requestFiatLpToLiquidity(payload: {
settlementRef: string;
dryRun: boolean;
}): Promise<FiatLpLiquidityAdapterResult> {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const base = resolveTokenAggregationBase();
const key = process.env.OMNL_API_KEY || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (key) headers.Authorization = `Bearer ${key}`;

View File

@@ -0,0 +1,15 @@
/**
* Chain 138 spot-price / oracle HTTP base.
* Pricing uses CHAIN138_ORACLE_URL (Python oracle, port 3500) when set;
* falls back to TOKEN_AGGREGATION_URL for gradual cutover.
*/
export function resolveChain138OracleBase(): string {
const oracle = (process.env.CHAIN138_ORACLE_URL || process.env.ORACLE_PUBLISHER_URL || '').replace(/\/$/, '');
if (oracle) return oracle;
return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3500').replace(/\/$/, '');
}
/** token-aggregation base for OMNL mint/transfer/ISO20022 (not spot prices). */
export function resolveTokenAggregationBase(): string {
return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
}

View File

@@ -1,6 +1,7 @@
import axios from 'axios';
import { resolveChain138OracleBase } from './price-service';
import { loadBtcLedgerConfig } from '../btc-ledger-config';
const CBTC_ADDRESS = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
const CHAIN_138 = 138;
export type LiveBtcUsdQuote = {
@@ -10,8 +11,12 @@ export type LiveBtcUsdQuote = {
tokenAddress: string;
};
function tokenAggregationBase(): string {
return (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
function cbtcAddress(): string {
try {
return loadBtcLedgerConfig().token.address.toLowerCase();
} catch {
return '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
}
}
/**
@@ -19,14 +24,14 @@ function tokenAggregationBase(): string {
* production must not fall back to the $90k repo snapshot.
*/
export async function resolveLiveBtcUsd(): Promise<LiveBtcUsdQuote> {
const base = tokenAggregationBase();
const base = resolveChain138OracleBase();
const key = process.env.OMNL_API_KEY || '';
const headers: Record<string, string> = { Accept: 'application/json' };
if (key) headers.Authorization = `Bearer ${key}`;
const url =
`${base}/api/v1/prices/metamask/v2/chains/${CHAIN_138}/spot-prices` +
`?tokenAddresses=${CBTC_ADDRESS}&vsCurrency=usd`;
`?tokenAddresses=${cbtcAddress()}&vsCurrency=usd`;
let data: unknown;
try {
@@ -49,13 +54,14 @@ export async function resolveLiveBtcUsd(): Promise<LiveBtcUsdQuote> {
throw err;
}
const map = (data ?? {}) as Record<string, Record<string, number>>;
const row = map[CBTC_ADDRESS.toLowerCase()] ?? map[CBTC_ADDRESS];
const addr = cbtcAddress();
const row = map[addr] ?? map[addr.toLowerCase()];
const rate = row?.usd ?? row?.USD;
if (!(typeof rate === 'number' && Number.isFinite(rate) && rate > 0)) {
throw new Error(
'Live BTC/USD price unresolved — settlement blocked (OMNL_REQUIRE_LIVE_BTC_PRICE). ' +
'Ensure token-aggregation FX refresh / CoinGecko is reachable.',
'Ensure chain138-oracle (CHAIN138_ORACLE_URL) or token-aggregation FX refresh is reachable.',
);
}
@@ -79,8 +85,8 @@ export async function resolveLiveBtcUsd(): Promise<LiveBtcUsdQuote> {
return {
btcUsdRate: rate,
source: 'token-aggregation/metamask-spot-prices',
source: 'chain138-oracle/metamask-spot-prices',
asOf: new Date().toISOString(),
tokenAddress: CBTC_ADDRESS,
tokenAddress: addr,
};
}

View File

@@ -29,6 +29,7 @@ import {
} from '../../workflows/btc-l1-settlement';
import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract';
import { resolveLiveBtcUsd } from '../../adapters/pricing';
import { btcLedgerContractsPayload, loadBtcLedgerConfig } from '../../btc-ledger-config';
import {
dispatchExternalBankOrExchange,
getExternalRailsStatus,
@@ -670,9 +671,19 @@ export function createSettlementRouter(): Router {
res.json({ count: rows.length, settlements: rows });
});
router.get('/btc/contracts', (_req, res) => {
if (!requireApiKey(_req, res)) return;
try {
res.json(btcLedgerContractsPayload());
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/btc/ledger', async (_req, res) => {
if (!requireApiKey(_req, res)) return;
try {
const btcCfg = loadBtcLedgerConfig();
const [balancesHo, balancesO24, quote] = await Promise.all([
fetchGlBalances(1),
fetchGlBalances(officeId),
@@ -690,6 +701,10 @@ export function createSettlementRouter(): Router {
.filter((r) => r.phase === 'SETTLED')
.reduce((sum, r) => sum + (r.btcSettlement?.amountSats ?? 0), 0);
const m2 = parseFloat(balancesO24['2200'] ?? '0');
const m3 = parseFloat(balancesO24['2300'] ?? '0');
const headroomUsd = m2 - m3;
res.json({
asOf: new Date().toISOString(),
gl: {
@@ -698,6 +713,16 @@ export function createSettlementRouter(): Router {
m2Broad2200: balancesO24['2200'] ?? '0',
m3Token2300: balancesO24['2300'] ?? '0',
},
headroomUsd: headroomUsd.toFixed(2),
requiredHeadroomUsd: btcCfg.requiredHeadroomUsd,
mint1000BtcAllowed: headroomUsd >= btcCfg.requiredHeadroomUsd,
contracts: {
cbtc: btcCfg.token.address,
lineId: btcCfg.token.lineId,
primaryRecipient: btcCfg.recipients.primaryMintTarget,
dodoPmmIntegration: btcCfg.dodoEvm.pmmIntegration,
pmmPools: btcCfg.pmmPools,
},
btcUsd:
'btcUsdRate' in quote
? { rate: quote.btcUsdRate, source: quote.source, asOf: quote.asOf }

View File

@@ -0,0 +1,128 @@
import fs from 'fs';
import path from 'path';
export type BtcLedgerConfig = {
version: string;
chainId: number;
officeId: number;
amountBtc: number;
amountSats: number;
btcUsdPolicyRate: number;
requiredHeadroomUsd: number;
token: {
symbol: string;
address: string;
decimals: number;
omnlLine: string;
lineId: string;
};
recipients: {
primaryMintTarget: string;
existingMintHolder1000: string;
};
fineractGl: Record<string, { glCode: string; officeId: number; memo?: string }>;
oracle: Record<string, string>;
dodoEvm: Record<string, string>;
pmmPools: Record<string, string>;
quoteTokens: Record<string, string>;
rpc: { envVar: string; public: string; explorer: string };
settlementApi: Record<string, string>;
};
let cached: BtcLedgerConfig | null = null;
function repoRoot(): string {
return path.resolve(__dirname, '../../../');
}
function configPath(): string {
const rel =
process.env.OMNL_BTC_LEDGER_CONFIG ||
process.env.BTC_L1_LEDGER_CONFIG ||
'config/btc-l1-ledger-1000.chain138.v1.json';
return path.isAbsolute(rel) ? rel : path.join(repoRoot(), rel.replace(/^\//, ''));
}
export function loadBtcLedgerConfig(): BtcLedgerConfig {
if (cached) return cached;
const raw = JSON.parse(fs.readFileSync(configPath(), 'utf8')) as BtcLedgerConfig;
cached = applyEnvOverrides(raw);
return cached;
}
function envAddr(key: string, fallback: string): string {
const v = process.env[key]?.trim();
return v || fallback;
}
/** Env overrides for deployment without editing JSON. */
function applyEnvOverrides(cfg: BtcLedgerConfig): BtcLedgerConfig {
return {
...cfg,
token: {
...cfg.token,
address: envAddr('CBTC_ADDRESS_138', cfg.token.address),
lineId: process.env.OMNL_BTC_LINE_ID?.trim() || cfg.token.lineId,
},
recipients: {
primaryMintTarget: envAddr(
'BTC_L1_RECIPIENT_ADDRESS',
envAddr('BTC_E2E_RECIPIENT', cfg.recipients.primaryMintTarget),
),
existingMintHolder1000: envAddr(
'BTC_EXISTING_MINT_HOLDER',
cfg.recipients.existingMintHolder1000,
),
},
oracle: {
...cfg.oracle,
aggregator: envAddr('AGGREGATOR_ADDRESS', envAddr('ORACLE_AGGREGATOR_ADDRESS', cfg.oracle.aggregator)),
proxy: envAddr('ORACLE_PROXY_ADDRESS', cfg.oracle.proxy),
oraclePriceFeed: envAddr('ORACLE_PRICE_FEED', cfg.oracle.oraclePriceFeed),
reserveSystem: envAddr('RESERVE_SYSTEM', cfg.oracle.reserveSystem),
priceFeedKeeper: envAddr('PRICE_FEED_KEEPER_ADDRESS', cfg.oracle.priceFeedKeeper),
},
dodoEvm: {
...cfg.dodoEvm,
pmmIntegration: envAddr(
'DODO_PMM_INTEGRATION',
envAddr('CHAIN_138_DODO_PMM_INTEGRATION', cfg.dodoEvm.pmmIntegration),
),
},
pmmPools: {
cBTC_cUSDT: envAddr('CHAIN138_POOL_CBTC_CUSDT', cfg.pmmPools.cBTC_cUSDT),
cBTC_cUSDC: envAddr('CHAIN138_POOL_CBTC_CUSDC', cfg.pmmPools.cBTC_cUSDC),
cBTC_cXAUC: envAddr('CHAIN138_POOL_CBTC_CXAUC', cfg.pmmPools.cBTC_cXAUC),
},
};
}
export function btcLedgerContractsPayload(): Record<string, unknown> {
const cfg = loadBtcLedgerConfig();
return {
configPath: configPath(),
chainId: cfg.chainId,
officeId: cfg.officeId,
amountBtc: cfg.amountBtc,
amountSats: cfg.amountSats,
requiredHeadroomUsd: cfg.requiredHeadroomUsd,
token: cfg.token,
recipients: cfg.recipients,
fineractGl: cfg.fineractGl,
oracle: cfg.oracle,
dodoEvm: cfg.dodoEvm,
pmmPools: cfg.pmmPools,
quoteTokens: cfg.quoteTokens,
rpc: cfg.rpc,
explorerLinks: {
cbtc: `${cfg.rpc.explorer}/address/${cfg.token.address}`,
pmmIntegration: `${cfg.rpc.explorer}/address/${cfg.dodoEvm.pmmIntegration}`,
primaryRecipient: `${cfg.rpc.explorer}/address/${cfg.recipients.primaryMintTarget}`,
poolCbtcCusdc: `${cfg.rpc.explorer}/address/${cfg.pmmPools.cBTC_cUSDC}`,
},
};
}
export function clearBtcLedgerConfigCache(): void {
cached = null;
}

View File

@@ -42,7 +42,7 @@ export type MiddlewareConfig = {
defaultCurrency: string;
rails: {
swift: { enabled: boolean; listenerUrl: string };
hybx: { enabled: boolean; sidecarUrl: string };
hybx: { enabled: boolean; sidecarUrl?: string; publicBaseUrl?: string };
chain138: { enabled: boolean; rpcEnv: string; defaultLineId: string };
multiChain?: {
enabled: boolean;

View File

@@ -21,6 +21,7 @@ import {
} from '../adapters/fineract';
import { requestTokenMint } from '../adapters/omnl';
import { resolveLiveBtcUsd } from '../adapters/pricing';
import { loadBtcLedgerConfig } from '../btc-ledger-config';
import { settlementStore } from '../store/settlement-store';
const MIN_CONFIRMATIONS = Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6);
@@ -214,13 +215,15 @@ export async function processBtcL1Settlement(
advance('ISO20022_ARCHIVED');
// 3) Mint cBTC in satoshi-accurate BTC decimal amount
const btcLedger = loadBtcLedgerConfig();
const mint = await requestTokenMint({
lineId: 'BTC-M2',
lineId: btcLedger.token.lineId,
amount: btcAmount,
recipient: req.recipientAddress,
settlementRef: record.settlementId,
dryRun: !allowChainMintExecute(),
symbol: 'cBTC',
symbol: btcLedger.token.symbol,
tokenAddress: btcLedger.token.address,
});
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
advance('SETTLED');

View File

@@ -97,6 +97,7 @@ export async function processExternalTransfer(
beneficiaryName: req.beneficiaryName,
remittanceInfo: req.remittanceInfo,
rail: req.rail,
debtorIban: req.debtorIban,
});
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
} else {

View File

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