diff --git a/backend/app.py b/backend/app.py index ab291bd..69e176c 100644 --- a/backend/app.py +++ b/backend/app.py @@ -50,8 +50,13 @@ SQUARE_API_VERSION = "2026-01-22" SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log") ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") -RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://cloudflare-eth.com") -RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arb1.arbitrum.io/rpc") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") @@ -258,40 +263,53 @@ def get_invoice_crypto_options(invoice): options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) return options -def get_rpc_url_for_chain(chain_name): +def get_rpc_urls_for_chain(chain_name): chain = str(chain_name or "").lower() if chain == "ethereum": - return RPC_ETHEREUM_URL + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] if chain == "arbitrum": - return RPC_ARBITRUM_URL - return None + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + return [] -def rpc_call(rpc_url, method, params): - payload = json.dumps({ - "jsonrpc": "2.0", - "id": 1, - "method": method, - "params": params, - }).encode("utf-8") +def rpc_call_any(rpc_urls, method, params): + last_error = None - req = urllib.request.Request( - rpc_url, - data=payload, - headers={ - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": "otb-billing-rpc/0.1", - }, - method="POST" - ) + for rpc_url in rpc_urls: + try: + 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-rpc/0.1", + }, + method="POST" + ) - with urllib.request.urlopen(req, timeout=20) as resp: - data = json.loads(resp.read().decode("utf-8")) + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) - if isinstance(data, dict) and data.get("error"): - raise RuntimeError(str(data["error"])) + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err - return (data or {}).get("result") + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") def _to_base_units(amount_text, decimals): amount_dec = Decimal(str(amount_text)) @@ -317,41 +335,58 @@ def parse_erc20_transfer_input(input_data): } def verify_wallet_transaction(option, tx_hash): - rpc_url = get_rpc_url_for_chain(option.get("chain")) - if not rpc_url: + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: raise RuntimeError("No RPC configured for chain") - tx = rpc_call(rpc_url, "eth_getTransactionByHash", [tx_hash]) - if not tx: - raise RuntimeError("Transaction hash not found on RPC") + seen_result = None + last_not_found = False - wallet_to = str(option.get("wallet_address") or "").lower() - expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue - if option.get("asset_type") == "native": - tx_to = str(tx.get("to") or "").lower() - tx_value = int(tx.get("value") or "0x0", 16) - if tx_to != wallet_to: - raise RuntimeError("Transaction destination does not match payment wallet") - if tx_value != expected_units: - raise RuntimeError("Transaction value does not match frozen quote amount") - else: - tx_to = str(tx.get("to") or "").lower() - contract = str(option.get("token_contract") or "").lower() - if tx_to != contract: - raise RuntimeError("Token contract does not match expected asset contract") - parsed = parse_erc20_transfer_input(tx.get("input") or "") - if not parsed: - raise RuntimeError("Transaction input is not a supported ERC20 transfer") - if str(parsed["to"]).lower() != wallet_to: - raise RuntimeError("Token transfer recipient does not match payment wallet") - if int(parsed["amount"]) != expected_units: - raise RuntimeError("Token transfer amount does not match frozen quote amount") + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) - return { - "rpc_url": rpc_url, - "tx": tx, - } + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result def square_amount_to_cents(value): return int((to_decimal(value) * 100).quantize(Decimal("1")))