|
|
|
@ -50,8 +50,13 @@ SQUARE_API_VERSION = "2026-01-22" |
|
|
|
SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log") |
|
|
|
SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log") |
|
|
|
ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") |
|
|
|
ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") |
|
|
|
CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") |
|
|
|
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_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") |
|
|
|
RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arb1.arbitrum.io/rpc") |
|
|
|
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"))) |
|
|
|
options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) |
|
|
|
return options |
|
|
|
return options |
|
|
|
|
|
|
|
|
|
|
|
def get_rpc_url_for_chain(chain_name): |
|
|
|
def get_rpc_urls_for_chain(chain_name): |
|
|
|
chain = str(chain_name or "").lower() |
|
|
|
chain = str(chain_name or "").lower() |
|
|
|
if chain == "ethereum": |
|
|
|
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": |
|
|
|
if chain == "arbitrum": |
|
|
|
return RPC_ARBITRUM_URL |
|
|
|
return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] |
|
|
|
return None |
|
|
|
return [] |
|
|
|
|
|
|
|
|
|
|
|
def rpc_call(rpc_url, method, params): |
|
|
|
def rpc_call_any(rpc_urls, method, params): |
|
|
|
payload = json.dumps({ |
|
|
|
last_error = None |
|
|
|
"jsonrpc": "2.0", |
|
|
|
|
|
|
|
"id": 1, |
|
|
|
|
|
|
|
"method": method, |
|
|
|
|
|
|
|
"params": params, |
|
|
|
|
|
|
|
}).encode("utf-8") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
req = urllib.request.Request( |
|
|
|
for rpc_url in rpc_urls: |
|
|
|
rpc_url, |
|
|
|
try: |
|
|
|
data=payload, |
|
|
|
payload = json.dumps({ |
|
|
|
headers={ |
|
|
|
"jsonrpc": "2.0", |
|
|
|
"Content-Type": "application/json", |
|
|
|
"id": 1, |
|
|
|
"Accept": "application/json", |
|
|
|
"method": method, |
|
|
|
"User-Agent": "otb-billing-rpc/0.1", |
|
|
|
"params": params, |
|
|
|
}, |
|
|
|
}).encode("utf-8") |
|
|
|
method="POST" |
|
|
|
|
|
|
|
) |
|
|
|
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: |
|
|
|
with urllib.request.urlopen(req, timeout=20) as resp: |
|
|
|
data = json.loads(resp.read().decode("utf-8")) |
|
|
|
data = json.loads(resp.read().decode("utf-8")) |
|
|
|
|
|
|
|
|
|
|
|
if isinstance(data, dict) and data.get("error"): |
|
|
|
if isinstance(data, dict) and data.get("error"): |
|
|
|
raise RuntimeError(str(data["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): |
|
|
|
def _to_base_units(amount_text, decimals): |
|
|
|
amount_dec = Decimal(str(amount_text)) |
|
|
|
amount_dec = Decimal(str(amount_text)) |
|
|
|
@ -317,41 +335,58 @@ def parse_erc20_transfer_input(input_data): |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
def verify_wallet_transaction(option, tx_hash): |
|
|
|
def verify_wallet_transaction(option, tx_hash): |
|
|
|
rpc_url = get_rpc_url_for_chain(option.get("chain")) |
|
|
|
rpc_urls = get_rpc_urls_for_chain(option.get("chain")) |
|
|
|
if not rpc_url: |
|
|
|
if not rpc_urls: |
|
|
|
raise RuntimeError("No RPC configured for chain") |
|
|
|
raise RuntimeError("No RPC configured for chain") |
|
|
|
|
|
|
|
|
|
|
|
tx = rpc_call(rpc_url, "eth_getTransactionByHash", [tx_hash]) |
|
|
|
seen_result = None |
|
|
|
if not tx: |
|
|
|
last_not_found = False |
|
|
|
raise RuntimeError("Transaction hash not found on RPC") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
wallet_to = str(option.get("wallet_address") or "").lower() |
|
|
|
for rpc_url in rpc_urls: |
|
|
|
expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) |
|
|
|
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": |
|
|
|
wallet_to = str(option.get("wallet_address") or "").lower() |
|
|
|
tx_to = str(tx.get("to") or "").lower() |
|
|
|
expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) |
|
|
|
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") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return { |
|
|
|
if option.get("asset_type") == "native": |
|
|
|
"rpc_url": rpc_url, |
|
|
|
tx_to = str(tx.get("to") or "").lower() |
|
|
|
"tx": tx, |
|
|
|
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): |
|
|
|
def square_amount_to_cents(value): |
|
|
|
return int((to_decimal(value) * 100).quantize(Decimal("1"))) |
|
|
|
return int((to_decimal(value) * 100).quantize(Decimal("1"))) |
|
|
|
|