"""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))), ))])