Add optional Cosmos/Engine-X/act-runner templates, CWUSDC/EI-matrix tooling, non-EVM route planner in multi-chain-execution (tests passing), token list and extraction updates, and documentation (MetaMask matrix, GRU/CWUSDC packets). Ignore institutional evidence tarballs/sha256 under reports/status. Validated with: bash scripts/verify/run-all-validation.sh --skip-genesis Co-authored-by: Cursor <cursoragent@cursor.com>
189 lines
5.8 KiB
Python
189 lines
5.8 KiB
Python
"""
|
|
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}"
|
|
)
|