feat: USDT reward system — billing ledger, devnet treasury, settlement loop, forfeiture PoW, tracker dashboard (US-030…US-035, ADR-0015)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
297
packages/contracts/meshnet_contracts/solana_adapter.py
Normal file
297
packages/contracts/meshnet_contracts/solana_adapter.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""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))),
|
||||
))])
|
||||
Reference in New Issue
Block a user