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