""" Minimal Solana JSON-RPC over HTTP (stdlib only). Some public RPCs return a bare string for ``sendTransaction`` ``result`` without extra fields that ``solana-py``'s ``SendTransactionResp`` expects, which makes ``Client.send_raw_transaction`` panic while deserializing (missing JSON field ``data``). Use :func:`send_transaction_wire` for submission; keep ``solders`` (or ``solana-py``) only for signing and local serialization. """ from __future__ import annotations import base64 import json import time import urllib.error import urllib.request from typing import Any DEFAULT_USER_AGENT = "proxmox-scripts/solana-jsonrpc/1.0" class SolanaJsonRpcError(RuntimeError): """JSON-RPC error object or unexpected HTTP / parse failure.""" def __init__(self, message: str, *, payload: dict[str, Any] | None = None) -> None: super().__init__(message) self.payload = payload def post_json_rpc( rpc_url: str, method: str, params: list[Any], *, request_id: int = 1, timeout_s: float = 90.0, user_agent: str = DEFAULT_USER_AGENT, ) -> dict[str, Any]: body = json.dumps( {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} ).encode("utf-8") req = urllib.request.Request( rpc_url, data=body, headers={"Content-Type": "application/json", "User-Agent": user_agent}, method="POST", ) try: with urllib.request.urlopen(req, timeout=timeout_s) as resp: raw = resp.read().decode("utf-8") except urllib.error.HTTPError as e: try: detail = e.read().decode("utf-8", errors="replace") except Exception: detail = str(e) raise SolanaJsonRpcError(f"HTTP {e.code}: {detail}") from e try: out: dict[str, Any] = json.loads(raw) except json.JSONDecodeError as e: raise SolanaJsonRpcError(f"invalid JSON from RPC: {raw[:500]!r}") from e err = out.get("error") if err: raise SolanaJsonRpcError(f"RPC error: {err}", payload=out) return out def get_latest_blockhash( rpc_url: str, *, commitment: str = "confirmed", timeout_s: float = 30.0 ) -> str: out = post_json_rpc( rpc_url, "getLatestBlockhash", [{"commitment": commitment}], timeout_s=timeout_s, ) try: return str(out["result"]["value"]["blockhash"]) except (KeyError, TypeError) as e: raise SolanaJsonRpcError(f"unexpected getLatestBlockhash shape: {out!r}") from e def get_balance_lamports( rpc_url: str, pubkey_b58: str, *, commitment: str = "confirmed" ) -> int: out = post_json_rpc( rpc_url, "getBalance", [pubkey_b58, {"commitment": commitment}], ) try: return int(out["result"]["value"]) except (KeyError, TypeError, ValueError) as e: raise SolanaJsonRpcError(f"unexpected getBalance shape: {out!r}") from e def send_transaction_wire( rpc_url: str, signed_wire: bytes, *, skip_preflight: bool = False, preflight_commitment: str = "confirmed", max_retries: int | None = None, timeout_s: float = 90.0, ) -> str: """ Submit a fully signed legacy or versioned transaction (wire bytes). Returns base58 transaction signature string from ``result``. """ opts: dict[str, Any] = { "encoding": "base64", "skipPreflight": skip_preflight, "preflightCommitment": preflight_commitment, } if max_retries is not None: opts["maxRetries"] = max_retries params: list[Any] = [base64.b64encode(signed_wire).decode("ascii"), opts] out = post_json_rpc(rpc_url, "sendTransaction", params, timeout_s=timeout_s) result = out.get("result") if not isinstance(result, str): raise SolanaJsonRpcError(f"unexpected sendTransaction result: {out!r}") return result def get_signature_statuses( rpc_url: str, signatures: list[str], *, search_transaction_history: bool = False, ) -> list[dict[str, Any] | None]: """Return one status object (or null) per signature, same order as input.""" if search_transaction_history: params: list[Any] = [signatures, {"searchTransactionHistory": True}] else: params = [signatures] out = post_json_rpc(rpc_url, "getSignatureStatuses", params) try: val = out["result"]["value"] except (KeyError, TypeError) as e: raise SolanaJsonRpcError(f"unexpected getSignatureStatuses shape: {out!r}") from e if not isinstance(val, list): raise SolanaJsonRpcError(f"unexpected getSignatureStatuses value: {val!r}") out_list: list[dict[str, Any] | None] = [] for item in val: if item is None: out_list.append(None) elif isinstance(item, dict): out_list.append(item) else: raise SolanaJsonRpcError(f"unexpected status entry: {item!r}") return out_list def wait_until_signature_confirmed( rpc_url: str, signature: str, *, timeout_s: float = 90.0, poll_interval_s: float = 1.0, ) -> dict[str, Any]: """ Poll ``getSignatureStatuses`` until the signature has a terminal ``err`` or reaches ``confirmationStatus`` of ``confirmed`` / ``finalized``. """ deadline = time.monotonic() + timeout_s last: dict[str, Any] | None = None while time.monotonic() < deadline: statuses = get_signature_statuses(rpc_url, [signature]) st = statuses[0] if statuses else None last = st if st is None: time.sleep(poll_interval_s) continue err = st.get("err") if err: raise SolanaJsonRpcError(f"transaction failed: err={err!r}", payload=st) conf = st.get("confirmationStatus") if conf in ("confirmed", "finalized"): return st time.sleep(poll_interval_s) raise SolanaJsonRpcError( f"timeout waiting for confirmation of {signature!r}; last={last!r}" )