Files
neuron-tai/packages/contracts/meshnet_contracts/solana_adapter.py

298 lines
11 KiB
Python

"""Custodial Solana treasury adapter (ADR-0015, US-032/US-033).
The entire on-chain surface is plain SPL token operations against a single
project-owned treasury wallet: read confirmed incoming USDT transfers, send
batched payout transfers, plus devnet helpers to create the mock-USDT mint.
No Anchor programs.
RPC transport is a minimal urllib JSON-RPC client (the installed solana-py
0.40 removed its sync client); signing and instruction building use solders /
spl.token. Import this module only where a cluster is actually configured —
the deterministic local boundary in ``meshnet_contracts`` stays
dependency-free for CI.
"""
from __future__ import annotations
import base64
import json
import os
import urllib.request
from dataclasses import dataclass
from solders.hash import Hash
from solders.keypair import Keypair
from solders.pubkey import Pubkey
from solders.system_program import CreateAccountParams, create_account
from solders.transaction import Transaction
from spl.token.constants import TOKEN_PROGRAM_ID
from spl.token.instructions import (
create_associated_token_account,
get_associated_token_address,
initialize_mint,
mint_to,
transfer_checked,
)
from spl.token.models import (
InitializeMintParams,
MintToParams,
TransferCheckedParams,
)
USDT_DECIMALS = 6 # matches real USDT on Solana mainnet
_MINT_ACCOUNT_SIZE = 82
@dataclass(frozen=True)
class Deposit:
"""A confirmed incoming USDT transfer into the treasury token account."""
signature: str
sender_wallet: str
amount_usdt: float
def load_keypair(path: str) -> Keypair:
"""Load a keypair from a solana-cli style JSON byte-array file."""
with open(os.path.expanduser(path), encoding="utf-8") as fh:
secret = json.load(fh)
return Keypair.from_bytes(bytes(secret))
class RpcClient:
"""Minimal synchronous Solana JSON-RPC client."""
def __init__(self, url: str, timeout: float = 30.0) -> None:
self.url = url
self._timeout = timeout
self._request_id = 0
def call(self, method: str, params: list | None = None):
self._request_id += 1
body = json.dumps({
"jsonrpc": "2.0",
"id": self._request_id,
"method": method,
"params": params or [],
}).encode()
request = urllib.request.Request(
self.url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=self._timeout) as response:
payload = json.loads(response.read())
if "error" in payload:
raise RuntimeError(f"RPC {method} failed: {payload['error']}")
return payload.get("result")
class SolanaCustodialTreasury:
"""Read deposits into / send payouts from the custodial treasury wallet."""
def __init__(
self,
rpc_url: str,
usdt_mint: str,
treasury_keypair: Keypair | str,
*,
decimals: int = USDT_DECIMALS,
) -> None:
self.rpc = RpcClient(rpc_url)
self._mint = Pubkey.from_string(usdt_mint)
self._treasury = (
load_keypair(treasury_keypair)
if isinstance(treasury_keypair, str)
else treasury_keypair
)
self._decimals = decimals
self._treasury_ata = get_associated_token_address(
self._treasury.pubkey(), self._mint
)
@property
def treasury_wallet(self) -> str:
return str(self._treasury.pubkey())
@property
def treasury_token_account(self) -> str:
return str(self._treasury_ata)
# ---- deposits (US-032) ----
def list_new_deposits(self, is_seen, *, limit: int = 200) -> list[Deposit]:
"""Confirmed incoming USDT transfers whose signature is not yet seen.
``is_seen(signature) -> bool`` lets the caller (the deposit watcher)
dedupe against its ledger, so replayed and re-observed transactions
are credited exactly once.
"""
infos = self.rpc.call(
"getSignaturesForAddress",
[str(self._treasury_ata), {"limit": limit}],
) or []
deposits: list[Deposit] = []
for info in infos:
signature = info.get("signature", "")
if not signature or info.get("err") is not None or is_seen(signature):
continue
deposit = self._parse_deposit(signature)
if deposit is not None:
deposits.append(deposit)
return deposits
def _parse_deposit(self, signature: str) -> Deposit | None:
result = self.rpc.call("getTransaction", [
signature,
{"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0},
])
meta = (result or {}).get("meta")
if not meta:
return None
pre = {b["accountIndex"]: b for b in meta.get("preTokenBalances") or []}
post = {b["accountIndex"]: b for b in meta.get("postTokenBalances") or []}
treasury_delta = 0
sender_wallet: str | None = None
for index, balance in post.items():
if balance.get("mint") != str(self._mint):
continue
before = pre.get(index)
before_amount = int(before["uiTokenAmount"]["amount"]) if before else 0
delta = int(balance["uiTokenAmount"]["amount"]) - before_amount
owner = balance.get("owner")
if owner == self.treasury_wallet and delta > 0:
treasury_delta = delta
elif delta < 0 and owner:
sender_wallet = owner
if sender_wallet is None:
# a token account emptied and closed may appear only in pre balances
for index, balance in pre.items():
if balance.get("mint") != str(self._mint):
continue
if index not in post and balance.get("owner"):
sender_wallet = balance["owner"]
if treasury_delta <= 0 or sender_wallet is None:
return None
return Deposit(
signature=signature,
sender_wallet=sender_wallet,
amount_usdt=treasury_delta / (10 ** self._decimals),
)
# ---- payouts (US-033) ----
def send_payouts(self, payouts: list[tuple[str, float]]) -> str:
"""Send one batched transaction of USDT transfers treasury → wallets.
Creates the recipient's associated token account when missing (fee
paid by the treasury). Returns the transaction signature.
"""
if not payouts:
raise ValueError("payouts must be non-empty")
instructions = []
for wallet, amount in payouts:
recipient = Pubkey.from_string(wallet)
recipient_ata = get_associated_token_address(recipient, self._mint)
if not self._account_exists(recipient_ata):
instructions.append(create_associated_token_account(
payer=self._treasury.pubkey(),
owner=recipient,
mint=self._mint,
))
instructions.append(transfer_checked(TransferCheckedParams(
program_id=TOKEN_PROGRAM_ID,
source=self._treasury_ata,
mint=self._mint,
dest=recipient_ata,
owner=self._treasury.pubkey(),
amount=int(round(amount * (10 ** self._decimals))),
decimals=self._decimals,
)))
return self.send_instructions(instructions)
# ---- shared plumbing + devnet helpers (scripts/devnet_setup.py) ----
def _account_exists(self, pubkey: Pubkey) -> bool:
result = self.rpc.call("getAccountInfo", [str(pubkey), {"encoding": "base64"}])
return bool(result and result.get("value"))
def send_instructions(self, instructions: list, extra_signers: list | None = None) -> str:
blockhash_result = self.rpc.call("getLatestBlockhash")
blockhash = Hash.from_string(blockhash_result["value"]["blockhash"])
transaction = Transaction.new_signed_with_payer(
instructions,
self._treasury.pubkey(),
[self._treasury, *(extra_signers or [])],
blockhash,
)
encoded = base64.b64encode(bytes(transaction)).decode()
return self.rpc.call("sendTransaction", [
encoded,
{"encoding": "base64", "preflightCommitment": "confirmed"},
])
def get_sol_balance(self, wallet: str | None = None) -> float:
result = self.rpc.call("getBalance", [wallet or self.treasury_wallet])
return result["value"] / 1e9
def request_airdrop(self, sol: float = 2.0) -> str:
return self.rpc.call(
"requestAirdrop", [self.treasury_wallet, int(sol * 1e9)]
)
def create_mock_usdt_mint(self) -> tuple["SolanaCustodialTreasury", str]:
"""Create a fresh 6-decimal mock-USDT mint (treasury = mint authority).
Returns a new adapter bound to the created mint plus its address.
"""
mint_keypair = Keypair()
rent = self.rpc.call(
"getMinimumBalanceForRentExemption", [_MINT_ACCOUNT_SIZE]
)
instructions = [
create_account(CreateAccountParams(
from_pubkey=self._treasury.pubkey(),
to_pubkey=mint_keypair.pubkey(),
lamports=rent,
space=_MINT_ACCOUNT_SIZE,
owner=TOKEN_PROGRAM_ID,
)),
initialize_mint(InitializeMintParams(
program_id=TOKEN_PROGRAM_ID,
mint=mint_keypair.pubkey(),
decimals=self._decimals,
mint_authority=self._treasury.pubkey(),
freeze_authority=None,
)),
]
self.send_instructions(instructions, extra_signers=[mint_keypair])
fresh = SolanaCustodialTreasury(
self.rpc.url,
str(mint_keypair.pubkey()),
self._treasury,
decimals=self._decimals,
)
return fresh, str(mint_keypair.pubkey())
def ensure_token_account(self, wallet: str) -> str:
owner = Pubkey.from_string(wallet)
ata = get_associated_token_address(owner, self._mint)
if not self._account_exists(ata):
self.send_instructions([create_associated_token_account(
payer=self._treasury.pubkey(), owner=owner, mint=self._mint,
)])
return str(ata)
def mint_mock_usdt(self, wallet: str, amount: float) -> str:
"""Mint mock USDT to a wallet (devnet only — treasury is mint authority)."""
ata = self.ensure_token_account(wallet)
return self.send_instructions([mint_to(MintToParams(
program_id=TOKEN_PROGRAM_ID,
mint=self._mint,
dest=Pubkey.from_string(ata),
mint_authority=self._treasury.pubkey(),
amount=int(round(amount * (10 ** self._decimals))),
))])