Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Manage on-chain EVM and Solana wallets: balances, transfers, message signing, and transaction history via Privy Server Wallets.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
wallet.py
1"""2Wallet Tool Wrappers — BaseTool classes for Agent framework.3Delegates to /app/tools/wallet core functions for single-source-of-truth maintenance.4"""56import logging7import os8import time9from core.tool import BaseTool, ToolContext, ToolResult1011# ── Import core wallet functions from /app/tools/wallet ─────────────────────12from tools.wallet import (13_is_fly_machine,14_wallet_request,15_get_wallet_addresses,16_validate_and_clean_rules,17DEBANK_CHAIN_MAP,18)19from core.http_client import proxied_get2021logger = logging.getLogger(__name__)2223EVM_CHAINS = list(DEBANK_CHAIN_MAP.keys())242526def _fly_check():27if not _is_fly_machine():28return ToolResult(success=False, error="Not running on a Fly Machine — wallet unavailable")29return None303132def _proxied_get_with_retry(url, params=None, headers=None, timeout=30, max_retries=3):33"""proxied_get with retry on timeout / 429 / 5xx."""34import requests35last_exc = None36for attempt in range(max_retries):37try:38resp = proxied_get(url, params=params, headers=headers, timeout=timeout)39if resp.status_code == 429 or resp.status_code >= 500:40if attempt < max_retries - 1:41time.sleep(1 * (attempt + 1))42continue43resp.raise_for_status()44return resp45except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:46last_exc = e47if attempt < max_retries - 1:48time.sleep(1)49except requests.exceptions.HTTPError:50raise51except Exception as e:52last_exc = e53break54raise last_exc or Exception("Max retries exceeded")555657# ── Info ─────────────────────────────────────────────────────────────────────5859class WalletInfoTool(BaseTool):60@property61def name(self): return "wallet_info"62@property63def description(self): return "Get all on-chain wallet addresses for this agent (one per chain)."64@property65def parameters(self): return {"type": "object", "properties": {}}6667async def execute(self, ctx: ToolContext, **kw) -> ToolResult:68if err := _fly_check(): return err69try:70return ToolResult(success=True, output=await _wallet_request("GET", "/agent/wallet"))71except Exception as e:72return ToolResult(success=False, error=str(e))737475# ── EVM Balance ──────────────────────────────────────────────────────────────7677class WalletBalanceTool(BaseTool):78@property79def name(self): return "wallet_balance"80@property81def description(self): return """Get EVM wallet balance on a specific chain. Omit 'asset' to discover ALL tokens.82Chains: ethereum, base, arbitrum, optimism, polygon, linea.83Use wallet_get_all_balances for all chains at once."""84@property85def parameters(self): return {86"type": "object",87"properties": {88"chain": {"type": "string", "enum": EVM_CHAINS, "description": "Required. Blockchain network."},89"address": {"type": "string", "description": "EVM address (0x...). Omit for own wallet."},90"asset": {"type": "string", "description": "Asset filter. Omit for ALL tokens."},91},92"required": ["chain"],93}9495async def execute(self, ctx: ToolContext, chain="", address="", asset="", **kw) -> ToolResult:96if not chain or chain not in EVM_CHAINS:97return ToolResult(success=False, error=f"'chain' required. One of: {', '.join(EVM_CHAINS)}")9899debank_key = os.environ.get("DEBANK_API_KEY", "")100if debank_key:101evm_address = address102if not evm_address:103if err := _fly_check(): return err104try:105addrs = await _get_wallet_addresses()106evm_address = addrs.get("evm", "")107except Exception as e:108return ToolResult(success=False, error=f"Failed to get wallet address: {e}")109if not evm_address:110return ToolResult(success=False, error="Could not determine EVM wallet address")111112debank_chain_id = DEBANK_CHAIN_MAP.get(chain)113try:114resp = _proxied_get_with_retry(115"https://pro-openapi.debank.com/v1/user/token_list",116params={"id": evm_address, "chain_id": debank_chain_id, "is_all": "false"},117headers={"AccessKey": debank_key},118)119return ToolResult(success=True, output={120"address": evm_address, "chain": chain, "tokens": resp.json(), "source": "debank",121})122except Exception as e:123return ToolResult(success=False, error=f"DeBank request failed: {e}")124else:125if err := _fly_check(): return err126try:127params = [f"chain_type=ethereum&chain={chain}"]128if asset: params.append(f"asset={asset}")129data = await _wallet_request("GET", f"/agent/balance?{'&'.join(params)}")130return ToolResult(success=True, output=data)131except Exception as e:132return ToolResult(success=False, error=str(e))133134135# ── Solana Balance ───────────────────────────────────────────────────────────136137class WalletSolBalanceTool(BaseTool):138@property139def name(self): return "wallet_sol_balance"140@property141def description(self): return "Get Solana wallet balance. Omit 'asset' to discover ALL SPL tokens."142@property143def parameters(self): return {144"type": "object",145"properties": {146"address": {"type": "string", "description": "Solana address. Omit for own wallet."},147"asset": {"type": "string", "description": "Asset filter. Omit for ALL tokens."},148},149}150151async def execute(self, ctx: ToolContext, address="", asset="", **kw) -> ToolResult:152birdeye_key = os.environ.get("BIRDEYE_API_KEY", "")153if birdeye_key:154sol_address = address155if not sol_address:156if err := _fly_check(): return err157try:158addrs = await _get_wallet_addresses()159sol_address = addrs.get("sol", "")160except Exception as e:161return ToolResult(success=False, error=f"Failed to get wallet address: {e}")162if not sol_address:163return ToolResult(success=False, error="Could not determine Solana wallet address")164try:165resp = _proxied_get_with_retry(166"https://public-api.birdeye.so/wallet/v2/net-worth",167params={"wallet": sol_address},168headers={"X-API-KEY": birdeye_key, "x-chain": "solana", "accept": "application/json"},169)170return ToolResult(success=True, output={"address": sol_address, "source": "birdeye", "data": resp.json()})171except Exception as e:172return ToolResult(success=False, error=f"Birdeye request failed: {e}")173else:174if err := _fly_check(): return err175try:176params = ["chain_type=solana"]177if asset: params.append(f"asset={asset}")178data = await _wallet_request("GET", f"/agent/balance?{'&'.join(params)}")179return ToolResult(success=True, output=data)180except Exception as e:181return ToolResult(success=False, error=str(e))182183184# ── All Balances ─────────────────────────────────────────────────────────────185186class WalletGetAllBalancesTool(BaseTool):187@property188def name(self): return "wallet_get_all_balances"189@property190def description(self): return "Get complete balance snapshot across ALL chains (EVM + Solana) with USD values."191@property192def parameters(self): return {193"type": "object",194"properties": {195"evm_address": {"type": "string", "description": "EVM address (0x...). Omit for own."},196"sol_address": {"type": "string", "description": "Solana address. Omit for own."},197},198}199200async def execute(self, ctx: ToolContext, evm_address="", sol_address="", **kw) -> ToolResult:201import asyncio202if not evm_address or not sol_address:203if _is_fly_machine():204try:205addrs = await _get_wallet_addresses()206evm_address = evm_address or addrs.get("evm", "")207sol_address = sol_address or addrs.get("sol", "")208except Exception:209pass210211result = {}212errors = []213evm_usd = 0.0214sol_usd = 0.0215216async def _fetch_evm():217nonlocal evm_usd218if not evm_address: return219debank_key = os.environ.get("DEBANK_API_KEY", "")220if not debank_key:221errors.append("No DEBANK_API_KEY"); return222try:223resp = _proxied_get_with_retry(224"https://pro-openapi.debank.com/v1/user/all_token_list",225params={"id": evm_address, "is_all": "true"},226headers={"AccessKey": debank_key},227)228tokens = resp.json()229by_chain = {}230for t in tokens:231c = t.get("chain", "unknown")232if c not in by_chain:233by_chain[c] = {"tokens": [], "total_usd": 0.0}234usd = t.get("price", 0) * t.get("amount", 0)235by_chain[c]["tokens"].append(t)236by_chain[c]["total_usd"] = round(by_chain[c]["total_usd"] + usd, 2)237evm_usd += usd238result["evm"] = {"address": evm_address, "chains": by_chain, "total_usd": round(evm_usd, 2), "source": "debank"}239except Exception as e:240errors.append(f"DeBank: {e}")241242async def _fetch_sol():243nonlocal sol_usd244if not sol_address: return245birdeye_key = os.environ.get("BIRDEYE_API_KEY", "")246if not birdeye_key:247errors.append("No BIRDEYE_API_KEY"); return248try:249resp = _proxied_get_with_retry(250"https://public-api.birdeye.so/wallet/v2/net-worth",251params={"wallet": sol_address},252headers={"X-API-KEY": birdeye_key, "x-chain": "solana", "accept": "application/json"},253)254data = resp.json()255sol_usd = data.get("data", {}).get("totalUsd", 0)256result["solana"] = {"address": sol_address, "source": "birdeye", "data": data, "total_usd": round(sol_usd, 2)}257except Exception as e:258errors.append(f"Birdeye: {e}")259260await asyncio.gather(_fetch_evm(), _fetch_sol())261result["total_usd_value"] = round(evm_usd + sol_usd, 2)262if errors: result["errors"] = errors263has_data = "evm" in result or "solana" in result264if not has_data and errors:265return ToolResult(success=False, error="All balance queries failed: " + "; ".join(errors))266return ToolResult(success=True, output=result)267268269270# ── EVM Transfer ─────────────────────────────────────────────────────────────271272class WalletTransferTool(BaseTool):273@property274def name(self): return "wallet_transfer"275@property276def description(self): return """Sign and BROADCAST an EVM transaction. Gas is sponsored by default (falls back to user-paid if unavailable). Set sponsor=false to pay gas from wallet balance.277Use '0' amount for contract calls. Policy-gated if enabled."""278@property279def parameters(self): return {280"type": "object",281"properties": {282"to": {"type": "string", "description": "Target address (0x...)"},283"amount": {"type": "string", "description": "Amount in wei"},284"chain_id": {"type": "integer", "description": "Chain ID (default: 1)"},285"data": {"type": "string", "description": "Hex calldata for contract calls"},286"gas_limit": {"type": "string"}, "gas_price": {"type": "string"},287"max_fee_per_gas": {"type": "string"}, "max_priority_fee_per_gas": {"type": "string"},288"nonce": {"type": "string"}, "tx_type": {"type": "integer", "description": "0=legacy, 2=EIP-1559"},289"sponsor": {"type": "boolean", "description": "Gas sponsorship: true=platform pays gas, false=user pays gas from wallet. Omit for auto (try sponsor, fallback to user-paid)."},290},291"required": ["to", "amount"],292}293294async def execute(self, ctx: ToolContext, to="", amount="", chain_id=1, data="",295gas_limit="", gas_price="", max_fee_per_gas="", max_priority_fee_per_gas="",296nonce="", tx_type=None, sponsor=None, **kw) -> ToolResult:297if err := _fly_check(): return err298if not to or not amount:299return ToolResult(success=False, error="'to' and 'amount' required")300body = {"to": to, "amount": amount, "chain_id": chain_id}301if data: body["data"] = data302if gas_limit: body["gas_limit"] = gas_limit303if gas_price: body["gas_price"] = gas_price304if max_fee_per_gas: body["max_fee_per_gas"] = max_fee_per_gas305if max_priority_fee_per_gas: body["max_priority_fee_per_gas"] = max_priority_fee_per_gas306if nonce: body["nonce"] = nonce307if tx_type is not None: body["tx_type"] = tx_type308if sponsor is not None: body["sponsor"] = sponsor309try:310return ToolResult(success=True, output=await _wallet_request("POST", "/agent/transfer", body))311except Exception as e:312msg = str(e)313if "policy" in msg.lower():314return ToolResult(success=False, error=f"Policy violation: {msg}")315return ToolResult(success=False, error=msg)316317318# ── EVM Sign Transaction ────────────────────────────────────────────────────319320class WalletSignTransactionTool(BaseTool):321@property322def name(self): return "wallet_sign_transaction"323@property324def description(self): return "Sign an EVM transaction WITHOUT broadcasting. Returns signed tx data."325@property326def parameters(self): return {327"type": "object",328"properties": {329"to": {"type": "string"}, "amount": {"type": "string"},330"chain_id": {"type": "integer"}, "data": {"type": "string"},331"gas_limit": {"type": "string"}, "gas_price": {"type": "string"},332"max_fee_per_gas": {"type": "string"}, "max_priority_fee_per_gas": {"type": "string"},333"nonce": {"type": "string"}, "tx_type": {"type": "integer"},334},335"required": ["to", "amount"],336}337338async def execute(self, ctx: ToolContext, to="", amount="", chain_id=1, data="",339gas_limit="", gas_price="", max_fee_per_gas="", max_priority_fee_per_gas="",340nonce="", tx_type=None, **kw) -> ToolResult:341if err := _fly_check(): return err342if not to: return ToolResult(success=False, error="'to' required")343body = {"to": to, "amount": amount, "chain_id": chain_id}344if data: body["data"] = data345if gas_limit: body["gas_limit"] = gas_limit346if gas_price: body["gas_price"] = gas_price347if max_fee_per_gas: body["max_fee_per_gas"] = max_fee_per_gas348if max_priority_fee_per_gas: body["max_priority_fee_per_gas"] = max_priority_fee_per_gas349if nonce: body["nonce"] = nonce350if tx_type is not None: body["tx_type"] = tx_type351try:352return ToolResult(success=True, output=await _wallet_request("POST", "/agent/sign-transaction", body))353except Exception as e:354return ToolResult(success=False, error=str(e))355356357# ── EVM Sign Message ────────────────────────────────────────────────────────358359class WalletSignTool(BaseTool):360@property361def name(self): return "wallet_sign"362@property363def description(self): return "Sign a message (EIP-191 personal_sign). Proves wallet ownership."364@property365def parameters(self): return {366"type": "object",367"properties": {"message": {"type": "string", "description": "Message to sign"}},368"required": ["message"],369}370371async def execute(self, ctx: ToolContext, message="", **kw) -> ToolResult:372if err := _fly_check(): return err373if not message: return ToolResult(success=False, error="'message' required")374try:375return ToolResult(success=True, output=await _wallet_request("POST", "/agent/sign", {"message": message}))376except Exception as e:377return ToolResult(success=False, error=str(e))378379380# ── EVM Sign Typed Data ─────────────────────────────────────────────────────381382class WalletSignTypedDataTool(BaseTool):383@property384def name(self): return "wallet_sign_typed_data"385@property386def description(self): return "Sign EIP-712 structured data (permits, orders, etc.)."387@property388def parameters(self): return {389"type": "object",390"properties": {391"domain": {"type": "object", "description": "EIP-712 domain separator"},392"types": {"type": "object", "description": "Type definitions"},393"primaryType": {"type": "string", "description": "Primary type name"},394"message": {"type": "object", "description": "Data to sign"},395},396"required": ["domain", "types", "primaryType", "message"],397}398399async def execute(self, ctx: ToolContext, domain=None, types=None,400primaryType="", message=None, **kw) -> ToolResult:401if err := _fly_check(): return err402if not all([domain, types, primaryType, message]):403return ToolResult(success=False, error="All params required: domain, types, primaryType, message")404try:405return ToolResult(success=True, output=await _wallet_request("POST", "/agent/sign-typed-data", {406"domain": domain, "types": types, "primaryType": primaryType, "message": message,407}))408except Exception as e:409return ToolResult(success=False, error=str(e))410411412# ── EVM Transactions ────────────────────────────────────────────────────────413414class WalletTransactionsTool(BaseTool):415@property416def name(self): return "wallet_transactions"417@property418def description(self): return "Get recent EVM transaction history."419@property420def parameters(self): return {421"type": "object",422"properties": {423"chain": {"type": "string"}, "asset": {"type": "string"},424"limit": {"type": "integer", "description": "Max 100"},425},426}427428async def execute(self, ctx: ToolContext, chain="ethereum", asset="eth", limit=20, **kw) -> ToolResult:429if err := _fly_check(): return err430try:431qs = f"?chain_type=ethereum&chain={chain}&asset={asset}&limit={limit}"432return ToolResult(success=True, output=await _wallet_request("GET", f"/agent/transactions{qs}"))433except Exception as e:434return ToolResult(success=False, error=str(e))435436437# ── Solana Transfer ──────────────────────────────────────────────────────────438439class WalletSolTransferTool(BaseTool):440@property441def name(self): return "wallet_sol_transfer"442@property443def description(self): return "Sign and BROADCAST a Solana transaction. User pays gas (SOL required for fees). Policy-gated if enabled."444@property445def parameters(self): return {446"type": "object",447"properties": {448"transaction": {"type": "string", "description": "Base64-encoded Solana tx"},449"caip2": {"type": "string", "description": "CAIP-2 chain ID (default: mainnet)"},450},451"required": ["transaction"],452}453454async def execute(self, ctx: ToolContext, transaction="", caip2="solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", **kw) -> ToolResult:455if err := _fly_check(): return err456if not transaction: return ToolResult(success=False, error="'transaction' required")457try:458return ToolResult(success=True, output=await _wallet_request("POST", "/agent/sol/transfer", {459"transaction": transaction, "caip2": caip2,460}))461except Exception as e:462msg = str(e)463if "policy" in msg.lower():464return ToolResult(success=False, error=f"Policy violation: {msg}")465return ToolResult(success=False, error=msg)466467468# ── Solana Sign Transaction ─────────────────────────────────────────────────469470class WalletSolSignTransactionTool(BaseTool):471@property472def name(self): return "wallet_sol_sign_transaction"473@property474def description(self): return "Sign a Solana transaction WITHOUT broadcasting."475@property476def parameters(self): return {477"type": "object",478"properties": {"transaction": {"type": "string", "description": "Base64-encoded Solana tx"}},479"required": ["transaction"],480}481482async def execute(self, ctx: ToolContext, transaction="", **kw) -> ToolResult:483if err := _fly_check(): return err484if not transaction: return ToolResult(success=False, error="'transaction' required")485try:486return ToolResult(success=True, output=await _wallet_request("POST", "/agent/sol/sign-transaction", {487"transaction": transaction,488}))489except Exception as e:490return ToolResult(success=False, error=str(e))491492493# ── Solana Sign Message ─────────────────────────────────────────────────────494495class WalletSolSignTool(BaseTool):496@property497def name(self): return "wallet_sol_sign"498@property499def description(self): return "Sign a message with Solana wallet (base64)."500@property501def parameters(self): return {502"type": "object",503"properties": {"message": {"type": "string", "description": "Base64-encoded message"}},504"required": ["message"],505}506507async def execute(self, ctx: ToolContext, message="", **kw) -> ToolResult:508if err := _fly_check(): return err509if not message: return ToolResult(success=False, error="'message' required")510try:511return ToolResult(success=True, output=await _wallet_request("POST", "/agent/sol/sign", {"message": message}))512except Exception as e:513return ToolResult(success=False, error=str(e))514515516# ── Solana Transactions ──────────────────────────────────────────────────────517518class WalletSolTransactionsTool(BaseTool):519@property520def name(self): return "wallet_sol_transactions"521@property522def description(self): return "Get recent Solana transaction history."523@property524def parameters(self): return {525"type": "object",526"properties": {527"chain": {"type": "string"}, "asset": {"type": "string"},528"limit": {"type": "integer"},529},530}531532async def execute(self, ctx: ToolContext, chain="solana", asset="sol", limit=20, **kw) -> ToolResult:533if err := _fly_check(): return err534try:535qs = f"?chain_type=solana&chain={chain}&asset={asset}&limit={limit}"536return ToolResult(success=True, output=await _wallet_request("GET", f"/agent/transactions{qs}"))537except Exception as e:538return ToolResult(success=False, error=str(e))539540541# ── Get Policy ───────────────────────────────────────────────────────────────542543class WalletGetPolicyTool(BaseTool):544@property545def name(self): return "wallet_get_policy"546@property547def description(self): return """Get wallet policy status.548- enabled=false → allow-all (default)549- enabled=true, rules=[] → deny-all550- enabled=true, rules=[...] → rules enforced"""551@property552def parameters(self): return {553"type": "object",554"properties": {555"chain_type": {"type": "string", "enum": ["ethereum", "solana"], "default": "ethereum"},556},557}558559async def execute(self, ctx: ToolContext, chain_type="ethereum", **kw) -> ToolResult:560if err := _fly_check(): return err561try:562return ToolResult(success=True, output=await _wallet_request("GET", f"/agent/policy?chain_type={chain_type}"))563except Exception as e:564return ToolResult(success=False, error=str(e))565566567# ── Propose Policy ───────────────────────────────────────────────────────────568569class WalletProposePolicyTool(BaseTool):570@property571def name(self): return "wallet_propose_policy"572@property573def description(self): return """Propose a wallet policy update. Sends action_request to frontend for user confirmation.574For both EVM and Solana, call TWICE (once per chain_type)."""575@property576def parameters(self): return {577"type": "object",578"properties": {579"chain_type": {"type": "string", "enum": ["ethereum", "solana"]},580"rules": {"type": "array", "description": "Privy policy rule objects", "items": {581"type": "object",582"properties": {583"name": {"type": "string"}, "method": {"type": "string"},584"conditions": {"type": "array", "items": {"type": "object"}},585"action": {"type": "string", "enum": ["ALLOW", "DENY"]},586},587}},588"title": {"type": "string", "description": "Short title (shown in UI)"},589"description": {"type": "string", "description": "What this policy does"},590},591"required": ["chain_type", "rules", "title", "description"],592}593594async def execute(self, ctx: ToolContext, chain_type="", rules=None,595title="", description="", **kw) -> ToolResult:596if not chain_type or chain_type not in ("ethereum", "solana"):597return ToolResult(success=False, error="chain_type must be 'ethereum' or 'solana'")598if rules is None:599return ToolResult(success=False, error="'rules' required")600if not title:601return ToolResult(success=False, error="'title' required")602603# Use system validation function604cleaned_rules, validation_errors = _validate_and_clean_rules(rules, chain_type)605if validation_errors:606return ToolResult(607success=False,608error="Rule validation failed:\n" + "\n".join(f"- {e}" for e in validation_errors),609)610611# Truncate names to Privy limit612for rule in cleaned_rules:613if isinstance(rule, dict) and "name" in rule and len(rule["name"]) > 50:614rule["name"] = rule["name"][:50]615616container_id = os.environ.get("FLY_MACHINE_ID", "") or os.environ.get("FLY_ALLOC_ID", "") or "local-dev"617action_id = f"act_{int(time.time())}_{os.urandom(4).hex()}"618619payload = {620"container_id": container_id,621"chain_type": chain_type,622"rules": cleaned_rules,623}624625streaming = getattr(ctx, "streaming", None)626if streaming:627streaming.action_request(628action_id=action_id,629action="update_wallet_policy",630title=title,631description=description or title,632payload=payload,633require_signature=True,634)635return ToolResult(success=True, output={636"status": "action_request_sent",637"action_id": action_id,638"message": f"Policy proposal sent. Chain: {chain_type}, Rules: {len(cleaned_rules)}.",639})640else:641return ToolResult(642success=False,643error="Streaming context not available — cannot send action_request.",644)645