diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 9f71450..08cffe9 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,3 +1,26 @@ +# PROJECT_STATE - OTB Billing + +## v1.3.0 - 2026-05-17 + +Current state: +- OTB Billing health page is working at /health. +- /health.json is working. +- Health page now includes: + - Operations Bal card for 0x44f6c44C42e6ae0392E7289F032384C0d37F56D5 + - Treasury Bal card for 0xbe1fdc8c69f712d62cfcd3bf23f636de1dbd213f +- Both cards report payment-asset balances for USDC, ETH, ETHO, EGAZ, and ETI. +- Existing wallet_balances JSON key remains as a backward-compatible alias for operations_balances. +- Service name: otb_billing.service +- Runtime host: outsidethedb +- App port: 5050 +- Project path: /home/def/otb_billing + +Recent verification: +- /health renders the new Operations Bal and Treasury Bal cards. +- ETHO, EGAZ, and ETI balances resolve through existing project RPCs. +- Arbitrum USDC and Ethereum ETH rows now use fallback-capable RPC helpers. +- OTB Operations wallet alias has been added in both Etica and ETHO Blockscout explorers. + ## v0.6.2 - Service Templates Stabilization - Verified template CRUD working diff --git a/README.md b/README.md index 488e717..d38bfe1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,25 @@ +# OTB Billing - v1.3.0 + +Build date: 2026-05-17 + +## v1.3.0 changes + +- Added health-page wallet balance cards for the OTB Operations wallet and OTB Treasury wallet. +- Renamed the original wallet card to "Operations Bal". +- Added matching "Treasury Bal" card. +- Balance cards show the configured payment assets: + - USDC on Arbitrum + - ETH on Ethereum + - ETHO on Etho Protocol + - EGAZ on Etica + - ETI on Etica +- Added the same balance data to /health.json using: + - operations_balances + - treasury_balances + - wallet_balances retained as backward-compatible alias for operations_balances. +- Added RPC fallback handling for public Arbitrum/Ethereum RPCs so health checks are less likely to fail on single-provider HTTP 403 responses. +- Confirmed explorer aliases for the OTB Operations wallet on Etica and ETHO Blockscout explorers. + ## v1.2.0 (2026-05-03) - Platform-level Service Agreement - Improved privacy and data messaging diff --git a/VERSION b/VERSION index 79127d8..18fa8e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.2.0 +v1.3.0 diff --git a/backend/health.py b/backend/health.py index 7b1ee41..e39a93c 100644 --- a/backend/health.py +++ b/backend/health.py @@ -125,13 +125,245 @@ def _health_payload(app): return payload, http_code + + +def _wallet_balance_format(raw_value, decimals): + try: + from decimal import Decimal, getcontext + getcontext().prec = 60 + value = Decimal(int(raw_value)) / (Decimal(10) ** int(decimals)) + fixed = f"{value:.8f}".rstrip("0").rstrip(".") + return fixed if fixed else "0" + except Exception: + return "error" + + +def _split_rpc_urls(value, defaults): + urls = [] + + if value: + for item in str(value).replace(",", " ").split(): + item = item.strip() + if item: + urls.append(item) + + for item in defaults: + if item and item not in urls: + urls.append(item) + + return urls + + +def _wallet_rpc_call_one(rpc_url, method, params, timeout=5): + import json + import urllib.request + + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "OTB-Billing-Health/1.0", + }, + method="POST", + ) + + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if data.get("error"): + raise RuntimeError(str(data["error"])) + + return data.get("result") + + +def _wallet_rpc_call(rpc_urls, method, params, timeout=5): + last_error = None + + if isinstance(rpc_urls, str): + rpc_urls = [rpc_urls] + + for rpc_url in rpc_urls: + try: + return _wallet_rpc_call_one(rpc_url, method, params, timeout=timeout) + except Exception as exc: + last_error = f"{rpc_url}: {exc}" + + raise RuntimeError(last_error or "No RPC URLs available") + + +def _wallet_native_balance(rpc_urls, wallet_address, decimals=18): + result = _wallet_rpc_call(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + return _wallet_balance_format(int(result or "0x0", 16), decimals) + + +def _wallet_erc20_balance(rpc_urls, token_contract, wallet_address, decimals): + # ERC20 balanceOf(address) selector: 0x70a08231 + clean_addr = wallet_address.lower().replace("0x", "").rjust(64, "0") + data = "0x70a08231" + clean_addr + result = _wallet_rpc_call( + rpc_urls, + "eth_call", + [{"to": token_contract, "data": data}, "latest"], + ) + return _wallet_balance_format(int(result or "0x0", 16), decimals) + + +def _wallet_balances(wallet_address=None, label=None): + import os + + wallet = wallet_address or os.environ.get( + "OTB_OPERATIONS_WALLET", + "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5", + ) + + arbitrum_rpcs = _split_rpc_urls( + os.environ.get("RPC_ARBITRUM_URLS") or os.environ.get("RPC_ARBITRUM_URL"), + [ + "https://arb1.arbitrum.io/rpc", + "https://arbitrum-one-rpc.publicnode.com", + "https://arbitrum.drpc.org", + ], + ) + + ethereum_rpcs = _split_rpc_urls( + os.environ.get("RPC_ETHEREUM_URLS") or os.environ.get("RPC_ETHEREUM_URL"), + [ + "https://ethereum-rpc.publicnode.com", + "https://cloudflare-eth.com", + "https://eth.llamarpc.com", + ], + ) + + etho_rpcs = _split_rpc_urls( + os.environ.get("RPC_ETHO_URLS") or os.environ.get("RPC_ETHO_URL"), + [ + "https://rpc.ethoprotocol.com", + "https://rpc4.ethoprotocol.com", + ], + ) + + etica_rpcs = _split_rpc_urls( + os.environ.get("RPC_ETICA_URLS") or os.environ.get("RPC_ETICA_URL"), + [ + "https://rpc.etica-stats.org", + "https://eticamainnet.eticaprotocol.org", + ], + ) + + assets = [ + { + "coin": "USDC", + "chain": "Arbitrum", + "kind": "erc20", + "decimals": 6, + "rpc": arbitrum_rpcs, + "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + { + "coin": "ETH", + "chain": "Ethereum", + "kind": "native", + "decimals": 18, + "rpc": ethereum_rpcs, + }, + { + "coin": "ETHO", + "chain": "Etho", + "kind": "native", + "decimals": 18, + "rpc": etho_rpcs, + }, + { + "coin": "EGAZ", + "chain": "Etica", + "kind": "native", + "decimals": 18, + "rpc": etica_rpcs, + }, + { + "coin": "ETI", + "chain": "Etica", + "kind": "erc20", + "decimals": 18, + "rpc": etica_rpcs, + "token": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + }, + ] + + rows = [] + for asset in assets: + row = { + "coin": asset["coin"], + "chain": asset["chain"], + "balance": "error", + "ok": False, + "error": None, + } + + try: + if asset["kind"] == "native": + row["balance"] = _wallet_native_balance( + asset["rpc"], + wallet, + asset["decimals"], + ) + else: + row["balance"] = _wallet_erc20_balance( + asset["rpc"], + asset["token"], + wallet, + asset["decimals"], + ) + row["ok"] = True + except Exception as exc: + row["error"] = str(exc)[:220] + + rows.append(row) + + return { + "label": label, + "wallet": wallet, + "assets": rows, + } + + + def register_health_routes(app): @app.route("/health", methods=["GET"]) def health_page(): health, http_code = _health_payload(app) + health["operations_balances"] = _wallet_balances( + os.environ.get("OTB_OPERATIONS_WALLET", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5"), + "Operations Bal", + ) + health["treasury_balances"] = _wallet_balances( + os.environ.get("OTB_TREASURY_WALLET", "0xbe1fdc8c69f712d62cfcd3bf23f636de1dbd213f"), + "Treasury Bal", + ) + # Backward compatible JSON key for anything already reading health.json. + health["wallet_balances"] = health["operations_balances"] return render_template("health.html", health=health), http_code @app.route("/health.json", methods=["GET"]) def health_json(): health, http_code = _health_payload(app) + health["operations_balances"] = _wallet_balances( + os.environ.get("OTB_OPERATIONS_WALLET", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5"), + "Operations Bal", + ) + health["treasury_balances"] = _wallet_balances( + os.environ.get("OTB_TREASURY_WALLET", "0xbe1fdc8c69f712d62cfcd3bf23f636de1dbd213f"), + "Treasury Bal", + ) + # Backward compatible JSON key for anything already reading health.json. + health["wallet_balances"] = health["operations_balances"] return jsonify(health), http_code diff --git a/templates/health.html b/templates/health.html index 197cc40..dd525fe 100644 --- a/templates/health.html +++ b/templates/health.html @@ -132,6 +132,44 @@

Free: {{ health.disk_root.free_gb }} GB

Used %: {{ health.disk_root.used_percent }}%

+ +
+

Operations Bal

+ {% if health.operations_balances %} + {% for asset in health.operations_balances.assets %} +

+ {{ asset.coin }}: + {% if asset.ok %} + {{ asset.balance }} + {% else %} + error + {% endif %} +

+ {% endfor %} +

Wallet: {{ health.operations_balances.wallet }}

+ {% else %} +

Not available

+ {% endif %} +
+ +
+

Treasury Bal

+ {% if health.treasury_balances %} + {% for asset in health.treasury_balances.assets %} +

+ {{ asset.coin }}: + {% if asset.ok %} + {{ asset.balance }} + {% else %} + error + {% endif %} +

+ {% endfor %} +

Wallet: {{ health.treasury_balances.wallet }}

+ {% else %} +

Not available

+ {% endif %} +