@agentlayer.tech/wallet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.openclaw/AGENTS.md +98 -0
  2. package/.openclaw/extensions/agent-wallet/README.md +127 -0
  3. package/.openclaw/extensions/agent-wallet/index.ts +1520 -0
  4. package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +184 -0
  5. package/.openclaw/extensions/agent-wallet/package.json +11 -0
  6. package/.openclaw/extensions/agent-wallet/skills/wallet-operator/SKILL.md +20 -0
  7. package/CHANGELOG.md +42 -0
  8. package/LICENSE +104 -0
  9. package/README.md +332 -0
  10. package/RELEASING.md +204 -0
  11. package/agent-wallet/.env.example +62 -0
  12. package/agent-wallet/AGENTS.md +129 -0
  13. package/agent-wallet/README.md +527 -0
  14. package/agent-wallet/agent_wallet/__init__.py +11 -0
  15. package/agent-wallet/agent_wallet/approval.py +161 -0
  16. package/agent-wallet/agent_wallet/bootstrap.py +178 -0
  17. package/agent-wallet/agent_wallet/btc_user_wallets.py +217 -0
  18. package/agent-wallet/agent_wallet/config.py +382 -0
  19. package/agent-wallet/agent_wallet/encrypted_storage.py +161 -0
  20. package/agent-wallet/agent_wallet/evm_user_wallets.py +370 -0
  21. package/agent-wallet/agent_wallet/exceptions.py +9 -0
  22. package/agent-wallet/agent_wallet/file_ops.py +34 -0
  23. package/agent-wallet/agent_wallet/http_client.py +25 -0
  24. package/agent-wallet/agent_wallet/models.py +66 -0
  25. package/agent-wallet/agent_wallet/nonce_registry.py +59 -0
  26. package/agent-wallet/agent_wallet/openclaw_adapter.py +5128 -0
  27. package/agent-wallet/agent_wallet/openclaw_cli.py +626 -0
  28. package/agent-wallet/agent_wallet/openclaw_runtime.py +272 -0
  29. package/agent-wallet/agent_wallet/plugin_bundle.py +42 -0
  30. package/agent-wallet/agent_wallet/providers/__init__.py +1 -0
  31. package/agent-wallet/agent_wallet/providers/bags.py +259 -0
  32. package/agent-wallet/agent_wallet/providers/evm_portfolio.py +470 -0
  33. package/agent-wallet/agent_wallet/providers/jupiter.py +567 -0
  34. package/agent-wallet/agent_wallet/providers/kamino.py +215 -0
  35. package/agent-wallet/agent_wallet/providers/lifi.py +277 -0
  36. package/agent-wallet/agent_wallet/providers/solana_rpc.py +470 -0
  37. package/agent-wallet/agent_wallet/providers/wdk_btc_local.py +114 -0
  38. package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +205 -0
  39. package/agent-wallet/agent_wallet/sealed_keys.py +61 -0
  40. package/agent-wallet/agent_wallet/solana_stake.py +103 -0
  41. package/agent-wallet/agent_wallet/solana_tx.py +93 -0
  42. package/agent-wallet/agent_wallet/spending_limits.py +101 -0
  43. package/agent-wallet/agent_wallet/transaction_policy.py +518 -0
  44. package/agent-wallet/agent_wallet/user_wallets.py +355 -0
  45. package/agent-wallet/agent_wallet/validation.py +31 -0
  46. package/agent-wallet/agent_wallet/wallet_layer/__init__.py +1 -0
  47. package/agent-wallet/agent_wallet/wallet_layer/base.py +808 -0
  48. package/agent-wallet/agent_wallet/wallet_layer/base58.py +44 -0
  49. package/agent-wallet/agent_wallet/wallet_layer/factory.py +102 -0
  50. package/agent-wallet/agent_wallet/wallet_layer/solana.py +4252 -0
  51. package/agent-wallet/agent_wallet/wallet_layer/wdk_btc.py +272 -0
  52. package/agent-wallet/agent_wallet/wallet_layer/wdk_evm.py +1628 -0
  53. package/agent-wallet/examples/bootstrap_wallet.py +21 -0
  54. package/agent-wallet/examples/openclaw_runtime_onboarding.py +28 -0
  55. package/agent-wallet/examples/openclaw_user_wallet_example.py +31 -0
  56. package/agent-wallet/examples/openclaw_wallet_adapter_example.py +33 -0
  57. package/agent-wallet/openclaw.plugin.json +138 -0
  58. package/agent-wallet/pyproject.toml +31 -0
  59. package/agent-wallet/scripts/bootstrap_openclaw_btc.py +278 -0
  60. package/agent-wallet/scripts/build_release_bundle.py +188 -0
  61. package/agent-wallet/scripts/finalize_openclaw_local_wallet_config.py +121 -0
  62. package/agent-wallet/scripts/install_agent_wallet.py +505 -0
  63. package/agent-wallet/scripts/install_openclaw_local_config.py +226 -0
  64. package/agent-wallet/scripts/install_openclaw_sealed_keys.py +105 -0
  65. package/agent-wallet/scripts/manage_openclaw_btc_wallet.py +244 -0
  66. package/agent-wallet/scripts/reveal_btc_seed.sh +130 -0
  67. package/agent-wallet/scripts/security_utils.py +37 -0
  68. package/agent-wallet/scripts/setup_btc_wallet.sh +146 -0
  69. package/agent-wallet/scripts/switch_openclaw_wallet_network.py +106 -0
  70. package/agent-wallet/skills/wallet-operator/SKILL.md +128 -0
  71. package/bin/openclaw-agent-wallet.mjs +487 -0
  72. package/install-from-github.sh +134 -0
  73. package/package.json +61 -0
  74. package/setup.sh +40 -0
  75. package/wdk-btc-wallet/README.md +325 -0
  76. package/wdk-btc-wallet/bootstrap.sh +22 -0
  77. package/wdk-btc-wallet/package-lock.json +1839 -0
  78. package/wdk-btc-wallet/package.json +18 -0
  79. package/wdk-btc-wallet/run-local.sh +21 -0
  80. package/wdk-btc-wallet/src/config.js +160 -0
  81. package/wdk-btc-wallet/src/json.js +35 -0
  82. package/wdk-btc-wallet/src/local_vault.js +432 -0
  83. package/wdk-btc-wallet/src/network_state.js +84 -0
  84. package/wdk-btc-wallet/src/server.js +257 -0
  85. package/wdk-btc-wallet/src/wdk_btc_wallet.js +332 -0
  86. package/wdk-evm-wallet/README.md +183 -0
  87. package/wdk-evm-wallet/bootstrap.sh +8 -0
  88. package/wdk-evm-wallet/package-lock.json +2340 -0
  89. package/wdk-evm-wallet/package.json +23 -0
  90. package/wdk-evm-wallet/run-local.sh +12 -0
  91. package/wdk-evm-wallet/src/config.js +274 -0
  92. package/wdk-evm-wallet/src/json.js +35 -0
  93. package/wdk-evm-wallet/src/local_vault.js +430 -0
  94. package/wdk-evm-wallet/src/network_state.js +92 -0
  95. package/wdk-evm-wallet/src/server.js +575 -0
  96. package/wdk-evm-wallet/src/wdk_evm_wallet.js +4981 -0
@@ -0,0 +1,4252 @@
1
+ """Solana wallet backend focused on simple local or read-only operation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import binascii
8
+ import hashlib
9
+ import json
10
+ from decimal import Decimal, InvalidOperation
11
+ from typing import Any
12
+
13
+ from agent_wallet.models import AgentWalletCapabilities, SolanaWalletState
14
+ from agent_wallet.providers import bags, jupiter, kamino, lifi, solana_rpc
15
+ from agent_wallet.solana_stake import (
16
+ STAKE_STATE_V2_SIZE,
17
+ deactivate_stake as build_deactivate_stake_instruction,
18
+ delegate_stake as build_delegate_stake_instruction,
19
+ initialize_checked as build_initialize_checked_instruction,
20
+ withdraw_stake as build_withdraw_stake_instruction,
21
+ )
22
+ from agent_wallet.solana_tx import (
23
+ build_legacy_sol_transfer_message,
24
+ encode_transaction_base64,
25
+ serialize_legacy_transaction,
26
+ )
27
+ from agent_wallet.transaction_policy import (
28
+ verify_provider_bags_transaction,
29
+ verify_provider_kamino_lend_transaction,
30
+ verify_provider_lend_transaction,
31
+ verify_provider_swap_simulation_result,
32
+ verify_provider_swap_transaction,
33
+ )
34
+ from agent_wallet.validation import validate_solana_address, validate_solana_mint
35
+ from agent_wallet.wallet_layer.base import (
36
+ AgentWalletBackend,
37
+ WalletBackendError,
38
+ WalletCapabilities,
39
+ )
40
+ from agent_wallet.exceptions import ProviderError
41
+ from agent_wallet.wallet_layer.base58 import b58decode, b58encode
42
+
43
+ SOLANA_BASE_FEE_LAMPORTS = 5_000
44
+ SOLANA_STAKE_CREATE_SIGNATURE_FEE_LAMPORTS = 10_000
45
+ TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
46
+ TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
47
+ NATIVE_SOL_MINT = "So11111111111111111111111111111111111111112"
48
+ STAKE_PROGRAM_ID = "Stake11111111111111111111111111111111111111"
49
+
50
+
51
+ def _load_signing_key():
52
+ try:
53
+ from nacl.signing import SigningKey
54
+ except ImportError as exc:
55
+ raise WalletBackendError(
56
+ "PyNaCl is required for local Solana signing. Install agent-wallet dependencies first."
57
+ ) from exc
58
+ return SigningKey
59
+
60
+
61
+ def _decode_secret_material(secret_material: str) -> bytes:
62
+ cleaned = secret_material.strip()
63
+ if not cleaned:
64
+ raise WalletBackendError("Solana secret material is empty.")
65
+
66
+ if cleaned.startswith("["):
67
+ try:
68
+ values = json.loads(cleaned)
69
+ except json.JSONDecodeError as exc:
70
+ raise WalletBackendError("Solana keypair JSON could not be parsed.") from exc
71
+ if not isinstance(values, list) or not values:
72
+ raise WalletBackendError("Solana keypair JSON must be a non-empty integer array.")
73
+ try:
74
+ raw = bytes(int(item) for item in values)
75
+ except ValueError as exc:
76
+ raise WalletBackendError("Solana keypair JSON must contain byte values.") from exc
77
+ return raw
78
+
79
+ try:
80
+ return b58decode(cleaned)
81
+ except ValueError as exc:
82
+ raise WalletBackendError("Solana secret must be base58 or JSON keypair bytes.") from exc
83
+
84
+
85
+ def _coerce_int(value: Any) -> int | None:
86
+ if value is None or value == "":
87
+ return None
88
+ try:
89
+ return int(value)
90
+ except (TypeError, ValueError):
91
+ return None
92
+
93
+
94
+ def _coerce_decimal(value: Any) -> Decimal | None:
95
+ if value is None or value == "":
96
+ return None
97
+ try:
98
+ return Decimal(str(value))
99
+ except (InvalidOperation, ValueError):
100
+ return None
101
+
102
+
103
+ def _format_decimal(value: Decimal | None, *, places: int = 2) -> str | None:
104
+ if value is None:
105
+ return None
106
+ quant = Decimal("1").scaleb(-places)
107
+ return str(value.quantize(quant))
108
+
109
+
110
+ def _jupiter_price_entry(price_data: dict[str, Any], mint: str) -> dict[str, Any] | None:
111
+ entry = price_data.get(mint)
112
+ if isinstance(entry, dict):
113
+ return entry
114
+ data = price_data.get("data")
115
+ if isinstance(data, dict) and isinstance(data.get(mint), dict):
116
+ return data[mint]
117
+ return None
118
+
119
+
120
+ def _jupiter_usd_price(entry: dict[str, Any] | None) -> Decimal | None:
121
+ if not isinstance(entry, dict):
122
+ return None
123
+ for key in ("usdPrice", "price", "usd", "value"):
124
+ price = _coerce_decimal(entry.get(key))
125
+ if price is not None:
126
+ return price
127
+ return None
128
+
129
+
130
+ def _require_positive_integer_string(value: Any, *, field_name: str) -> str:
131
+ if not isinstance(value, str) or not value.strip().isdigit():
132
+ raise WalletBackendError(f"{field_name} must be a positive integer string.")
133
+ normalized = value.strip()
134
+ if int(normalized) <= 0:
135
+ raise WalletBackendError(f"{field_name} must be greater than zero.")
136
+ return normalized
137
+
138
+
139
+ def _require_positive_decimal_string(value: Any, *, field_name: str) -> str:
140
+ if isinstance(value, bool):
141
+ raise WalletBackendError(f"{field_name} must be a positive decimal string.")
142
+ text = str(value).strip()
143
+ if not text:
144
+ raise WalletBackendError(f"{field_name} must be a positive decimal string.")
145
+ try:
146
+ decimal_value = Decimal(text)
147
+ except InvalidOperation as exc:
148
+ raise WalletBackendError(f"{field_name} must be a positive decimal string.") from exc
149
+ if not decimal_value.is_finite() or decimal_value <= 0:
150
+ raise WalletBackendError(f"{field_name} must be greater than zero.")
151
+ normalized = format(decimal_value, "f")
152
+ if "." in normalized:
153
+ normalized = normalized.rstrip("0").rstrip(".")
154
+ return normalized or "0"
155
+
156
+
157
+ def _coerce_positive_int_from_any(value: Any) -> int | None:
158
+ if value is None or value == "":
159
+ return None
160
+ try:
161
+ parsed = int(str(value))
162
+ except (TypeError, ValueError):
163
+ return None
164
+ return parsed if parsed > 0 else None
165
+
166
+
167
+ def _coerce_non_negative_integer(value: Any, *, field_name: str) -> int:
168
+ if isinstance(value, bool):
169
+ raise WalletBackendError(f"{field_name} must be a non-negative integer.")
170
+ try:
171
+ normalized = int(value)
172
+ except (TypeError, ValueError) as exc:
173
+ raise WalletBackendError(f"{field_name} must be a non-negative integer.") from exc
174
+ if normalized < 0:
175
+ raise WalletBackendError(f"{field_name} must be a non-negative integer.")
176
+ return normalized
177
+
178
+
179
+ def _kamino_entry_address(entry: Any, *keys: str) -> str:
180
+ if not isinstance(entry, dict):
181
+ return ""
182
+ for key in keys:
183
+ value = entry.get(key)
184
+ if isinstance(value, str) and value.strip():
185
+ return value.strip()
186
+ return ""
187
+
188
+
189
+ class SolanaLocalKeypairSigner:
190
+ """Local signer compatible with agent-style wallet backends."""
191
+
192
+ def __init__(self, seed: bytes):
193
+ SigningKey = _load_signing_key()
194
+ self._signing_key = SigningKey(seed)
195
+ self._public_key = b58encode(bytes(self._signing_key.verify_key))
196
+
197
+ @classmethod
198
+ def from_secret_material(cls, secret_material: str) -> "SolanaLocalKeypairSigner":
199
+ raw = _decode_secret_material(secret_material)
200
+ if len(raw) == 64:
201
+ seed = raw[:32]
202
+ elif len(raw) == 32:
203
+ seed = raw
204
+ else:
205
+ raise WalletBackendError(
206
+ "Unsupported Solana secret length. Expected 32-byte seed or 64-byte keypair."
207
+ )
208
+ return cls(seed)
209
+
210
+ @property
211
+ def address(self) -> str:
212
+ return self._public_key
213
+
214
+ def export_keypair_bytes(self) -> bytes:
215
+ """Return 64-byte Solana keypair bytes in Solana CLI file format."""
216
+ return self._signing_key.encode() + bytes(self._signing_key.verify_key)
217
+
218
+ def sign_message(self, message: bytes) -> bytes:
219
+ signed = self._signing_key.sign(message)
220
+ return bytes(signed.signature)
221
+
222
+ def sign_bytes(self, payload: bytes) -> bytes:
223
+ signed = self._signing_key.sign(payload)
224
+ return bytes(signed.signature)
225
+
226
+
227
+ class SolanaWalletBackend(AgentWalletBackend):
228
+ """Minimal Solana wallet backend for plugin-style integration."""
229
+
230
+ name = "solana_local"
231
+
232
+ def __init__(
233
+ self,
234
+ rpc_url: str | list[str],
235
+ commitment: str = "confirmed",
236
+ network: str = "mainnet",
237
+ signer: SolanaLocalKeypairSigner | None = None,
238
+ address: str | None = None,
239
+ sign_only: bool = True,
240
+ rpc_provider_mode: str | None = None,
241
+ rpc_provider: str | None = None,
242
+ rpc_transport: str | None = None,
243
+ swap_provider: str | None = None,
244
+ swap_transport: str | None = None,
245
+ ):
246
+ derived_address = signer.address if signer else None
247
+ final_address = address or derived_address
248
+ if final_address:
249
+ final_address = validate_solana_address(final_address)
250
+ if derived_address and final_address and derived_address != final_address:
251
+ raise WalletBackendError(
252
+ "Configured Solana public key does not match the private key provided for signing."
253
+ )
254
+
255
+ self.rpc_urls = rpc_url if isinstance(rpc_url, list) else [rpc_url]
256
+ self.rpc_url = self.rpc_urls[0]
257
+ self.commitment = commitment
258
+ self.network = network
259
+ self.signer = signer
260
+ self.address = final_address
261
+ self.sign_only = sign_only
262
+ self.rpc_provider_mode = rpc_provider_mode
263
+ self.rpc_provider = rpc_provider
264
+ self.rpc_transport = rpc_transport
265
+ self.swap_provider = swap_provider
266
+ self.swap_transport = swap_transport
267
+
268
+ async def get_address(self) -> str | None:
269
+ return self.address
270
+
271
+ def get_capabilities(self) -> WalletCapabilities:
272
+ return WalletCapabilities(
273
+ backend=self.name,
274
+ chain="solana",
275
+ custody_model="local" if self.signer else "read_only",
276
+ sign_only=self.sign_only,
277
+ has_signer=self.signer is not None,
278
+ can_sign_message=self.signer is not None,
279
+ can_sign_transaction=self.signer is not None,
280
+ can_send_transaction=self.signer is not None and not self.sign_only,
281
+ external_dependencies=["solana-rpc", "pynacl" if self.signer else "solana-rpc"],
282
+ )
283
+
284
+ async def _get_native_balance(self, address: str | None = None) -> dict[str, Any]:
285
+ wallet_address = address or self.address
286
+ if not wallet_address:
287
+ raise WalletBackendError(
288
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
289
+ )
290
+ return await solana_rpc.fetch_balance(
291
+ validate_solana_address(wallet_address),
292
+ rpc_url=self.rpc_urls,
293
+ commitment=self.commitment,
294
+ )
295
+
296
+ async def get_balance(self, address: str | None = None) -> dict[str, Any]:
297
+ return await self.get_portfolio(address=address)
298
+
299
+ async def get_portfolio(self, address: str | None = None) -> dict[str, Any]:
300
+ wallet_address = address or self.address
301
+ if not wallet_address:
302
+ raise WalletBackendError(
303
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
304
+ )
305
+ wallet_address = validate_solana_address(wallet_address)
306
+ native_balance, tokens = await asyncio.gather(
307
+ self._get_native_balance(wallet_address),
308
+ self._fetch_token_entries(wallet_address, include_zero_balances=False),
309
+ )
310
+ tokens.sort(
311
+ key=lambda item: float(item.get("amount_ui") or 0),
312
+ reverse=True,
313
+ )
314
+ mints = [NATIVE_SOL_MINT]
315
+ for token in tokens:
316
+ mint = token.get("mint")
317
+ if isinstance(mint, str) and mint.strip() and mint not in mints:
318
+ mints.append(mint.strip())
319
+
320
+ price_data_by_mint: dict[str, dict[str, Any]] = {}
321
+ price_errors: list[str] = []
322
+ for index in range(0, len(mints), 20):
323
+ batch = mints[index : index + 20]
324
+ try:
325
+ price_data = await jupiter.fetch_prices(mints=batch)
326
+ except ProviderError as exc:
327
+ price_errors.append(str(exc))
328
+ continue
329
+ for mint in batch:
330
+ entry = _jupiter_price_entry(price_data, mint)
331
+ if entry is not None:
332
+ price_data_by_mint[mint] = entry
333
+
334
+ native_price = _jupiter_usd_price(price_data_by_mint.get(NATIVE_SOL_MINT))
335
+ native_amount = _coerce_decimal(native_balance.get("balance_native"))
336
+ native_value = (
337
+ native_amount * native_price
338
+ if native_amount is not None and native_price is not None
339
+ else None
340
+ )
341
+ native_balance = {
342
+ **native_balance,
343
+ "mint": NATIVE_SOL_MINT,
344
+ "symbol": "SOL",
345
+ "balance_usd": _format_decimal(native_value),
346
+ "price_usd": str(native_price) if native_price is not None else None,
347
+ "value_usd": _format_decimal(native_value),
348
+ "pricing_source": "jupiter-price" if native_price is not None else None,
349
+ }
350
+
351
+ enriched_tokens: list[dict[str, Any]] = []
352
+ total_value = native_value or Decimal("0")
353
+ priced_asset_count = 1 if native_value is not None else 0
354
+ for token in tokens:
355
+ mint = str(token.get("mint") or "").strip()
356
+ amount = _coerce_decimal(token.get("amount_ui"))
357
+ price = _jupiter_usd_price(price_data_by_mint.get(mint))
358
+ value = amount * price if amount is not None and price is not None else None
359
+ if value is not None:
360
+ total_value += value
361
+ priced_asset_count += 1
362
+ enriched_tokens.append(
363
+ {
364
+ **token,
365
+ "price_usd": str(price) if price is not None else None,
366
+ "value_usd": _format_decimal(value),
367
+ "pricing_source": "jupiter-price" if price is not None else None,
368
+ "price_raw": price_data_by_mint.get(mint),
369
+ }
370
+ )
371
+
372
+ assets = [
373
+ {
374
+ "asset_type": "native",
375
+ "mint": NATIVE_SOL_MINT,
376
+ "symbol": "SOL",
377
+ "amount_raw": str(
378
+ int(
379
+ (native_amount or Decimal("0"))
380
+ * Decimal(solana_rpc.LAMPORTS_PER_SOL)
381
+ )
382
+ ),
383
+ "amount_ui": native_balance.get("balance_native"),
384
+ "price_usd": native_balance.get("price_usd"),
385
+ "value_usd": native_balance.get("value_usd"),
386
+ "pricing_source": native_balance.get("pricing_source"),
387
+ }
388
+ ]
389
+ assets.extend(
390
+ {
391
+ "asset_type": "spl-token",
392
+ "mint": token.get("mint"),
393
+ "token_account": token.get("token_account"),
394
+ "amount_raw": token.get("amount_raw"),
395
+ "amount_ui": token.get("amount_ui"),
396
+ "decimals": token.get("decimals"),
397
+ "price_usd": token.get("price_usd"),
398
+ "value_usd": token.get("value_usd"),
399
+ "pricing_source": token.get("pricing_source"),
400
+ }
401
+ for token in enriched_tokens
402
+ )
403
+ assets.sort(
404
+ key=lambda item: float(item.get("value_usd") or 0),
405
+ reverse=True,
406
+ )
407
+ formatted_total_value = _format_decimal(total_value) if priced_asset_count else None
408
+ return {
409
+ "chain": "solana",
410
+ "network": self.network,
411
+ "address": wallet_address,
412
+ "native_balance": native_balance,
413
+ "balance_native": native_balance.get("balance_native"),
414
+ "balance_usd": formatted_total_value,
415
+ "native_price_usd": native_balance.get("price_usd"),
416
+ "native_value_usd": native_balance.get("value_usd"),
417
+ "tokens": enriched_tokens,
418
+ "token_count": len(enriched_tokens),
419
+ "assets": assets,
420
+ "asset_count": len(assets),
421
+ "priced_asset_count": priced_asset_count,
422
+ "total_value_usd": formatted_total_value,
423
+ "pricing_source": "jupiter-price" if price_data_by_mint else None,
424
+ "pricing_errors": price_errors,
425
+ "token_discovery_source": "solana-rpc",
426
+ "source": "solana-rpc+jupiter-price",
427
+ }
428
+
429
+ async def get_token_prices(self, mints: list[str]) -> dict[str, Any]:
430
+ if not mints:
431
+ raise WalletBackendError("At least one mint is required.")
432
+ normalized: list[str] = []
433
+ for mint in mints:
434
+ if not isinstance(mint, str) or not mint.strip():
435
+ raise WalletBackendError("Each mint must be a non-empty string.")
436
+ normalized.append(validate_solana_mint(mint.strip()))
437
+
438
+ unique_mints = list(dict.fromkeys(normalized))
439
+ if len(unique_mints) > 20:
440
+ raise WalletBackendError("At most 20 mints can be requested at once.")
441
+
442
+ price_data = await jupiter.fetch_prices(mints=unique_mints)
443
+ items: list[dict[str, Any]] = []
444
+ for mint in unique_mints:
445
+ entry = _jupiter_price_entry(price_data, mint)
446
+ items.append(
447
+ {
448
+ "mint": mint,
449
+ "price": entry.get("usdPrice") if isinstance(entry, dict) else None,
450
+ "raw": entry,
451
+ }
452
+ )
453
+
454
+ return {
455
+ "chain": "solana",
456
+ "network": "mainnet",
457
+ "requested_mints": unique_mints,
458
+ "count": len(items),
459
+ "prices": items,
460
+ "source": "jupiter",
461
+ }
462
+
463
+ async def get_lifi_supported_chains(self) -> dict[str, Any]:
464
+ chains = await lifi.fetch_supported_chains()
465
+ supported = lifi.format_openclaw_supported_chains(chains)
466
+ return {
467
+ "provider": "lifi",
468
+ "chain": "cross-chain",
469
+ "network": "mainnet",
470
+ "supported_by_openclaw": lifi.OPENCLAW_SUPPORTED_CHAINS,
471
+ "chain_count": len(supported),
472
+ "chains": supported,
473
+ "source": "lifi",
474
+ }
475
+
476
+ async def get_lifi_quote(
477
+ self,
478
+ *,
479
+ from_chain: str,
480
+ to_chain: str,
481
+ from_token: str,
482
+ to_token: str,
483
+ amount_in_raw: str,
484
+ from_address: str | None = None,
485
+ to_address: str | None = None,
486
+ slippage: float | int | None = None,
487
+ allow_bridges: list[str] | None = None,
488
+ deny_bridges: list[str] | None = None,
489
+ prefer_bridges: list[str] | None = None,
490
+ ) -> dict[str, Any]:
491
+ from_chain_id = lifi.normalize_chain_id(from_chain, field_name="from_chain")
492
+ to_chain_id = lifi.normalize_chain_id(to_chain, field_name="to_chain")
493
+ resolved_from_address = str(from_address or "").strip()
494
+ resolved_to_address = str(to_address or "").strip()
495
+ wallet_address: str | None = None
496
+ if from_chain_id == "1151111081099710" and not resolved_from_address:
497
+ wallet_address = await self.get_address()
498
+ resolved_from_address = str(wallet_address or "").strip()
499
+ if to_chain_id == "1151111081099710" and not resolved_to_address:
500
+ wallet_address = wallet_address or await self.get_address()
501
+ resolved_to_address = str(wallet_address or "").strip()
502
+ if not resolved_from_address:
503
+ raise WalletBackendError("from_address is required when the LI.FI source chain is not Solana.")
504
+ if not resolved_to_address:
505
+ raise WalletBackendError("to_address is required when the LI.FI destination chain is not Solana.")
506
+
507
+ payload = await lifi.fetch_quote(
508
+ from_chain=from_chain_id,
509
+ to_chain=to_chain_id,
510
+ from_token=from_token,
511
+ to_token=to_token,
512
+ amount_in_raw=amount_in_raw,
513
+ from_address=resolved_from_address,
514
+ to_address=resolved_to_address,
515
+ slippage=slippage,
516
+ allow_bridges=allow_bridges,
517
+ deny_bridges=deny_bridges,
518
+ prefer_bridges=prefer_bridges,
519
+ )
520
+ return {
521
+ "provider": "lifi",
522
+ "chain": "cross-chain",
523
+ "network": "mainnet",
524
+ "from_chain": lifi.chain_name_for_id(from_chain_id),
525
+ "to_chain": lifi.chain_name_for_id(to_chain_id),
526
+ "from_chain_id": from_chain_id,
527
+ "to_chain_id": to_chain_id,
528
+ "from_token": lifi.normalize_token_address(from_token, chain_id=from_chain_id),
529
+ "to_token": lifi.normalize_token_address(to_token, chain_id=to_chain_id),
530
+ "amount_in_raw": amount_in_raw,
531
+ "from_address": resolved_from_address,
532
+ "to_address": resolved_to_address,
533
+ "slippage": slippage,
534
+ "allow_bridges": allow_bridges,
535
+ "deny_bridges": deny_bridges,
536
+ "prefer_bridges": prefer_bridges,
537
+ "tool": payload.get("tool"),
538
+ "tool_details": payload.get("toolDetails"),
539
+ "action": payload.get("action"),
540
+ "estimate": payload.get("estimate"),
541
+ "included_steps": payload.get("includedSteps"),
542
+ "transaction_request": payload.get("transactionRequest"),
543
+ "quote": payload,
544
+ "source": "lifi",
545
+ }
546
+
547
+ async def get_lifi_transfer_status(
548
+ self,
549
+ *,
550
+ tx_hash: str,
551
+ bridge: str | None = None,
552
+ from_chain: str | None = None,
553
+ to_chain: str | None = None,
554
+ ) -> dict[str, Any]:
555
+ payload = await lifi.fetch_transfer_status(
556
+ tx_hash=tx_hash,
557
+ bridge=bridge,
558
+ from_chain=from_chain,
559
+ to_chain=to_chain,
560
+ )
561
+ return {
562
+ "provider": "lifi",
563
+ "chain": "cross-chain",
564
+ "network": "mainnet",
565
+ "tx_hash": tx_hash,
566
+ "bridge": bridge,
567
+ "from_chain": from_chain,
568
+ "to_chain": to_chain,
569
+ "status": payload.get("status"),
570
+ "substatus": payload.get("substatus"),
571
+ "sending": payload.get("sending"),
572
+ "receiving": payload.get("receiving"),
573
+ "transfer": payload,
574
+ "source": "lifi",
575
+ }
576
+
577
+ def _build_lifi_fee_summary(self, *, quote: dict[str, Any]) -> dict[str, Any]:
578
+ estimate = quote.get("estimate") if isinstance(quote.get("estimate"), dict) else {}
579
+ fee_costs = [item for item in estimate.get("feeCosts") or [] if isinstance(item, dict)]
580
+ gas_costs = [item for item in estimate.get("gasCosts") or [] if isinstance(item, dict)]
581
+ return {
582
+ "swap_provider": "lifi",
583
+ "tool": quote.get("tool"),
584
+ "tool_details": quote.get("toolDetails"),
585
+ "fee_costs": fee_costs,
586
+ "gas_costs": gas_costs,
587
+ "execution_duration_seconds": estimate.get("executionDuration"),
588
+ "from_amount_usd": estimate.get("fromAmountUSD"),
589
+ "to_amount_usd": estimate.get("toAmountUSD"),
590
+ "quoted_output_includes_route_fees": True,
591
+ }
592
+
593
+ def _lifi_token_metadata(self, token: Any, fallback_address: str) -> dict[str, Any]:
594
+ raw = token if isinstance(token, dict) else {}
595
+ decimals = _coerce_int(raw.get("decimals"))
596
+ return {
597
+ "address": str(raw.get("address") or fallback_address or "").strip(),
598
+ "chain_id": raw.get("chainId"),
599
+ "symbol": raw.get("symbol"),
600
+ "name": raw.get("name"),
601
+ "decimals": decimals,
602
+ "coin_key": raw.get("coinKey"),
603
+ "price_usd": raw.get("priceUSD"),
604
+ "tags": raw.get("tags") if isinstance(raw.get("tags"), list) else [],
605
+ "source": "lifi",
606
+ }
607
+
608
+ def _format_lifi_amount(self, amount: Any, decimals: int | None) -> float | None:
609
+ parsed = _coerce_positive_int_from_any(amount)
610
+ if parsed is None or decimals is None or decimals < 0:
611
+ return None
612
+ return parsed / (10**decimals)
613
+
614
+ def _normalize_solana_lifi_preview_payload(
615
+ self,
616
+ *,
617
+ quote: dict[str, Any],
618
+ owner: str,
619
+ destination_chain_id: str,
620
+ destination_chain: str,
621
+ input_token: str,
622
+ output_token: str,
623
+ destination_address: str,
624
+ amount_in_raw: str,
625
+ slippage: float | int | None,
626
+ allow_bridges: list[str] | None,
627
+ deny_bridges: list[str] | None,
628
+ prefer_bridges: list[str] | None,
629
+ ) -> dict[str, Any]:
630
+ action = quote.get("action") if isinstance(quote.get("action"), dict) else {}
631
+ estimate = quote.get("estimate") if isinstance(quote.get("estimate"), dict) else {}
632
+ transaction_request = quote.get("transactionRequest")
633
+ transaction_data = (
634
+ str(transaction_request.get("data") or "").strip()
635
+ if isinstance(transaction_request, dict)
636
+ else ""
637
+ )
638
+ normalized_input_token = lifi.normalize_token_address(input_token, chain_id="1151111081099710")
639
+ normalized_output_token = lifi.normalize_token_address(output_token, chain_id=destination_chain_id)
640
+ input_metadata = self._lifi_token_metadata(action.get("fromToken"), normalized_input_token)
641
+ output_metadata = self._lifi_token_metadata(action.get("toToken"), normalized_output_token)
642
+ input_decimals = input_metadata.get("decimals")
643
+ output_decimals = output_metadata.get("decimals")
644
+ estimated_output_amount_raw = str(estimate.get("toAmount") or "0")
645
+ minimum_output_amount_raw = str(estimate.get("toAmountMin") or estimate.get("toAmount") or "0")
646
+ quote_id = str(quote.get("id") or "").strip() or None
647
+ transaction_id = str(quote.get("transactionId") or "").strip() or None
648
+ return {
649
+ "chain": "solana",
650
+ "network": self.network,
651
+ "mode": "preview",
652
+ "asset_type": "solana-lifi-cross-chain-swap",
653
+ "owner": owner,
654
+ "source_chain": "solana",
655
+ "source_chain_id": "1151111081099710",
656
+ "destination_chain": destination_chain,
657
+ "destination_chain_id": destination_chain_id,
658
+ "input_token": normalized_input_token,
659
+ "input_mint": normalized_input_token,
660
+ "output_token": normalized_output_token,
661
+ "destination_address": destination_address,
662
+ "input_amount_raw": amount_in_raw,
663
+ "input_amount_ui": self._format_lifi_amount(amount_in_raw, input_decimals),
664
+ "input_decimals": input_decimals,
665
+ "input_symbol": input_metadata.get("symbol"),
666
+ "estimated_output_amount_raw": estimated_output_amount_raw,
667
+ "estimated_output_amount_ui": self._format_lifi_amount(
668
+ estimated_output_amount_raw,
669
+ output_decimals,
670
+ ),
671
+ "minimum_output_amount_raw": minimum_output_amount_raw,
672
+ "minimum_output_amount_ui": self._format_lifi_amount(
673
+ minimum_output_amount_raw,
674
+ output_decimals,
675
+ ),
676
+ "output_decimals": output_decimals,
677
+ "output_symbol": output_metadata.get("symbol"),
678
+ "slippage": slippage,
679
+ "allow_bridges": allow_bridges,
680
+ "deny_bridges": deny_bridges,
681
+ "prefer_bridges": prefer_bridges,
682
+ "quote_type": quote.get("type"),
683
+ "quote_id": quote_id,
684
+ "transaction_id": transaction_id,
685
+ "tool": quote.get("tool"),
686
+ "tool_details": quote.get("toolDetails"),
687
+ "fee_summary": self._build_lifi_fee_summary(quote=quote),
688
+ "route_plan": quote.get("includedSteps") or [],
689
+ "transaction_data_hash": (
690
+ hashlib.sha256(transaction_data.encode("utf-8")).hexdigest()
691
+ if transaction_data
692
+ else None
693
+ ),
694
+ "input_token_metadata": input_metadata,
695
+ "output_token_metadata": output_metadata,
696
+ "swap_provider": "lifi",
697
+ "can_send": self.get_capabilities().can_send_transaction,
698
+ "sign_only": self.sign_only,
699
+ "source": "lifi",
700
+ }
701
+
702
+ async def preview_solana_lifi_cross_chain_swap(
703
+ self,
704
+ *,
705
+ input_token: str,
706
+ destination_chain: str,
707
+ output_token: str,
708
+ destination_address: str,
709
+ amount_in_raw: str,
710
+ slippage: float | int | None = None,
711
+ allow_bridges: list[str] | None = None,
712
+ deny_bridges: list[str] | None = None,
713
+ prefer_bridges: list[str] | None = None,
714
+ ) -> dict[str, Any]:
715
+ if self.network != "mainnet":
716
+ raise WalletBackendError("LI.FI Solana-origin cross-chain swaps are only enabled for Solana mainnet.")
717
+ amount_in_raw = _require_positive_integer_string(amount_in_raw, field_name="amount_in_raw")
718
+ destination_chain_id = lifi.normalize_chain_id(destination_chain, field_name="destination_chain")
719
+ if destination_chain_id == "1151111081099710":
720
+ raise WalletBackendError("Use swap_solana_tokens for Solana-only swaps.")
721
+ destination_chain_name = lifi.chain_name_for_id(destination_chain_id)
722
+ owner = await self.get_address()
723
+ if not owner:
724
+ raise WalletBackendError(
725
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
726
+ )
727
+ if not isinstance(destination_address, str) or not destination_address.strip():
728
+ raise WalletBackendError("destination_address is required.")
729
+
730
+ quote = await lifi.fetch_quote(
731
+ from_chain="solana",
732
+ to_chain=destination_chain_id,
733
+ from_token=input_token,
734
+ to_token=output_token,
735
+ amount_in_raw=amount_in_raw,
736
+ from_address=owner,
737
+ to_address=destination_address.strip(),
738
+ slippage=slippage,
739
+ allow_bridges=allow_bridges,
740
+ deny_bridges=deny_bridges,
741
+ prefer_bridges=prefer_bridges,
742
+ )
743
+ return self._normalize_solana_lifi_preview_payload(
744
+ quote=quote,
745
+ owner=owner,
746
+ destination_chain_id=destination_chain_id,
747
+ destination_chain=destination_chain_name,
748
+ input_token=input_token,
749
+ output_token=output_token,
750
+ destination_address=destination_address.strip(),
751
+ amount_in_raw=amount_in_raw,
752
+ slippage=slippage,
753
+ allow_bridges=allow_bridges,
754
+ deny_bridges=deny_bridges,
755
+ prefer_bridges=prefer_bridges,
756
+ )
757
+
758
+ def _lifi_transaction_data_from_quote(self, quote: dict[str, Any]) -> str:
759
+ transaction_request = quote.get("transactionRequest")
760
+ if not isinstance(transaction_request, dict):
761
+ raise WalletBackendError("LI.FI quote returned no Solana transactionRequest.")
762
+ transaction_data = str(transaction_request.get("data") or "").strip()
763
+ if not transaction_data:
764
+ raise WalletBackendError("LI.FI quote returned no Solana transactionRequest.data.")
765
+ return transaction_data
766
+
767
+ async def _verify_solana_lifi_transaction(
768
+ self,
769
+ message: Any,
770
+ *,
771
+ wallet_address: str,
772
+ input_token: str,
773
+ ) -> dict[str, Any]:
774
+ keys = [str(value) for value in getattr(message, "account_keys", []) or []]
775
+ if not keys:
776
+ raise WalletBackendError("LI.FI transaction does not include account keys.")
777
+ header = getattr(message, "header", None)
778
+ required_signature_count = int(getattr(header, "num_required_signatures", 0) or 0)
779
+ if required_signature_count <= 0 or required_signature_count > len(keys):
780
+ raise WalletBackendError("LI.FI transaction signer metadata is inconsistent.")
781
+ required_signer_keys = keys[:required_signature_count]
782
+ if wallet_address not in required_signer_keys:
783
+ raise WalletBackendError(
784
+ "LI.FI transaction does not require the connected wallet as an authorized signer."
785
+ )
786
+ if required_signature_count > 2:
787
+ raise WalletBackendError(
788
+ "LI.FI transaction requires unexpected additional signers and was rejected."
789
+ )
790
+ loaded_addresses = await self._resolve_versioned_message_lookup_addresses(message)
791
+ all_keys = keys + loaded_addresses
792
+ program_ids: list[str] = []
793
+ for instruction in list(getattr(message, "instructions", []) or []):
794
+ index = int(getattr(instruction, "program_id_index", -1))
795
+ if index < 0 or index >= len(all_keys):
796
+ raise WalletBackendError("LI.FI transaction contains an invalid program id index.")
797
+ program_ids.append(all_keys[index])
798
+ return {
799
+ "wallet_address": wallet_address,
800
+ "fee_payer": keys[0],
801
+ "required_signer_keys": required_signer_keys,
802
+ "required_signature_count": required_signature_count,
803
+ "wallet_signer_index": required_signer_keys.index(wallet_address),
804
+ "sponsored_fee_payer": keys[0] != wallet_address,
805
+ "program_ids": program_ids,
806
+ "non_core_program_ids": [
807
+ pid
808
+ for pid in program_ids
809
+ if pid
810
+ not in {
811
+ "11111111111111111111111111111111",
812
+ "ComputeBudget111111111111111111111111111111",
813
+ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
814
+ "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
815
+ "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
816
+ "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
817
+ "AddressLookupTab1e1111111111111111111111111",
818
+ }
819
+ ],
820
+ "account_key_count": len(all_keys),
821
+ "instruction_count": len(list(getattr(message, "instructions", []) or [])),
822
+ "input_token": input_token,
823
+ "verified": True,
824
+ }
825
+
826
+ async def execute_solana_lifi_cross_chain_swap(
827
+ self,
828
+ *,
829
+ input_token: str,
830
+ destination_chain: str,
831
+ output_token: str,
832
+ destination_address: str,
833
+ amount_in_raw: str,
834
+ slippage: float | int | None = None,
835
+ allow_bridges: list[str] | None = None,
836
+ deny_bridges: list[str] | None = None,
837
+ prefer_bridges: list[str] | None = None,
838
+ minimum_output_amount_raw: str | None = None,
839
+ ) -> dict[str, Any]:
840
+ if not self.signer:
841
+ raise WalletBackendError("Solana signer is not configured.")
842
+ if self.sign_only:
843
+ raise WalletBackendError(
844
+ "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
845
+ )
846
+ amount_in_raw = _require_positive_integer_string(amount_in_raw, field_name="amount_in_raw")
847
+ if not isinstance(destination_address, str) or not destination_address.strip():
848
+ raise WalletBackendError("destination_address is required.")
849
+ destination_address = destination_address.strip()
850
+ preview = await self.preview_solana_lifi_cross_chain_swap(
851
+ input_token=input_token,
852
+ destination_chain=destination_chain,
853
+ output_token=output_token,
854
+ destination_address=destination_address,
855
+ amount_in_raw=amount_in_raw,
856
+ slippage=slippage,
857
+ allow_bridges=allow_bridges,
858
+ deny_bridges=deny_bridges,
859
+ prefer_bridges=prefer_bridges,
860
+ )
861
+ if minimum_output_amount_raw is not None:
862
+ minimum_output_amount_raw = _require_positive_integer_string(
863
+ minimum_output_amount_raw,
864
+ field_name="minimum_output_amount_raw",
865
+ )
866
+ quoted_minimum = _coerce_positive_int_from_any(preview.get("minimum_output_amount_raw")) or 0
867
+ if quoted_minimum < int(minimum_output_amount_raw):
868
+ raise WalletBackendError(
869
+ "LI.FI quote changed below the approved minimum output. Generate a new preview and approval.",
870
+ code="swap_quote_changed",
871
+ details={
872
+ "approved_minimum_output_amount_raw": minimum_output_amount_raw,
873
+ "quoted_minimum_output_amount_raw": str(quoted_minimum),
874
+ },
875
+ )
876
+
877
+ quote = await lifi.fetch_quote(
878
+ from_chain="solana",
879
+ to_chain=str(preview["destination_chain_id"]),
880
+ from_token=input_token,
881
+ to_token=output_token,
882
+ amount_in_raw=amount_in_raw,
883
+ from_address=str(preview["owner"]),
884
+ to_address=destination_address,
885
+ slippage=slippage,
886
+ allow_bridges=allow_bridges,
887
+ deny_bridges=deny_bridges,
888
+ prefer_bridges=prefer_bridges,
889
+ )
890
+ final_preview = self._normalize_solana_lifi_preview_payload(
891
+ quote=quote,
892
+ owner=str(preview["owner"]),
893
+ destination_chain_id=str(preview["destination_chain_id"]),
894
+ destination_chain=str(preview["destination_chain"]),
895
+ input_token=input_token,
896
+ output_token=output_token,
897
+ destination_address=destination_address,
898
+ amount_in_raw=amount_in_raw,
899
+ slippage=slippage,
900
+ allow_bridges=allow_bridges,
901
+ deny_bridges=deny_bridges,
902
+ prefer_bridges=prefer_bridges,
903
+ )
904
+ if minimum_output_amount_raw is not None:
905
+ quoted_minimum = _coerce_positive_int_from_any(final_preview.get("minimum_output_amount_raw")) or 0
906
+ if quoted_minimum < int(minimum_output_amount_raw):
907
+ raise WalletBackendError(
908
+ "LI.FI quote changed below the approved minimum output. Generate a new preview and approval.",
909
+ code="swap_quote_changed",
910
+ details={
911
+ "approved_minimum_output_amount_raw": minimum_output_amount_raw,
912
+ "quoted_minimum_output_amount_raw": str(quoted_minimum),
913
+ },
914
+ )
915
+ transaction_data = self._lifi_transaction_data_from_quote(quote)
916
+ try:
917
+ from solders.transaction import VersionedTransaction
918
+ except ImportError as exc:
919
+ raise WalletBackendError(
920
+ "solana and solders packages are required for LI.FI transaction signing."
921
+ ) from exc
922
+ try:
923
+ unsigned_transaction = VersionedTransaction.from_bytes(base64.b64decode(transaction_data))
924
+ except Exception as exc:
925
+ raise WalletBackendError("LI.FI Solana transaction could not be decoded.") from exc
926
+ verification = await self._verify_solana_lifi_transaction(
927
+ unsigned_transaction.message,
928
+ wallet_address=str(final_preview["owner"]),
929
+ input_token=str(final_preview["input_token"]),
930
+ )
931
+ signed_transaction_base64 = await self._sign_versioned_provider_transaction(
932
+ transaction_base64=transaction_data,
933
+ wallet_signer_index=int(verification.get("wallet_signer_index") or 0),
934
+ )
935
+ simulation = await solana_rpc.simulate_transaction(
936
+ transaction_base64=signed_transaction_base64,
937
+ rpc_url=self.rpc_urls,
938
+ commitment=self.commitment,
939
+ )
940
+ simulation_value = simulation.get("value") if isinstance(simulation.get("value"), dict) else {}
941
+ if isinstance(simulation_value, dict) and simulation_value.get("err") is not None:
942
+ raise WalletBackendError(
943
+ "LI.FI Solana transaction simulation failed.",
944
+ code="transaction_simulation_failed",
945
+ details={"simulation": simulation_value},
946
+ )
947
+ submitted = await solana_rpc.send_transaction(
948
+ transaction_base64=signed_transaction_base64,
949
+ rpc_url=self.rpc_urls,
950
+ )
951
+ signature = str(submitted.get("signature") or "").strip()
952
+ status = None
953
+ if signature:
954
+ status = await solana_rpc.wait_for_confirmation(
955
+ signature=signature,
956
+ rpc_url=self.rpc_urls,
957
+ )
958
+ return {
959
+ **final_preview,
960
+ "mode": "execute",
961
+ "signature": signature or None,
962
+ "source_tx_hash": signature or None,
963
+ "broadcasted": bool(signature),
964
+ "confirmed": status is not None,
965
+ "confirmation_status": status.get("confirmationStatus") if status else None,
966
+ "slot": status.get("slot") if status else None,
967
+ "simulation": simulation_value,
968
+ "verification": verification,
969
+ "execute_response": submitted,
970
+ "quote_id": quote.get("id") or preview.get("quote_id"),
971
+ "transaction_id": quote.get("transactionId") or preview.get("transaction_id"),
972
+ "source": "lifi",
973
+ }
974
+
975
+ async def get_bags_claimable_positions(
976
+ self,
977
+ wallet: str | None = None,
978
+ ) -> dict[str, Any]:
979
+ self._require_mainnet_bags("Bags fee claims")
980
+ wallet_address = wallet or self.address
981
+ if not wallet_address:
982
+ raise WalletBackendError("A wallet address is required for Bags claimable positions.")
983
+ wallet_address = validate_solana_address(wallet_address)
984
+ raw = await bags.fetch_claimable_positions(wallet_address)
985
+ positions = self._bags_claim_positions_list(raw)
986
+ return {
987
+ "chain": "solana",
988
+ "network": self.network,
989
+ "wallet": wallet_address,
990
+ "position_count": len(positions),
991
+ "positions": positions,
992
+ "raw": raw,
993
+ "source": "bags",
994
+ }
995
+
996
+ async def get_bags_fee_analytics(
997
+ self,
998
+ token_mint: str,
999
+ *,
1000
+ include_claim_events: bool = False,
1001
+ mode: str = "offset",
1002
+ limit: int | None = None,
1003
+ offset: int | None = None,
1004
+ from_ts: int | None = None,
1005
+ to_ts: int | None = None,
1006
+ ) -> dict[str, Any]:
1007
+ self._require_mainnet_bags("Bags fee analytics")
1008
+ normalized_mint = validate_solana_mint(token_mint)
1009
+ if mode not in {"offset", "time"}:
1010
+ raise WalletBackendError("mode must be 'offset' or 'time'.")
1011
+ if limit is not None and limit <= 0:
1012
+ raise WalletBackendError("limit must be greater than zero when provided.")
1013
+ if offset is not None and offset < 0:
1014
+ raise WalletBackendError("offset must be greater than or equal to zero when provided.")
1015
+ if from_ts is not None and from_ts < 0:
1016
+ raise WalletBackendError("from_ts must be greater than or equal to zero.")
1017
+ if to_ts is not None and to_ts < 0:
1018
+ raise WalletBackendError("to_ts must be greater than or equal to zero.")
1019
+
1020
+ tasks = [
1021
+ bags.fetch_lifetime_fees(normalized_mint),
1022
+ bags.fetch_claim_stats(normalized_mint),
1023
+ ]
1024
+ if include_claim_events:
1025
+ tasks.append(
1026
+ bags.fetch_claim_events(
1027
+ token_mint=normalized_mint,
1028
+ mode=mode,
1029
+ limit=limit,
1030
+ offset=offset,
1031
+ from_ts=from_ts,
1032
+ to_ts=to_ts,
1033
+ )
1034
+ )
1035
+ results = await asyncio.gather(*tasks)
1036
+ claim_events = results[2] if include_claim_events else None
1037
+ return {
1038
+ "chain": "solana",
1039
+ "network": self.network,
1040
+ "token_mint": normalized_mint,
1041
+ "lifetime_fees": results[0],
1042
+ "claim_stats": results[1],
1043
+ "claim_events": claim_events,
1044
+ "include_claim_events": include_claim_events,
1045
+ "source": "bags",
1046
+ }
1047
+
1048
+ async def get_staking_validators(
1049
+ self,
1050
+ limit: int = 20,
1051
+ include_delinquent: bool = False,
1052
+ ) -> dict[str, Any]:
1053
+ if limit <= 0:
1054
+ raise WalletBackendError("limit must be greater than zero.")
1055
+ vote_accounts = await solana_rpc.fetch_vote_accounts(
1056
+ rpc_url=self.rpc_urls,
1057
+ commitment=self.commitment,
1058
+ )
1059
+ validators: list[dict[str, Any]] = []
1060
+ for item in vote_accounts["current"]:
1061
+ validator = dict(item)
1062
+ validator["status"] = "current"
1063
+ validators.append(validator)
1064
+ if include_delinquent:
1065
+ for item in vote_accounts["delinquent"]:
1066
+ validator = dict(item)
1067
+ validator["status"] = "delinquent"
1068
+ validators.append(validator)
1069
+ validators.sort(
1070
+ key=lambda item: int(item.get("activatedStake") or 0),
1071
+ reverse=True,
1072
+ )
1073
+ selected = validators[:limit]
1074
+ return {
1075
+ "chain": "solana",
1076
+ "network": self.network,
1077
+ "limit": limit,
1078
+ "include_delinquent": include_delinquent,
1079
+ "validator_count": len(selected),
1080
+ "validators": selected,
1081
+ "source": "solana-rpc",
1082
+ }
1083
+
1084
+ async def _fetch_stake_account_snapshot(self, stake_account: str) -> dict[str, Any]:
1085
+ stake_account = validate_solana_address(stake_account)
1086
+ account_info, balance, activation = await asyncio.gather(
1087
+ solana_rpc.fetch_account_info(
1088
+ stake_account,
1089
+ rpc_url=self.rpc_urls,
1090
+ encoding="jsonParsed",
1091
+ ),
1092
+ self._get_native_balance(stake_account),
1093
+ solana_rpc.fetch_stake_activation(
1094
+ stake_account,
1095
+ rpc_url=self.rpc_urls,
1096
+ commitment=self.commitment,
1097
+ ),
1098
+ )
1099
+ if account_info is None:
1100
+ raise WalletBackendError("Stake account was not found on Solana RPC.")
1101
+ if str(account_info.get("owner")) != STAKE_PROGRAM_ID:
1102
+ raise WalletBackendError("Provided account is not owned by the Solana Stake Program.")
1103
+
1104
+ parsed = account_info.get("data", {}).get("parsed", {}) if isinstance(account_info, dict) else {}
1105
+ info = parsed.get("info", {}) if isinstance(parsed, dict) else {}
1106
+ meta = info.get("meta", {}) if isinstance(info, dict) else {}
1107
+ authorized = meta.get("authorized", {}) if isinstance(meta, dict) else {}
1108
+ lockup = meta.get("lockup", {}) if isinstance(meta, dict) else {}
1109
+ stake_info = info.get("stake", {}) if isinstance(info, dict) else {}
1110
+ delegation = stake_info.get("delegation", {}) if isinstance(stake_info, dict) else {}
1111
+ rent_exempt_reserve = meta.get("rentExemptReserve")
1112
+ rent_exempt_lamports = int(rent_exempt_reserve or 0)
1113
+ balance_lamports = int(round(float(balance["balance_native"]) * solana_rpc.LAMPORTS_PER_SOL))
1114
+ withdrawable_lamports = max(balance_lamports - rent_exempt_lamports, 0)
1115
+ return {
1116
+ "chain": "solana",
1117
+ "network": self.network,
1118
+ "stake_account": stake_account,
1119
+ "lamports": balance_lamports,
1120
+ "balance_native": balance["balance_native"],
1121
+ "rent_exempt_reserve_lamports": rent_exempt_lamports,
1122
+ "rent_exempt_reserve_native": rent_exempt_lamports / solana_rpc.LAMPORTS_PER_SOL,
1123
+ "estimated_withdrawable_lamports": withdrawable_lamports,
1124
+ "estimated_withdrawable_native": withdrawable_lamports / solana_rpc.LAMPORTS_PER_SOL,
1125
+ "account_type": parsed.get("type") if isinstance(parsed, dict) else None,
1126
+ "authorized_staker": authorized.get("staker"),
1127
+ "authorized_withdrawer": authorized.get("withdrawer"),
1128
+ "lockup": lockup,
1129
+ "delegation": delegation,
1130
+ "activation": activation,
1131
+ "raw_account": account_info,
1132
+ "source": "solana-rpc",
1133
+ }
1134
+
1135
+ async def get_stake_account(self, stake_account: str) -> dict[str, Any]:
1136
+ return await self._fetch_stake_account_snapshot(stake_account)
1137
+
1138
+ def _build_swap_fee_summary(
1139
+ self,
1140
+ *,
1141
+ swap_provider: str,
1142
+ quote_response: dict[str, Any],
1143
+ prioritization_fee_lamports: Any = None,
1144
+ compute_unit_limit: Any = None,
1145
+ signature_fee_lamports: Any = None,
1146
+ rent_fee_lamports: Any = None,
1147
+ ) -> dict[str, Any]:
1148
+ platform_fee = quote_response.get("platformFee")
1149
+ fee_bps = _coerce_int(quote_response.get("feeBps"))
1150
+ if fee_bps is None and isinstance(platform_fee, dict):
1151
+ fee_bps = _coerce_int(platform_fee.get("feeBps"))
1152
+ resolved_signature_fee = _coerce_int(signature_fee_lamports)
1153
+ resolved_priority_fee = _coerce_int(prioritization_fee_lamports)
1154
+ resolved_rent_fee = _coerce_int(rent_fee_lamports)
1155
+
1156
+ if resolved_signature_fee is None and swap_provider == "jupiter-metis":
1157
+ resolved_signature_fee = SOLANA_BASE_FEE_LAMPORTS
1158
+ if resolved_signature_fee is None:
1159
+ resolved_signature_fee = _coerce_int(quote_response.get("signatureFeeLamports"))
1160
+ if resolved_priority_fee is None:
1161
+ resolved_priority_fee = _coerce_int(quote_response.get("prioritizationFeeLamports"))
1162
+ if resolved_rent_fee is None:
1163
+ resolved_rent_fee = _coerce_int(quote_response.get("rentFeeLamports"))
1164
+
1165
+ known_lamport_parts = [
1166
+ value
1167
+ for value in (resolved_signature_fee, resolved_priority_fee, resolved_rent_fee)
1168
+ if isinstance(value, int)
1169
+ ]
1170
+ total_known_lamports = sum(known_lamport_parts)
1171
+
1172
+ return {
1173
+ "swap_provider": swap_provider,
1174
+ "network_fee_lamports": total_known_lamports,
1175
+ "network_fee_sol": total_known_lamports / solana_rpc.LAMPORTS_PER_SOL,
1176
+ "signature_fee_lamports": resolved_signature_fee or 0,
1177
+ "prioritization_fee_lamports": resolved_priority_fee or 0,
1178
+ "rent_fee_lamports": resolved_rent_fee or 0,
1179
+ "route_fee_bps": fee_bps,
1180
+ "compute_unit_limit": _coerce_int(compute_unit_limit),
1181
+ "quoted_output_includes_route_fees": True,
1182
+ }
1183
+
1184
+ def _format_swap_fee_label(self, fee_summary: dict[str, Any]) -> str:
1185
+ network_fee_sol = float(fee_summary.get("network_fee_sol") or 0)
1186
+ route_fee_bps = fee_summary.get("route_fee_bps")
1187
+ parts = [f"network fee ~{network_fee_sol:.6f} SOL"]
1188
+ if isinstance(route_fee_bps, int):
1189
+ parts.append(f"route fee {route_fee_bps} bps (already reflected in quoted output)")
1190
+ return "; ".join(parts)
1191
+
1192
+ def _require_mainnet_bags(self, feature: str) -> None:
1193
+ if self.network != "mainnet":
1194
+ raise WalletBackendError(f"{feature} is only enabled for Solana mainnet.")
1195
+
1196
+ def _normalize_bags_claimers(self, claimers: list[str]) -> list[str]:
1197
+ if not isinstance(claimers, list) or not claimers:
1198
+ raise WalletBackendError("claimers must be a non-empty array of Solana wallet addresses.")
1199
+ if len(claimers) > 100:
1200
+ raise WalletBackendError("Bags fee share supports at most 100 claimers.")
1201
+ normalized: list[str] = []
1202
+ for raw in claimers:
1203
+ if not isinstance(raw, str) or not raw.strip():
1204
+ raise WalletBackendError(
1205
+ "claimers must be a non-empty array of Solana wallet addresses."
1206
+ )
1207
+ normalized.append(validate_solana_address(raw.strip()))
1208
+ return normalized
1209
+
1210
+ def _normalize_bags_basis_points(self, basis_points: list[int]) -> list[int]:
1211
+ if not isinstance(basis_points, list) or not basis_points:
1212
+ raise WalletBackendError("basis_points must be a non-empty array of integers.")
1213
+ normalized = [
1214
+ _coerce_non_negative_integer(value, field_name="basis_points")
1215
+ for value in basis_points
1216
+ ]
1217
+ if sum(normalized) != 10_000:
1218
+ raise WalletBackendError("basis_points must sum to exactly 10000.")
1219
+ return normalized
1220
+
1221
+ def _bags_claim_positions_list(self, payload: Any) -> list[dict[str, Any]]:
1222
+ if isinstance(payload, list):
1223
+ return [item for item in payload if isinstance(item, dict)]
1224
+ if isinstance(payload, dict):
1225
+ for key in ("positions", "claimablePositions", "items", "data"):
1226
+ value = payload.get(key)
1227
+ if isinstance(value, list):
1228
+ return [item for item in value if isinstance(item, dict)]
1229
+ return []
1230
+
1231
+ def _bags_decode_serialized_transaction_bytes(self, serialized_transaction: str) -> bytes:
1232
+ serialized = str(serialized_transaction).strip()
1233
+ if not serialized:
1234
+ raise WalletBackendError("Bags serialized transaction is empty.")
1235
+ try:
1236
+ return base64.b64decode(serialized, validate=True)
1237
+ except (ValueError, binascii.Error):
1238
+ return b58decode(serialized)
1239
+
1240
+ def _bags_extract_serialized_transaction_string(self, payload: Any) -> str:
1241
+ if isinstance(payload, str):
1242
+ cleaned = payload.strip()
1243
+ return cleaned if cleaned else ""
1244
+ if isinstance(payload, dict):
1245
+ for key in ("transaction", "tx", "response"):
1246
+ value = payload.get(key)
1247
+ if isinstance(value, str) and value.strip():
1248
+ return value.strip()
1249
+ return ""
1250
+
1251
+ def _bags_extract_serialized_transaction_strings(self, payload: Any) -> list[str]:
1252
+ if isinstance(payload, str):
1253
+ cleaned = payload.strip()
1254
+ return [cleaned] if cleaned else []
1255
+ if isinstance(payload, list):
1256
+ normalized: list[str] = []
1257
+ for item in payload:
1258
+ value = self._bags_extract_serialized_transaction_string(item)
1259
+ if value:
1260
+ normalized.append(value)
1261
+ return normalized
1262
+ if isinstance(payload, dict):
1263
+ normalized: list[str] = []
1264
+ for key in ("transactions", "txs", "responses"):
1265
+ value = payload.get(key)
1266
+ if isinstance(value, list):
1267
+ normalized.extend(self._bags_extract_serialized_transaction_strings(value))
1268
+ for key in (
1269
+ "transaction",
1270
+ "launchTransaction",
1271
+ "serializedTransaction",
1272
+ "swapTransaction",
1273
+ "response",
1274
+ "tx",
1275
+ ):
1276
+ value = payload.get(key)
1277
+ if isinstance(value, str) and value.strip():
1278
+ normalized.append(value.strip())
1279
+ return normalized
1280
+ return []
1281
+
1282
+ def _bags_extract_fee_share_config_transaction_strings(self, payload: Any) -> list[str]:
1283
+ if not isinstance(payload, dict):
1284
+ return self._bags_extract_serialized_transaction_strings(payload)
1285
+ transaction_strings: list[str] = []
1286
+ transactions = payload.get("transactions")
1287
+ if isinstance(transactions, list):
1288
+ transaction_strings.extend(self._bags_extract_serialized_transaction_strings(transactions))
1289
+ bundles = payload.get("bundles")
1290
+ if isinstance(bundles, list):
1291
+ for bundle in bundles:
1292
+ if isinstance(bundle, list):
1293
+ transaction_strings.extend(self._bags_extract_serialized_transaction_strings(bundle))
1294
+ else:
1295
+ value = self._bags_extract_serialized_transaction_string(bundle)
1296
+ if value:
1297
+ transaction_strings.append(value)
1298
+ return transaction_strings
1299
+
1300
+ def _bags_extract_transaction_base64s(self, payload: Any) -> list[str]:
1301
+ def _normalize(item: Any) -> list[str]:
1302
+ if isinstance(item, str):
1303
+ cleaned = item.strip()
1304
+ return [cleaned] if cleaned else []
1305
+ if isinstance(item, list):
1306
+ normalized: list[str] = []
1307
+ for value in item:
1308
+ normalized.extend(_normalize(value))
1309
+ return normalized
1310
+ if isinstance(item, dict):
1311
+ for key in ("tx", "transaction", "serializedTransaction", "swapTransaction", "launchTransaction"):
1312
+ value = item.get(key)
1313
+ if isinstance(value, str) and value.strip():
1314
+ return [value.strip()]
1315
+ for key in ("transactions", "claimTransactions", "txs", "responses", "response"):
1316
+ value = item.get(key)
1317
+ if isinstance(value, (list, dict, str)):
1318
+ normalized = _normalize(value)
1319
+ if normalized:
1320
+ return normalized
1321
+ return []
1322
+
1323
+ return _normalize(payload)
1324
+
1325
+ def _bags_extract_token_info_fields(self, payload: Any) -> tuple[str, str]:
1326
+ if not isinstance(payload, dict):
1327
+ raise WalletBackendError("Bags token info response is missing metadata fields.")
1328
+ token_mint = str(payload.get("tokenMint") or "").strip()
1329
+ token_launch = payload.get("tokenLaunch") if isinstance(payload.get("tokenLaunch"), dict) else {}
1330
+ token_metadata = payload.get("tokenMetadata")
1331
+ metadata_candidates: list[Any] = [
1332
+ token_launch.get("uri") if isinstance(token_launch.get("uri"), str) else None,
1333
+ token_launch.get("ipfs") if isinstance(token_launch.get("ipfs"), str) else None,
1334
+ token_launch.get("metadataUri") if isinstance(token_launch.get("metadataUri"), str) else None,
1335
+ token_metadata if isinstance(token_metadata, str) else None,
1336
+ token_metadata.get("uri") if isinstance(token_metadata, dict) else None,
1337
+ token_metadata.get("ipfs") if isinstance(token_metadata, dict) else None,
1338
+ token_metadata.get("metadataUri") if isinstance(token_metadata, dict) else None,
1339
+ payload.get("ipfs") if isinstance(payload.get("ipfs"), str) else None,
1340
+ payload.get("metadataUri") if isinstance(payload.get("metadataUri"), str) else None,
1341
+ payload.get("uri") if isinstance(payload.get("uri"), str) else None,
1342
+ ]
1343
+ ipfs = next((str(candidate).strip() for candidate in metadata_candidates if isinstance(candidate, str) and candidate.strip()), "")
1344
+ if not token_mint:
1345
+ raise WalletBackendError("Bags token info response is missing tokenMint.")
1346
+ if not ipfs:
1347
+ raise WalletBackendError("Bags token info response is missing metadata reference.")
1348
+ return token_mint, ipfs
1349
+
1350
+ def _bags_extract_config_key(self, payload: Any) -> str:
1351
+ if not isinstance(payload, dict):
1352
+ raise WalletBackendError("Bags fee share config response is missing config key.")
1353
+ config_key = str(
1354
+ payload.get("meteoraConfigKey")
1355
+ or payload.get("configKey")
1356
+ or payload.get("key")
1357
+ or ""
1358
+ ).strip()
1359
+ if not config_key:
1360
+ raise WalletBackendError("Bags fee share config response is missing config key.")
1361
+ return config_key
1362
+
1363
+ async def _prepare_bags_transactions(
1364
+ self,
1365
+ *,
1366
+ transaction_base64s: list[str],
1367
+ token_mint: str,
1368
+ action: str,
1369
+ owner: str,
1370
+ asset_type: str,
1371
+ extra: dict[str, Any],
1372
+ ) -> dict[str, Any]:
1373
+ if not self.signer:
1374
+ raise WalletBackendError("Solana signer is not configured.")
1375
+ if not transaction_base64s:
1376
+ raise WalletBackendError(f"{action} did not return any transactions.")
1377
+ try:
1378
+ from solders.transaction import VersionedTransaction
1379
+ except ImportError as exc:
1380
+ raise WalletBackendError(
1381
+ "solana and solders packages are required for Bags transaction signing."
1382
+ ) from exc
1383
+
1384
+ signed_transactions: list[str] = []
1385
+ verifications: list[dict[str, Any]] = []
1386
+ for transaction_base64 in transaction_base64s:
1387
+ unsigned_transaction = VersionedTransaction.from_bytes(
1388
+ self._bags_decode_serialized_transaction_bytes(transaction_base64)
1389
+ )
1390
+ loaded_addresses = await self._resolve_versioned_message_lookup_addresses(
1391
+ unsigned_transaction.message
1392
+ )
1393
+ verification = verify_provider_bags_transaction(
1394
+ unsigned_transaction.message,
1395
+ wallet_address=owner,
1396
+ token_mint=token_mint,
1397
+ action=action,
1398
+ loaded_addresses=loaded_addresses,
1399
+ )
1400
+ signed_transactions.append(
1401
+ await self._sign_versioned_provider_transaction(
1402
+ transaction_base64=transaction_base64,
1403
+ wallet_signer_index=int(verification.get("wallet_signer_index") or 0),
1404
+ )
1405
+ )
1406
+ verifications.append(verification)
1407
+
1408
+ return {
1409
+ "chain": "solana",
1410
+ "network": self.network,
1411
+ "mode": "prepare",
1412
+ "asset_type": asset_type,
1413
+ "owner": owner,
1414
+ "token_mint": token_mint,
1415
+ "transaction_count": len(signed_transactions),
1416
+ "transactions_base64": signed_transactions,
1417
+ "transaction_encoding": "base64",
1418
+ "transaction_format": "versioned",
1419
+ "signed": True,
1420
+ "broadcasted": False,
1421
+ "confirmed": False,
1422
+ "verification": verifications[0] if len(verifications) == 1 else None,
1423
+ "verifications": verifications,
1424
+ "sign_only": self.sign_only,
1425
+ "source": "bags",
1426
+ **extra,
1427
+ }
1428
+
1429
+ async def _execute_prepared_bags_transactions(
1430
+ self,
1431
+ prepared: dict[str, Any],
1432
+ ) -> dict[str, Any]:
1433
+ if self.sign_only:
1434
+ raise WalletBackendError(
1435
+ "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
1436
+ )
1437
+ serialized = prepared.get("transactions_base64")
1438
+ if not isinstance(serialized, list) or not serialized:
1439
+ raise WalletBackendError("Prepared Bags transaction payload is missing transactions.")
1440
+
1441
+ signatures: list[str] = []
1442
+ statuses: list[dict[str, Any] | None] = []
1443
+ for transaction_base64 in serialized:
1444
+ submitted = await solana_rpc.send_transaction(
1445
+ transaction_base64=str(transaction_base64),
1446
+ rpc_url=self.rpc_urls,
1447
+ )
1448
+ signature = str(submitted.get("signature") or "").strip()
1449
+ signatures.append(signature)
1450
+ status = None
1451
+ if signature:
1452
+ status = await solana_rpc.wait_for_confirmation(
1453
+ signature=signature,
1454
+ rpc_url=self.rpc_urls,
1455
+ )
1456
+ statuses.append(status)
1457
+
1458
+ confirmed = all(status is not None for status in statuses)
1459
+ return {
1460
+ "chain": "solana",
1461
+ "network": self.network,
1462
+ "mode": "execute",
1463
+ "asset_type": prepared["asset_type"],
1464
+ "owner": prepared.get("owner"),
1465
+ "token_mint": prepared.get("token_mint"),
1466
+ "transaction_count": len(serialized),
1467
+ "signatures": signatures,
1468
+ "signature": signatures[0] if len(signatures) == 1 else None,
1469
+ "broadcasted": any(bool(item) for item in signatures),
1470
+ "confirmed": confirmed,
1471
+ "confirmation_statuses": [
1472
+ status.get("confirmationStatus") if status else None for status in statuses
1473
+ ],
1474
+ "slots": [status.get("slot") if status else None for status in statuses],
1475
+ "verification": prepared.get("verification"),
1476
+ "verifications": prepared.get("verifications"),
1477
+ "source": "bags",
1478
+ }
1479
+
1480
+ def _require_mainnet_jupiter(self, feature: str) -> None:
1481
+ if self.network != "mainnet":
1482
+ raise WalletBackendError(f"{feature} is only enabled for Solana mainnet.")
1483
+
1484
+ def _require_mainnet_kamino(self, feature: str) -> None:
1485
+ if self.network != "mainnet":
1486
+ raise WalletBackendError(f"{feature} is only enabled for Solana mainnet.")
1487
+
1488
+ async def get_jupiter_portfolio_platforms(self) -> dict[str, Any]:
1489
+ self._require_mainnet_jupiter("Jupiter portfolio")
1490
+ data = await jupiter.fetch_portfolio_platforms()
1491
+ platforms = data.get("platforms")
1492
+ if not isinstance(platforms, list):
1493
+ platforms = data.get("data") if isinstance(data.get("data"), list) else []
1494
+ return {
1495
+ "chain": "solana",
1496
+ "network": self.network,
1497
+ "platform_count": len(platforms),
1498
+ "platforms": platforms,
1499
+ "raw": data,
1500
+ "source": "jupiter-portfolio",
1501
+ }
1502
+
1503
+ async def get_jupiter_portfolio(
1504
+ self,
1505
+ address: str | None = None,
1506
+ platforms: list[str] | None = None,
1507
+ ) -> dict[str, Any]:
1508
+ self._require_mainnet_jupiter("Jupiter portfolio")
1509
+ wallet_address = address or self.address
1510
+ if not wallet_address:
1511
+ raise WalletBackendError(
1512
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
1513
+ )
1514
+ wallet_address = validate_solana_address(wallet_address)
1515
+ platform_filter: list[str] | None = None
1516
+ if platforms is not None:
1517
+ platform_filter = []
1518
+ for platform in platforms:
1519
+ if not isinstance(platform, str) or not platform.strip():
1520
+ raise WalletBackendError("Each platform must be a non-empty string.")
1521
+ platform_filter.append(platform.strip())
1522
+ data = await jupiter.fetch_portfolio_positions(
1523
+ address=wallet_address,
1524
+ platforms=platform_filter,
1525
+ )
1526
+ positions = data.get("positions")
1527
+ if not isinstance(positions, list):
1528
+ positions = data.get("data") if isinstance(data.get("data"), list) else []
1529
+ return {
1530
+ "chain": "solana",
1531
+ "network": self.network,
1532
+ "address": wallet_address,
1533
+ "platforms": platform_filter or [],
1534
+ "position_count": len(positions),
1535
+ "positions": positions,
1536
+ "raw": data,
1537
+ "source": "jupiter-portfolio",
1538
+ }
1539
+
1540
+ async def get_jupiter_staked_jup(self, address: str | None = None) -> dict[str, Any]:
1541
+ self._require_mainnet_jupiter("Jupiter staked JUP")
1542
+ wallet_address = address or self.address
1543
+ if not wallet_address:
1544
+ raise WalletBackendError(
1545
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
1546
+ )
1547
+ wallet_address = validate_solana_address(wallet_address)
1548
+ data = await jupiter.fetch_staked_jup(address=wallet_address)
1549
+ return {
1550
+ "chain": "solana",
1551
+ "network": self.network,
1552
+ "address": wallet_address,
1553
+ "raw": data,
1554
+ "source": "jupiter-portfolio",
1555
+ }
1556
+
1557
+ async def get_jupiter_earn_tokens(self) -> dict[str, Any]:
1558
+ self._require_mainnet_jupiter("Jupiter Earn")
1559
+ data = await jupiter.fetch_earn_tokens()
1560
+ tokens = data.get("tokens")
1561
+ if not isinstance(tokens, list):
1562
+ tokens = []
1563
+ return {
1564
+ "chain": "solana",
1565
+ "network": self.network,
1566
+ "token_count": len(tokens),
1567
+ "tokens": tokens,
1568
+ "raw": data,
1569
+ "source": "jupiter-lend",
1570
+ }
1571
+
1572
+ async def get_jupiter_earn_positions(
1573
+ self,
1574
+ users: list[str] | None = None,
1575
+ ) -> dict[str, Any]:
1576
+ self._require_mainnet_jupiter("Jupiter Earn")
1577
+ resolved_users = users or [self.address]
1578
+ if not resolved_users or any(user is None for user in resolved_users):
1579
+ raise WalletBackendError("At least one wallet address is required for Earn positions.")
1580
+ normalized_users = [validate_solana_address(str(user)) for user in resolved_users]
1581
+ data = await jupiter.fetch_earn_positions(users=normalized_users)
1582
+ positions = data.get("positions")
1583
+ if not isinstance(positions, list):
1584
+ positions = []
1585
+ return {
1586
+ "chain": "solana",
1587
+ "network": self.network,
1588
+ "users": normalized_users,
1589
+ "position_count": len(positions),
1590
+ "positions": positions,
1591
+ "raw": data,
1592
+ "source": "jupiter-lend",
1593
+ }
1594
+
1595
+ async def get_jupiter_earn_earnings(
1596
+ self,
1597
+ user: str | None = None,
1598
+ positions: list[str] | None = None,
1599
+ ) -> dict[str, Any]:
1600
+ self._require_mainnet_jupiter("Jupiter Earn")
1601
+ wallet_address = user or self.address
1602
+ if not wallet_address:
1603
+ raise WalletBackendError(
1604
+ "A wallet address is required for Jupiter Earn earnings lookup."
1605
+ )
1606
+ if not positions:
1607
+ raise WalletBackendError("positions must include at least one Earn position address.")
1608
+ wallet_address = validate_solana_address(wallet_address)
1609
+ normalized_positions = [validate_solana_address(str(position)) for position in positions]
1610
+ data = await jupiter.fetch_earn_earnings(
1611
+ user=wallet_address,
1612
+ positions=normalized_positions,
1613
+ )
1614
+ earnings = data.get("earnings")
1615
+ if not isinstance(earnings, list):
1616
+ earnings = []
1617
+ return {
1618
+ "chain": "solana",
1619
+ "network": self.network,
1620
+ "user": wallet_address,
1621
+ "positions": normalized_positions,
1622
+ "earnings": earnings,
1623
+ "raw": data,
1624
+ "source": "jupiter-lend",
1625
+ }
1626
+
1627
+ async def get_kamino_lend_markets(self) -> dict[str, Any]:
1628
+ self._require_mainnet_kamino("Kamino lending")
1629
+ data = await kamino.fetch_lend_markets()
1630
+ markets = data.get("markets")
1631
+ if not isinstance(markets, list):
1632
+ markets = []
1633
+ return {
1634
+ "chain": "solana",
1635
+ "network": self.network,
1636
+ "market_count": len(markets),
1637
+ "markets": markets,
1638
+ "raw": data,
1639
+ "source": "kamino",
1640
+ }
1641
+
1642
+ async def get_kamino_lend_market_reserves(self, market: str) -> dict[str, Any]:
1643
+ self._require_mainnet_kamino("Kamino lending")
1644
+ market = validate_solana_address(market)
1645
+ data = await kamino.fetch_lend_market_reserves(
1646
+ market=market,
1647
+ network=self.network,
1648
+ )
1649
+ reserves = data.get("reserves")
1650
+ if not isinstance(reserves, list):
1651
+ reserves = []
1652
+ return {
1653
+ "chain": "solana",
1654
+ "network": self.network,
1655
+ "market": market,
1656
+ "reserve_count": len(reserves),
1657
+ "reserves": reserves,
1658
+ "raw": data,
1659
+ "source": "kamino",
1660
+ }
1661
+
1662
+ async def get_kamino_lend_user_obligations(
1663
+ self,
1664
+ market: str,
1665
+ user: str | None = None,
1666
+ ) -> dict[str, Any]:
1667
+ self._require_mainnet_kamino("Kamino lending")
1668
+ wallet_address = user or self.address
1669
+ if not wallet_address:
1670
+ raise WalletBackendError("A wallet address is required for Kamino obligation lookup.")
1671
+ market = validate_solana_address(market)
1672
+ wallet_address = validate_solana_address(wallet_address)
1673
+ data = await kamino.fetch_lend_user_obligations(
1674
+ market=market,
1675
+ user=wallet_address,
1676
+ network=self.network,
1677
+ )
1678
+ obligations = data.get("obligations")
1679
+ if not isinstance(obligations, list):
1680
+ obligations = []
1681
+ return {
1682
+ "chain": "solana",
1683
+ "network": self.network,
1684
+ "market": market,
1685
+ "user": wallet_address,
1686
+ "obligation_count": len(obligations),
1687
+ "obligations": obligations,
1688
+ "raw": data,
1689
+ "source": "kamino",
1690
+ }
1691
+
1692
+ async def get_kamino_lend_user_rewards(self, user: str | None = None) -> dict[str, Any]:
1693
+ self._require_mainnet_kamino("Kamino lending")
1694
+ wallet_address = user or self.address
1695
+ if not wallet_address:
1696
+ raise WalletBackendError("A wallet address is required for Kamino rewards lookup.")
1697
+ wallet_address = validate_solana_address(wallet_address)
1698
+ data = await kamino.fetch_lend_user_rewards(user=wallet_address)
1699
+ rewards = data.get("rewards")
1700
+ if not isinstance(rewards, list):
1701
+ rewards = []
1702
+ return {
1703
+ "chain": "solana",
1704
+ "network": self.network,
1705
+ "user": wallet_address,
1706
+ "reward_count": len(rewards),
1707
+ "rewards": rewards,
1708
+ "avg_base_apy": data.get("avgBaseApy"),
1709
+ "avg_boosted_apy": data.get("avgBoostedApy"),
1710
+ "avg_max_apy": data.get("avgMaxApy"),
1711
+ "raw": data,
1712
+ "source": "kamino",
1713
+ }
1714
+
1715
+ async def get_state(self) -> SolanaWalletState:
1716
+ balance_native = None
1717
+ if self.address:
1718
+ balance = await self._get_native_balance(self.address)
1719
+ balance_native = balance["balance_native"]
1720
+
1721
+ return SolanaWalletState(
1722
+ chain="solana",
1723
+ backend=self.name,
1724
+ address=self.address,
1725
+ balance_native=balance_native,
1726
+ sign_only=self.sign_only,
1727
+ has_signer=self.signer is not None,
1728
+ )
1729
+
1730
+ async def describe(self) -> AgentWalletCapabilities:
1731
+ return AgentWalletCapabilities(**self.get_capabilities().to_dict())
1732
+
1733
+ async def _resolve_versioned_message_lookup_addresses(self, message: Any) -> list[str]:
1734
+ lookups = list(getattr(message, "address_table_lookups", []) or [])
1735
+ if not lookups:
1736
+ return []
1737
+ try:
1738
+ from solders.address_lookup_table_account import AddressLookupTable
1739
+ except ImportError as exc:
1740
+ raise WalletBackendError(
1741
+ "solders package is required for Kamino lookup table verification."
1742
+ ) from exc
1743
+
1744
+ loaded_addresses: list[str] = []
1745
+ for lookup in lookups:
1746
+ table_address = str(lookup.account_key)
1747
+ account_info = await solana_rpc.fetch_account_info(
1748
+ table_address,
1749
+ rpc_url=self.rpc_urls,
1750
+ encoding="base64",
1751
+ )
1752
+ if not account_info:
1753
+ raise WalletBackendError(
1754
+ f"Failed to load address lookup table account {table_address}."
1755
+ )
1756
+ data = account_info.get("data")
1757
+ if not isinstance(data, list) or not data or not isinstance(data[0], str):
1758
+ raise WalletBackendError(
1759
+ f"Address lookup table {table_address} returned invalid account data."
1760
+ )
1761
+ try:
1762
+ raw = base64.b64decode(data[0])
1763
+ table = AddressLookupTable.deserialize(raw)
1764
+ except Exception as exc:
1765
+ raise WalletBackendError(
1766
+ f"Address lookup table {table_address} could not be decoded."
1767
+ ) from exc
1768
+ addresses = list(table.addresses)
1769
+ loaded_addresses.extend(
1770
+ str(addresses[int(index)]) for index in list(lookup.writable_indexes)
1771
+ )
1772
+ loaded_addresses.extend(
1773
+ str(addresses[int(index)]) for index in list(lookup.readonly_indexes)
1774
+ )
1775
+ return loaded_addresses
1776
+
1777
+ async def sign_message(self, message: bytes | str) -> str:
1778
+ if not self.signer:
1779
+ raise WalletBackendError("Solana signer is not configured.")
1780
+ payload = message.encode("utf-8") if isinstance(message, str) else message
1781
+ return b58encode(self.signer.sign_message(payload))
1782
+
1783
+ async def preview_native_stake(
1784
+ self,
1785
+ vote_account: str,
1786
+ amount_native: float,
1787
+ ) -> dict[str, Any]:
1788
+ owner = await self.get_address()
1789
+ if not owner:
1790
+ raise WalletBackendError(
1791
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
1792
+ )
1793
+ if amount_native <= 0:
1794
+ raise WalletBackendError("amount must be greater than zero.")
1795
+ vote_account = validate_solana_address(vote_account)
1796
+
1797
+ validator_set, balance, latest_blockhash, rent_exempt = await asyncio.gather(
1798
+ self.get_staking_validators(limit=200, include_delinquent=True),
1799
+ self._get_native_balance(owner),
1800
+ solana_rpc.fetch_latest_blockhash(
1801
+ rpc_url=self.rpc_urls,
1802
+ commitment=self.commitment,
1803
+ ),
1804
+ solana_rpc.fetch_minimum_balance_for_rent_exemption(
1805
+ STAKE_STATE_V2_SIZE,
1806
+ rpc_url=self.rpc_urls,
1807
+ commitment=self.commitment,
1808
+ ),
1809
+ )
1810
+
1811
+ validator = next(
1812
+ (item for item in validator_set["validators"] if str(item.get("votePubkey")) == vote_account),
1813
+ None,
1814
+ )
1815
+ if validator is None:
1816
+ raise WalletBackendError("vote_account was not found in current Solana vote accounts.")
1817
+
1818
+ stake_lamports = int(round(amount_native * solana_rpc.LAMPORTS_PER_SOL))
1819
+ if stake_lamports <= 0:
1820
+ raise WalletBackendError("amount is too small after converting to lamports.")
1821
+ rent_lamports = int(rent_exempt["lamports"])
1822
+ total_lamports = stake_lamports + rent_lamports
1823
+ available_lamports = int(round(balance["balance_native"] * solana_rpc.LAMPORTS_PER_SOL))
1824
+ total_with_fees = total_lamports + SOLANA_STAKE_CREATE_SIGNATURE_FEE_LAMPORTS
1825
+ if total_with_fees > available_lamports:
1826
+ raise WalletBackendError(
1827
+ "Insufficient SOL balance for native staking preview, including rent and estimated fees."
1828
+ )
1829
+
1830
+ return {
1831
+ "chain": "solana",
1832
+ "network": self.network,
1833
+ "mode": "preview",
1834
+ "asset_type": "native-stake",
1835
+ "owner": owner,
1836
+ "stake_account_address": None,
1837
+ "vote_account": vote_account,
1838
+ "validator": validator,
1839
+ "amount_native": amount_native,
1840
+ "stake_lamports": stake_lamports,
1841
+ "rent_exempt_lamports": rent_lamports,
1842
+ "rent_exempt_native": rent_lamports / solana_rpc.LAMPORTS_PER_SOL,
1843
+ "total_lamports": total_lamports,
1844
+ "estimated_fee_lamports": SOLANA_STAKE_CREATE_SIGNATURE_FEE_LAMPORTS,
1845
+ "estimated_fee_native": (
1846
+ SOLANA_STAKE_CREATE_SIGNATURE_FEE_LAMPORTS / solana_rpc.LAMPORTS_PER_SOL
1847
+ ),
1848
+ "balance_native_before": balance["balance_native"],
1849
+ "estimated_balance_native_after": (
1850
+ (available_lamports - total_with_fees) / solana_rpc.LAMPORTS_PER_SOL
1851
+ ),
1852
+ "latest_blockhash": latest_blockhash["blockhash"],
1853
+ "last_valid_block_height": latest_blockhash["last_valid_block_height"],
1854
+ "sign_only": self.sign_only,
1855
+ "can_send": self.get_capabilities().can_send_transaction,
1856
+ "source": "solana-rpc",
1857
+ }
1858
+
1859
+ async def prepare_native_stake(
1860
+ self,
1861
+ vote_account: str,
1862
+ amount_native: float,
1863
+ ) -> dict[str, Any]:
1864
+ if not self.signer:
1865
+ raise WalletBackendError("Solana signer is not configured.")
1866
+
1867
+ preview = await self.preview_native_stake(vote_account=vote_account, amount_native=amount_native)
1868
+ try:
1869
+ from solders.hash import Hash
1870
+ from solders.keypair import Keypair
1871
+ from solders.message import Message
1872
+ from solders.pubkey import Pubkey
1873
+ from solders.system_program import CreateAccountParams, create_account
1874
+ from solders.transaction import Transaction
1875
+ except ImportError as exc:
1876
+ raise WalletBackendError(
1877
+ "solana and solders packages are required for native staking."
1878
+ ) from exc
1879
+
1880
+ owner = str(preview["owner"])
1881
+ owner_pubkey = Pubkey.from_string(owner)
1882
+ vote_pubkey = Pubkey.from_string(str(preview["vote_account"]))
1883
+ wallet_keypair = Keypair.from_bytes(self.signer.export_keypair_bytes())
1884
+ stake_keypair = Keypair()
1885
+ stake_account_address = str(stake_keypair.pubkey())
1886
+ blockhash = Hash.from_string(str(preview["latest_blockhash"]))
1887
+
1888
+ instructions = [
1889
+ create_account(
1890
+ CreateAccountParams(
1891
+ from_pubkey=owner_pubkey,
1892
+ to_pubkey=stake_keypair.pubkey(),
1893
+ lamports=int(preview["total_lamports"]),
1894
+ space=STAKE_STATE_V2_SIZE,
1895
+ owner=Pubkey.from_string(STAKE_PROGRAM_ID),
1896
+ )
1897
+ ),
1898
+ build_initialize_checked_instruction(
1899
+ stake_account=stake_keypair.pubkey(),
1900
+ staker=owner_pubkey,
1901
+ withdrawer=owner_pubkey,
1902
+ ),
1903
+ build_delegate_stake_instruction(
1904
+ stake_account=stake_keypair.pubkey(),
1905
+ vote_account=vote_pubkey,
1906
+ authority=owner_pubkey,
1907
+ ),
1908
+ ]
1909
+ message = Message.new_with_blockhash(instructions, owner_pubkey, blockhash)
1910
+ transaction = Transaction([wallet_keypair, stake_keypair], message, blockhash)
1911
+
1912
+ return {
1913
+ "chain": "solana",
1914
+ "network": self.network,
1915
+ "mode": "prepare",
1916
+ "asset_type": "native-stake",
1917
+ "owner": owner,
1918
+ "stake_account_address": stake_account_address,
1919
+ "vote_account": str(preview["vote_account"]),
1920
+ "amount_native": amount_native,
1921
+ "stake_lamports": int(preview["stake_lamports"]),
1922
+ "rent_exempt_lamports": int(preview["rent_exempt_lamports"]),
1923
+ "total_lamports": int(preview["total_lamports"]),
1924
+ "estimated_fee_lamports": int(preview["estimated_fee_lamports"]),
1925
+ "validator": preview["validator"],
1926
+ "transaction_base64": encode_transaction_base64(bytes(transaction)),
1927
+ "transaction_encoding": "base64",
1928
+ "transaction_format": "legacy",
1929
+ "signed": True,
1930
+ "broadcasted": False,
1931
+ "confirmed": False,
1932
+ "latest_blockhash": str(preview["latest_blockhash"]),
1933
+ "last_valid_block_height": preview["last_valid_block_height"],
1934
+ "sign_only": self.sign_only,
1935
+ "source": "solana-rpc",
1936
+ }
1937
+
1938
+ async def execute_native_stake(
1939
+ self,
1940
+ vote_account: str,
1941
+ amount_native: float,
1942
+ ) -> dict[str, Any]:
1943
+ prepared = await self.prepare_native_stake(vote_account=vote_account, amount_native=amount_native)
1944
+ if self.sign_only:
1945
+ raise WalletBackendError(
1946
+ "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
1947
+ )
1948
+ submitted = await solana_rpc.send_transaction(
1949
+ transaction_base64=str(prepared["transaction_base64"]),
1950
+ rpc_url=self.rpc_urls,
1951
+ )
1952
+ signature = submitted.get("signature")
1953
+ status = None
1954
+ confirmed = False
1955
+ if isinstance(signature, str) and signature:
1956
+ status = await solana_rpc.wait_for_confirmation(
1957
+ signature=signature,
1958
+ rpc_url=self.rpc_urls,
1959
+ )
1960
+ confirmed = status is not None
1961
+ return {
1962
+ "chain": "solana",
1963
+ "network": self.network,
1964
+ "mode": "execute",
1965
+ "asset_type": "native-stake",
1966
+ "owner": prepared["owner"],
1967
+ "stake_account_address": prepared["stake_account_address"],
1968
+ "vote_account": prepared["vote_account"],
1969
+ "amount_native": prepared["amount_native"],
1970
+ "stake_lamports": prepared["stake_lamports"],
1971
+ "rent_exempt_lamports": prepared["rent_exempt_lamports"],
1972
+ "total_lamports": prepared["total_lamports"],
1973
+ "signature": signature,
1974
+ "broadcasted": bool(signature),
1975
+ "confirmed": confirmed,
1976
+ "confirmation_status": status.get("confirmationStatus") if status else None,
1977
+ "slot": status.get("slot") if status else None,
1978
+ "sign_only": self.sign_only,
1979
+ "source": "solana-rpc",
1980
+ }
1981
+
1982
+ async def preview_deactivate_stake(self, stake_account: str) -> dict[str, Any]:
1983
+ owner = await self.get_address()
1984
+ if not owner:
1985
+ raise WalletBackendError(
1986
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
1987
+ )
1988
+ snapshot, latest_blockhash = await asyncio.gather(
1989
+ self.get_stake_account(stake_account),
1990
+ solana_rpc.fetch_latest_blockhash(
1991
+ rpc_url=self.rpc_urls,
1992
+ commitment=self.commitment,
1993
+ ),
1994
+ )
1995
+ return {
1996
+ "chain": "solana",
1997
+ "network": self.network,
1998
+ "mode": "preview",
1999
+ "asset_type": "deactivate-stake",
2000
+ "authority": owner,
2001
+ "stake_account": snapshot["stake_account"],
2002
+ "activation": snapshot["activation"],
2003
+ "delegation": snapshot["delegation"],
2004
+ "latest_blockhash": latest_blockhash["blockhash"],
2005
+ "last_valid_block_height": latest_blockhash["last_valid_block_height"],
2006
+ "sign_only": self.sign_only,
2007
+ "can_send": self.get_capabilities().can_send_transaction,
2008
+ "source": "solana-rpc",
2009
+ }
2010
+
2011
+ async def prepare_deactivate_stake(self, stake_account: str) -> dict[str, Any]:
2012
+ if not self.signer:
2013
+ raise WalletBackendError("Solana signer is not configured.")
2014
+ preview = await self.preview_deactivate_stake(stake_account)
2015
+ try:
2016
+ from solders.hash import Hash
2017
+ from solders.keypair import Keypair
2018
+ from solders.message import Message
2019
+ from solders.pubkey import Pubkey
2020
+ from solders.transaction import Transaction
2021
+ except ImportError as exc:
2022
+ raise WalletBackendError(
2023
+ "solana and solders packages are required for native staking."
2024
+ ) from exc
2025
+ authority_pubkey = Pubkey.from_string(str(preview["authority"]))
2026
+ stake_pubkey = Pubkey.from_string(str(preview["stake_account"]))
2027
+ wallet_keypair = Keypair.from_bytes(self.signer.export_keypair_bytes())
2028
+ blockhash = Hash.from_string(str(preview["latest_blockhash"]))
2029
+ message = Message.new_with_blockhash(
2030
+ [build_deactivate_stake_instruction(stake_account=stake_pubkey, authority=authority_pubkey)],
2031
+ authority_pubkey,
2032
+ blockhash,
2033
+ )
2034
+ transaction = Transaction([wallet_keypair], message, blockhash)
2035
+ return {
2036
+ "chain": "solana",
2037
+ "network": self.network,
2038
+ "mode": "prepare",
2039
+ "asset_type": "deactivate-stake",
2040
+ "authority": str(preview["authority"]),
2041
+ "stake_account": str(preview["stake_account"]),
2042
+ "transaction_base64": encode_transaction_base64(bytes(transaction)),
2043
+ "transaction_encoding": "base64",
2044
+ "transaction_format": "legacy",
2045
+ "signed": True,
2046
+ "broadcasted": False,
2047
+ "confirmed": False,
2048
+ "latest_blockhash": str(preview["latest_blockhash"]),
2049
+ "last_valid_block_height": preview["last_valid_block_height"],
2050
+ "sign_only": self.sign_only,
2051
+ "source": "solana-rpc",
2052
+ }
2053
+
2054
+ async def execute_deactivate_stake(self, stake_account: str) -> dict[str, Any]:
2055
+ prepared = await self.prepare_deactivate_stake(stake_account)
2056
+ if self.sign_only:
2057
+ raise WalletBackendError(
2058
+ "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
2059
+ )
2060
+ submitted = await solana_rpc.send_transaction(
2061
+ transaction_base64=str(prepared["transaction_base64"]),
2062
+ rpc_url=self.rpc_urls,
2063
+ )
2064
+ signature = submitted.get("signature")
2065
+ status = None
2066
+ confirmed = False
2067
+ if isinstance(signature, str) and signature:
2068
+ status = await solana_rpc.wait_for_confirmation(
2069
+ signature=signature,
2070
+ rpc_url=self.rpc_urls,
2071
+ )
2072
+ confirmed = status is not None
2073
+ return {
2074
+ "chain": "solana",
2075
+ "network": self.network,
2076
+ "mode": "execute",
2077
+ "asset_type": "deactivate-stake",
2078
+ "authority": prepared["authority"],
2079
+ "stake_account": prepared["stake_account"],
2080
+ "signature": signature,
2081
+ "broadcasted": bool(signature),
2082
+ "confirmed": confirmed,
2083
+ "confirmation_status": status.get("confirmationStatus") if status else None,
2084
+ "slot": status.get("slot") if status else None,
2085
+ "sign_only": self.sign_only,
2086
+ "source": "solana-rpc",
2087
+ }
2088
+
2089
+ async def preview_withdraw_stake(
2090
+ self,
2091
+ stake_account: str,
2092
+ amount_native: float,
2093
+ recipient: str | None = None,
2094
+ ) -> dict[str, Any]:
2095
+ owner = await self.get_address()
2096
+ if not owner:
2097
+ raise WalletBackendError(
2098
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
2099
+ )
2100
+ if amount_native <= 0:
2101
+ raise WalletBackendError("amount must be greater than zero.")
2102
+ recipient_address = validate_solana_address(recipient or owner)
2103
+ snapshot, latest_blockhash = await asyncio.gather(
2104
+ self.get_stake_account(stake_account),
2105
+ solana_rpc.fetch_latest_blockhash(
2106
+ rpc_url=self.rpc_urls,
2107
+ commitment=self.commitment,
2108
+ ),
2109
+ )
2110
+ lamports = int(round(amount_native * solana_rpc.LAMPORTS_PER_SOL))
2111
+ if lamports > int(snapshot["estimated_withdrawable_lamports"]):
2112
+ raise WalletBackendError(
2113
+ "Requested withdraw amount exceeds the estimated withdrawable lamports for this stake account."
2114
+ )
2115
+ return {
2116
+ "chain": "solana",
2117
+ "network": self.network,
2118
+ "mode": "preview",
2119
+ "asset_type": "withdraw-stake",
2120
+ "authority": owner,
2121
+ "stake_account": snapshot["stake_account"],
2122
+ "recipient": recipient_address,
2123
+ "amount_native": amount_native,
2124
+ "amount_lamports": lamports,
2125
+ "activation": snapshot["activation"],
2126
+ "estimated_withdrawable_lamports": snapshot["estimated_withdrawable_lamports"],
2127
+ "latest_blockhash": latest_blockhash["blockhash"],
2128
+ "last_valid_block_height": latest_blockhash["last_valid_block_height"],
2129
+ "sign_only": self.sign_only,
2130
+ "can_send": self.get_capabilities().can_send_transaction,
2131
+ "source": "solana-rpc",
2132
+ }
2133
+
2134
+ async def prepare_withdraw_stake(
2135
+ self,
2136
+ stake_account: str,
2137
+ amount_native: float,
2138
+ recipient: str | None = None,
2139
+ ) -> dict[str, Any]:
2140
+ if not self.signer:
2141
+ raise WalletBackendError("Solana signer is not configured.")
2142
+ preview = await self.preview_withdraw_stake(
2143
+ stake_account=stake_account,
2144
+ amount_native=amount_native,
2145
+ recipient=recipient,
2146
+ )
2147
+ try:
2148
+ from solders.hash import Hash
2149
+ from solders.keypair import Keypair
2150
+ from solders.message import Message
2151
+ from solders.pubkey import Pubkey
2152
+ from solders.transaction import Transaction
2153
+ except ImportError as exc:
2154
+ raise WalletBackendError(
2155
+ "solana and solders packages are required for native staking."
2156
+ ) from exc
2157
+ authority_pubkey = Pubkey.from_string(str(preview["authority"]))
2158
+ stake_pubkey = Pubkey.from_string(str(preview["stake_account"]))
2159
+ recipient_pubkey = Pubkey.from_string(str(preview["recipient"]))
2160
+ wallet_keypair = Keypair.from_bytes(self.signer.export_keypair_bytes())
2161
+ blockhash = Hash.from_string(str(preview["latest_blockhash"]))
2162
+ message = Message.new_with_blockhash(
2163
+ [
2164
+ build_withdraw_stake_instruction(
2165
+ stake_account=stake_pubkey,
2166
+ recipient=recipient_pubkey,
2167
+ authority=authority_pubkey,
2168
+ lamports=int(preview["amount_lamports"]),
2169
+ )
2170
+ ],
2171
+ authority_pubkey,
2172
+ blockhash,
2173
+ )
2174
+ transaction = Transaction([wallet_keypair], message, blockhash)
2175
+ return {
2176
+ "chain": "solana",
2177
+ "network": self.network,
2178
+ "mode": "prepare",
2179
+ "asset_type": "withdraw-stake",
2180
+ "authority": str(preview["authority"]),
2181
+ "stake_account": str(preview["stake_account"]),
2182
+ "recipient": str(preview["recipient"]),
2183
+ "amount_native": amount_native,
2184
+ "amount_lamports": int(preview["amount_lamports"]),
2185
+ "transaction_base64": encode_transaction_base64(bytes(transaction)),
2186
+ "transaction_encoding": "base64",
2187
+ "transaction_format": "legacy",
2188
+ "signed": True,
2189
+ "broadcasted": False,
2190
+ "confirmed": False,
2191
+ "latest_blockhash": str(preview["latest_blockhash"]),
2192
+ "last_valid_block_height": preview["last_valid_block_height"],
2193
+ "sign_only": self.sign_only,
2194
+ "source": "solana-rpc",
2195
+ }
2196
+
2197
+ async def execute_withdraw_stake(
2198
+ self,
2199
+ stake_account: str,
2200
+ amount_native: float,
2201
+ recipient: str | None = None,
2202
+ ) -> dict[str, Any]:
2203
+ prepared = await self.prepare_withdraw_stake(
2204
+ stake_account=stake_account,
2205
+ amount_native=amount_native,
2206
+ recipient=recipient,
2207
+ )
2208
+ if self.sign_only:
2209
+ raise WalletBackendError(
2210
+ "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
2211
+ )
2212
+ submitted = await solana_rpc.send_transaction(
2213
+ transaction_base64=str(prepared["transaction_base64"]),
2214
+ rpc_url=self.rpc_urls,
2215
+ )
2216
+ signature = submitted.get("signature")
2217
+ status = None
2218
+ confirmed = False
2219
+ if isinstance(signature, str) and signature:
2220
+ status = await solana_rpc.wait_for_confirmation(
2221
+ signature=signature,
2222
+ rpc_url=self.rpc_urls,
2223
+ )
2224
+ confirmed = status is not None
2225
+ return {
2226
+ "chain": "solana",
2227
+ "network": self.network,
2228
+ "mode": "execute",
2229
+ "asset_type": "withdraw-stake",
2230
+ "authority": prepared["authority"],
2231
+ "stake_account": prepared["stake_account"],
2232
+ "recipient": prepared["recipient"],
2233
+ "amount_native": prepared["amount_native"],
2234
+ "amount_lamports": prepared["amount_lamports"],
2235
+ "signature": signature,
2236
+ "broadcasted": bool(signature),
2237
+ "confirmed": confirmed,
2238
+ "confirmation_status": status.get("confirmationStatus") if status else None,
2239
+ "slot": status.get("slot") if status else None,
2240
+ "sign_only": self.sign_only,
2241
+ "source": "solana-rpc",
2242
+ }
2243
+
2244
+ async def _sign_versioned_provider_transaction(
2245
+ self,
2246
+ *,
2247
+ transaction_base64: str,
2248
+ wallet_signer_index: int,
2249
+ ) -> str:
2250
+ try:
2251
+ from solders.keypair import Keypair
2252
+ from solders.message import to_bytes_versioned
2253
+ from solders.transaction import VersionedTransaction
2254
+ except ImportError as exc:
2255
+ raise WalletBackendError(
2256
+ "solana and solders packages are required for provider transaction signing."
2257
+ ) from exc
2258
+
2259
+ unsigned_transaction = VersionedTransaction.from_bytes(
2260
+ self._bags_decode_serialized_transaction_bytes(transaction_base64)
2261
+ )
2262
+ keypair = Keypair.from_bytes(self.signer.export_keypair_bytes())
2263
+ signature = keypair.sign_message(to_bytes_versioned(unsigned_transaction.message))
2264
+ signatures = list(unsigned_transaction.signatures)
2265
+ if wallet_signer_index >= len(signatures):
2266
+ raise WalletBackendError(
2267
+ "Provider transaction signer layout is incompatible with local signing."
2268
+ )
2269
+ signatures[wallet_signer_index] = signature
2270
+ signed_transaction = VersionedTransaction.populate(
2271
+ unsigned_transaction.message,
2272
+ signatures,
2273
+ )
2274
+ return encode_transaction_base64(bytes(signed_transaction))
2275
+
2276
+ async def _prepare_jupiter_lend_transaction(
2277
+ self,
2278
+ *,
2279
+ transaction_base64: str,
2280
+ action: str,
2281
+ asset: str,
2282
+ amount_raw: str,
2283
+ ) -> dict[str, Any]:
2284
+ if not self.signer:
2285
+ raise WalletBackendError("Solana signer is not configured.")
2286
+ try:
2287
+ from solders.transaction import VersionedTransaction
2288
+ except ImportError as exc:
2289
+ raise WalletBackendError(
2290
+ "solana and solders packages are required for Jupiter Earn transaction signing."
2291
+ ) from exc
2292
+ unsigned_transaction = VersionedTransaction.from_bytes(base64.b64decode(transaction_base64))
2293
+ owner = await self.get_address()
2294
+ verification = verify_provider_lend_transaction(
2295
+ unsigned_transaction.message,
2296
+ wallet_address=str(owner),
2297
+ asset_mint=asset,
2298
+ action=f"Jupiter Earn {action}",
2299
+ )
2300
+ signed_transaction_base64 = await self._sign_versioned_provider_transaction(
2301
+ transaction_base64=transaction_base64,
2302
+ wallet_signer_index=int(verification.get("wallet_signer_index") or 0),
2303
+ )
2304
+ return {
2305
+ "chain": "solana",
2306
+ "network": self.network,
2307
+ "mode": "prepare",
2308
+ "asset_type": f"jupiter-earn-{action}",
2309
+ "owner": owner,
2310
+ "asset": asset,
2311
+ "amount_raw": amount_raw,
2312
+ "transaction_base64": signed_transaction_base64,
2313
+ "transaction_encoding": "base64",
2314
+ "transaction_format": "versioned",
2315
+ "signed": True,
2316
+ "broadcasted": False,
2317
+ "confirmed": False,
2318
+ "verification": verification,
2319
+ "sign_only": self.sign_only,
2320
+ "source": "jupiter-lend",
2321
+ }
2322
+
2323
+ async def _execute_prepared_provider_transaction(
2324
+ self,
2325
+ prepared: dict[str, Any],
2326
+ *,
2327
+ source: str,
2328
+ ) -> dict[str, Any]:
2329
+ if self.sign_only:
2330
+ raise WalletBackendError(
2331
+ "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
2332
+ )
2333
+ submitted = await solana_rpc.send_transaction(
2334
+ transaction_base64=str(prepared["transaction_base64"]),
2335
+ rpc_url=self.rpc_urls,
2336
+ )
2337
+ signature = submitted.get("signature")
2338
+ status = None
2339
+ confirmed = False
2340
+ if isinstance(signature, str) and signature:
2341
+ status = await solana_rpc.wait_for_confirmation(
2342
+ signature=signature,
2343
+ rpc_url=self.rpc_urls,
2344
+ )
2345
+ confirmed = status is not None
2346
+ return {
2347
+ "chain": "solana",
2348
+ "network": self.network,
2349
+ "mode": "execute",
2350
+ "asset_type": prepared["asset_type"],
2351
+ "owner": prepared.get("owner"),
2352
+ "asset": prepared.get("asset"),
2353
+ "amount_raw": prepared.get("amount_raw"),
2354
+ "signature": signature,
2355
+ "broadcasted": bool(signature),
2356
+ "confirmed": confirmed,
2357
+ "confirmation_status": status.get("confirmationStatus") if status else None,
2358
+ "slot": status.get("slot") if status else None,
2359
+ "sign_only": self.sign_only,
2360
+ "source": source,
2361
+ }
2362
+
2363
+ async def _execute_prepared_jupiter_lend_transaction(self, prepared: dict[str, Any]) -> dict[str, Any]:
2364
+ return await self._execute_prepared_provider_transaction(
2365
+ prepared,
2366
+ source="jupiter-lend",
2367
+ )
2368
+
2369
+ def _find_kamino_reserve_entry(
2370
+ self,
2371
+ *,
2372
+ reserves: list[Any],
2373
+ reserve: str,
2374
+ ) -> dict[str, Any] | None:
2375
+ for item in reserves:
2376
+ if _kamino_entry_address(item, "reserve", "address", "pubkey") == reserve:
2377
+ return item
2378
+ return None
2379
+
2380
+ def _find_kamino_obligation_matches(
2381
+ self,
2382
+ *,
2383
+ obligations: list[Any],
2384
+ reserve: str,
2385
+ ) -> list[dict[str, Any]]:
2386
+ matches: list[dict[str, Any]] = []
2387
+ for item in obligations:
2388
+ if not isinstance(item, dict):
2389
+ continue
2390
+ state = item.get("state")
2391
+ if not isinstance(state, dict):
2392
+ continue
2393
+ deposits = state.get("deposits")
2394
+ borrows = state.get("borrows")
2395
+ deposit_match = any(
2396
+ isinstance(entry, dict)
2397
+ and str(entry.get("depositReserve") or "").strip() == reserve
2398
+ and str(entry.get("depositedAmount") or "0").strip() not in {"", "0"}
2399
+ for entry in (deposits or [])
2400
+ )
2401
+ borrow_match = any(
2402
+ isinstance(entry, dict)
2403
+ and str(entry.get("borrowReserve") or "").strip() == reserve
2404
+ and str(entry.get("borrowedAmountSf") or "0").strip() not in {"", "0"}
2405
+ for entry in (borrows or [])
2406
+ )
2407
+ if deposit_match or borrow_match:
2408
+ matches.append(item)
2409
+ return matches
2410
+
2411
+ async def _prepare_kamino_lend_transaction(
2412
+ self,
2413
+ *,
2414
+ transaction_base64: str,
2415
+ action: str,
2416
+ market: str,
2417
+ reserve: str,
2418
+ amount_ui: str,
2419
+ ) -> dict[str, Any]:
2420
+ if not self.signer:
2421
+ raise WalletBackendError("Solana signer is not configured.")
2422
+ try:
2423
+ from solders.transaction import VersionedTransaction
2424
+ except ImportError as exc:
2425
+ raise WalletBackendError(
2426
+ "solana and solders packages are required for Kamino transaction signing."
2427
+ ) from exc
2428
+ owner = await self.get_address()
2429
+ unsigned_transaction = VersionedTransaction.from_bytes(base64.b64decode(transaction_base64))
2430
+ loaded_addresses = await self._resolve_versioned_message_lookup_addresses(
2431
+ unsigned_transaction.message
2432
+ )
2433
+ verification = verify_provider_kamino_lend_transaction(
2434
+ unsigned_transaction.message,
2435
+ wallet_address=str(owner),
2436
+ market_address=market,
2437
+ reserve_address=reserve,
2438
+ action=f"Kamino {action}",
2439
+ loaded_addresses=loaded_addresses,
2440
+ )
2441
+ signed_transaction_base64 = await self._sign_versioned_provider_transaction(
2442
+ transaction_base64=transaction_base64,
2443
+ wallet_signer_index=int(verification.get("wallet_signer_index") or 0),
2444
+ )
2445
+ return {
2446
+ "chain": "solana",
2447
+ "network": self.network,
2448
+ "mode": "prepare",
2449
+ "asset_type": f"kamino-lend-{action}",
2450
+ "owner": owner,
2451
+ "market": market,
2452
+ "reserve": reserve,
2453
+ "amount_ui": amount_ui,
2454
+ "transaction_base64": signed_transaction_base64,
2455
+ "transaction_encoding": "base64",
2456
+ "transaction_format": "versioned",
2457
+ "signed": True,
2458
+ "broadcasted": False,
2459
+ "confirmed": False,
2460
+ "verification": verification,
2461
+ "sign_only": self.sign_only,
2462
+ "source": "kamino",
2463
+ }
2464
+
2465
+ async def preview_kamino_lend_deposit(
2466
+ self,
2467
+ market: str,
2468
+ reserve: str,
2469
+ amount_ui: str,
2470
+ ) -> dict[str, Any]:
2471
+ self._require_mainnet_kamino("Kamino lending")
2472
+ owner = await self.get_address()
2473
+ if not owner:
2474
+ raise WalletBackendError(
2475
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
2476
+ )
2477
+ market = validate_solana_address(market)
2478
+ reserve = validate_solana_address(reserve)
2479
+ amount_ui = _require_positive_decimal_string(amount_ui, field_name="amount_ui")
2480
+ reserve_snapshot = await self.get_kamino_lend_market_reserves(market)
2481
+ reserve_entry = self._find_kamino_reserve_entry(
2482
+ reserves=list(reserve_snapshot["reserves"]),
2483
+ reserve=reserve,
2484
+ )
2485
+ if reserve_entry is None:
2486
+ raise WalletBackendError("Requested reserve is not available in the selected Kamino market.")
2487
+ return {
2488
+ "chain": "solana",
2489
+ "network": self.network,
2490
+ "mode": "preview",
2491
+ "asset_type": "kamino-lend-deposit",
2492
+ "owner": owner,
2493
+ "market": market,
2494
+ "reserve": reserve,
2495
+ "amount_ui": amount_ui,
2496
+ "reserve_info": reserve_entry,
2497
+ "sign_only": self.sign_only,
2498
+ "can_send": self.get_capabilities().can_send_transaction,
2499
+ "source": "kamino",
2500
+ }
2501
+
2502
+ async def prepare_kamino_lend_deposit(
2503
+ self,
2504
+ market: str,
2505
+ reserve: str,
2506
+ amount_ui: str,
2507
+ ) -> dict[str, Any]:
2508
+ preview = await self.preview_kamino_lend_deposit(
2509
+ market=market,
2510
+ reserve=reserve,
2511
+ amount_ui=amount_ui,
2512
+ )
2513
+ owner = str(preview["owner"])
2514
+ build = await kamino.build_lend_deposit_transaction(
2515
+ wallet=owner,
2516
+ market=str(preview["market"]),
2517
+ reserve=str(preview["reserve"]),
2518
+ amount_ui=str(preview["amount_ui"]),
2519
+ )
2520
+ prepared = await self._prepare_kamino_lend_transaction(
2521
+ transaction_base64=str(build["transaction"]),
2522
+ action="deposit",
2523
+ market=str(preview["market"]),
2524
+ reserve=str(preview["reserve"]),
2525
+ amount_ui=str(preview["amount_ui"]),
2526
+ )
2527
+ prepared["build_response"] = build
2528
+ return prepared
2529
+
2530
+ async def execute_kamino_lend_deposit(
2531
+ self,
2532
+ market: str,
2533
+ reserve: str,
2534
+ amount_ui: str,
2535
+ ) -> dict[str, Any]:
2536
+ prepared = await self.prepare_kamino_lend_deposit(
2537
+ market=market,
2538
+ reserve=reserve,
2539
+ amount_ui=amount_ui,
2540
+ )
2541
+ result = await self._execute_prepared_provider_transaction(prepared, source="kamino")
2542
+ result["build_response"] = prepared.get("build_response")
2543
+ return result
2544
+
2545
+ async def preview_kamino_lend_withdraw(
2546
+ self,
2547
+ market: str,
2548
+ reserve: str,
2549
+ amount_ui: str,
2550
+ ) -> dict[str, Any]:
2551
+ self._require_mainnet_kamino("Kamino lending")
2552
+ owner = await self.get_address()
2553
+ if not owner:
2554
+ raise WalletBackendError(
2555
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
2556
+ )
2557
+ market = validate_solana_address(market)
2558
+ reserve = validate_solana_address(reserve)
2559
+ amount_ui = _require_positive_decimal_string(amount_ui, field_name="amount_ui")
2560
+ reserve_snapshot = await self.get_kamino_lend_market_reserves(market)
2561
+ reserve_entry = self._find_kamino_reserve_entry(
2562
+ reserves=list(reserve_snapshot["reserves"]),
2563
+ reserve=reserve,
2564
+ )
2565
+ if reserve_entry is None:
2566
+ raise WalletBackendError("Requested reserve is not available in the selected Kamino market.")
2567
+ obligations = await self.get_kamino_lend_user_obligations(market=market, user=owner)
2568
+ obligation_matches = self._find_kamino_obligation_matches(
2569
+ obligations=list(obligations["obligations"]),
2570
+ reserve=reserve,
2571
+ )
2572
+ if not obligation_matches:
2573
+ raise WalletBackendError("No Kamino obligation found for the requested reserve.")
2574
+ return {
2575
+ "chain": "solana",
2576
+ "network": self.network,
2577
+ "mode": "preview",
2578
+ "asset_type": "kamino-lend-withdraw",
2579
+ "owner": owner,
2580
+ "market": market,
2581
+ "reserve": reserve,
2582
+ "amount_ui": amount_ui,
2583
+ "reserve_info": reserve_entry,
2584
+ "obligations": obligation_matches,
2585
+ "sign_only": self.sign_only,
2586
+ "can_send": self.get_capabilities().can_send_transaction,
2587
+ "source": "kamino",
2588
+ }
2589
+
2590
+ async def prepare_kamino_lend_withdraw(
2591
+ self,
2592
+ market: str,
2593
+ reserve: str,
2594
+ amount_ui: str,
2595
+ ) -> dict[str, Any]:
2596
+ preview = await self.preview_kamino_lend_withdraw(
2597
+ market=market,
2598
+ reserve=reserve,
2599
+ amount_ui=amount_ui,
2600
+ )
2601
+ owner = str(preview["owner"])
2602
+ build = await kamino.build_lend_withdraw_transaction(
2603
+ wallet=owner,
2604
+ market=str(preview["market"]),
2605
+ reserve=str(preview["reserve"]),
2606
+ amount_ui=str(preview["amount_ui"]),
2607
+ )
2608
+ prepared = await self._prepare_kamino_lend_transaction(
2609
+ transaction_base64=str(build["transaction"]),
2610
+ action="withdraw",
2611
+ market=str(preview["market"]),
2612
+ reserve=str(preview["reserve"]),
2613
+ amount_ui=str(preview["amount_ui"]),
2614
+ )
2615
+ prepared["build_response"] = build
2616
+ return prepared
2617
+
2618
+ async def execute_kamino_lend_withdraw(
2619
+ self,
2620
+ market: str,
2621
+ reserve: str,
2622
+ amount_ui: str,
2623
+ ) -> dict[str, Any]:
2624
+ prepared = await self.prepare_kamino_lend_withdraw(
2625
+ market=market,
2626
+ reserve=reserve,
2627
+ amount_ui=amount_ui,
2628
+ )
2629
+ result = await self._execute_prepared_provider_transaction(prepared, source="kamino")
2630
+ result["build_response"] = prepared.get("build_response")
2631
+ return result
2632
+
2633
+ async def preview_kamino_lend_borrow(
2634
+ self,
2635
+ market: str,
2636
+ reserve: str,
2637
+ amount_ui: str,
2638
+ ) -> dict[str, Any]:
2639
+ self._require_mainnet_kamino("Kamino lending")
2640
+ owner = await self.get_address()
2641
+ if not owner:
2642
+ raise WalletBackendError(
2643
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
2644
+ )
2645
+ market = validate_solana_address(market)
2646
+ reserve = validate_solana_address(reserve)
2647
+ amount_ui = _require_positive_decimal_string(amount_ui, field_name="amount_ui")
2648
+ reserve_snapshot = await self.get_kamino_lend_market_reserves(market)
2649
+ reserve_entry = self._find_kamino_reserve_entry(
2650
+ reserves=list(reserve_snapshot["reserves"]),
2651
+ reserve=reserve,
2652
+ )
2653
+ if reserve_entry is None:
2654
+ raise WalletBackendError("Requested reserve is not available in the selected Kamino market.")
2655
+ obligations = await self.get_kamino_lend_user_obligations(market=market, user=owner)
2656
+ if int(obligations["obligation_count"]) <= 0:
2657
+ raise WalletBackendError("Kamino borrow requires an existing obligation in the selected market.")
2658
+ return {
2659
+ "chain": "solana",
2660
+ "network": self.network,
2661
+ "mode": "preview",
2662
+ "asset_type": "kamino-lend-borrow",
2663
+ "owner": owner,
2664
+ "market": market,
2665
+ "reserve": reserve,
2666
+ "amount_ui": amount_ui,
2667
+ "reserve_info": reserve_entry,
2668
+ "obligations": obligations["obligations"],
2669
+ "sign_only": self.sign_only,
2670
+ "can_send": self.get_capabilities().can_send_transaction,
2671
+ "source": "kamino",
2672
+ }
2673
+
2674
+ async def prepare_kamino_lend_borrow(
2675
+ self,
2676
+ market: str,
2677
+ reserve: str,
2678
+ amount_ui: str,
2679
+ ) -> dict[str, Any]:
2680
+ preview = await self.preview_kamino_lend_borrow(
2681
+ market=market,
2682
+ reserve=reserve,
2683
+ amount_ui=amount_ui,
2684
+ )
2685
+ owner = str(preview["owner"])
2686
+ build = await kamino.build_lend_borrow_transaction(
2687
+ wallet=owner,
2688
+ market=str(preview["market"]),
2689
+ reserve=str(preview["reserve"]),
2690
+ amount_ui=str(preview["amount_ui"]),
2691
+ )
2692
+ prepared = await self._prepare_kamino_lend_transaction(
2693
+ transaction_base64=str(build["transaction"]),
2694
+ action="borrow",
2695
+ market=str(preview["market"]),
2696
+ reserve=str(preview["reserve"]),
2697
+ amount_ui=str(preview["amount_ui"]),
2698
+ )
2699
+ prepared["build_response"] = build
2700
+ return prepared
2701
+
2702
+ async def execute_kamino_lend_borrow(
2703
+ self,
2704
+ market: str,
2705
+ reserve: str,
2706
+ amount_ui: str,
2707
+ ) -> dict[str, Any]:
2708
+ prepared = await self.prepare_kamino_lend_borrow(
2709
+ market=market,
2710
+ reserve=reserve,
2711
+ amount_ui=amount_ui,
2712
+ )
2713
+ result = await self._execute_prepared_provider_transaction(prepared, source="kamino")
2714
+ result["build_response"] = prepared.get("build_response")
2715
+ return result
2716
+
2717
+ async def preview_kamino_lend_repay(
2718
+ self,
2719
+ market: str,
2720
+ reserve: str,
2721
+ amount_ui: str,
2722
+ ) -> dict[str, Any]:
2723
+ self._require_mainnet_kamino("Kamino lending")
2724
+ owner = await self.get_address()
2725
+ if not owner:
2726
+ raise WalletBackendError(
2727
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
2728
+ )
2729
+ market = validate_solana_address(market)
2730
+ reserve = validate_solana_address(reserve)
2731
+ amount_ui = _require_positive_decimal_string(amount_ui, field_name="amount_ui")
2732
+ reserve_snapshot = await self.get_kamino_lend_market_reserves(market)
2733
+ reserve_entry = self._find_kamino_reserve_entry(
2734
+ reserves=list(reserve_snapshot["reserves"]),
2735
+ reserve=reserve,
2736
+ )
2737
+ if reserve_entry is None:
2738
+ raise WalletBackendError("Requested reserve is not available in the selected Kamino market.")
2739
+ obligations = await self.get_kamino_lend_user_obligations(market=market, user=owner)
2740
+ obligation_matches = self._find_kamino_obligation_matches(
2741
+ obligations=list(obligations["obligations"]),
2742
+ reserve=reserve,
2743
+ )
2744
+ if not obligation_matches:
2745
+ raise WalletBackendError("No Kamino debt position found for the requested reserve.")
2746
+ return {
2747
+ "chain": "solana",
2748
+ "network": self.network,
2749
+ "mode": "preview",
2750
+ "asset_type": "kamino-lend-repay",
2751
+ "owner": owner,
2752
+ "market": market,
2753
+ "reserve": reserve,
2754
+ "amount_ui": amount_ui,
2755
+ "reserve_info": reserve_entry,
2756
+ "obligations": obligation_matches,
2757
+ "sign_only": self.sign_only,
2758
+ "can_send": self.get_capabilities().can_send_transaction,
2759
+ "source": "kamino",
2760
+ }
2761
+
2762
+ async def prepare_kamino_lend_repay(
2763
+ self,
2764
+ market: str,
2765
+ reserve: str,
2766
+ amount_ui: str,
2767
+ ) -> dict[str, Any]:
2768
+ preview = await self.preview_kamino_lend_repay(
2769
+ market=market,
2770
+ reserve=reserve,
2771
+ amount_ui=amount_ui,
2772
+ )
2773
+ owner = str(preview["owner"])
2774
+ build = await kamino.build_lend_repay_transaction(
2775
+ wallet=owner,
2776
+ market=str(preview["market"]),
2777
+ reserve=str(preview["reserve"]),
2778
+ amount_ui=str(preview["amount_ui"]),
2779
+ )
2780
+ prepared = await self._prepare_kamino_lend_transaction(
2781
+ transaction_base64=str(build["transaction"]),
2782
+ action="repay",
2783
+ market=str(preview["market"]),
2784
+ reserve=str(preview["reserve"]),
2785
+ amount_ui=str(preview["amount_ui"]),
2786
+ )
2787
+ prepared["build_response"] = build
2788
+ return prepared
2789
+
2790
+ async def execute_kamino_lend_repay(
2791
+ self,
2792
+ market: str,
2793
+ reserve: str,
2794
+ amount_ui: str,
2795
+ ) -> dict[str, Any]:
2796
+ prepared = await self.prepare_kamino_lend_repay(
2797
+ market=market,
2798
+ reserve=reserve,
2799
+ amount_ui=amount_ui,
2800
+ )
2801
+ result = await self._execute_prepared_provider_transaction(prepared, source="kamino")
2802
+ result["build_response"] = prepared.get("build_response")
2803
+ return result
2804
+
2805
+ async def preview_jupiter_earn_deposit(
2806
+ self,
2807
+ asset: str,
2808
+ amount_raw: str,
2809
+ ) -> dict[str, Any]:
2810
+ self._require_mainnet_jupiter("Jupiter Earn")
2811
+ owner = await self.get_address()
2812
+ if not owner:
2813
+ raise WalletBackendError(
2814
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
2815
+ )
2816
+ amount_raw = _require_positive_integer_string(amount_raw, field_name="amount_raw")
2817
+ asset = validate_solana_mint(asset)
2818
+ tokens = await self.get_jupiter_earn_tokens()
2819
+ token_entry = next(
2820
+ (
2821
+ item
2822
+ for item in tokens["tokens"]
2823
+ if isinstance(item, dict)
2824
+ and str(item.get("asset") or item.get("mint") or "").strip() == asset
2825
+ ),
2826
+ None,
2827
+ )
2828
+ if token_entry is None:
2829
+ raise WalletBackendError("Requested asset is not currently available in Jupiter Earn.")
2830
+ return {
2831
+ "chain": "solana",
2832
+ "network": self.network,
2833
+ "mode": "preview",
2834
+ "asset_type": "jupiter-earn-deposit",
2835
+ "owner": owner,
2836
+ "asset": asset,
2837
+ "amount_raw": amount_raw,
2838
+ "token": token_entry,
2839
+ "sign_only": self.sign_only,
2840
+ "can_send": self.get_capabilities().can_send_transaction,
2841
+ "source": "jupiter-lend",
2842
+ }
2843
+
2844
+ async def prepare_jupiter_earn_deposit(
2845
+ self,
2846
+ asset: str,
2847
+ amount_raw: str,
2848
+ ) -> dict[str, Any]:
2849
+ preview = await self.preview_jupiter_earn_deposit(asset=asset, amount_raw=amount_raw)
2850
+ owner = str(preview["owner"])
2851
+ build = await jupiter.build_earn_deposit_transaction(
2852
+ asset=str(preview["asset"]),
2853
+ user_address=owner,
2854
+ amount_raw=str(preview["amount_raw"]),
2855
+ )
2856
+ prepared = await self._prepare_jupiter_lend_transaction(
2857
+ transaction_base64=str(build["transaction"]),
2858
+ action="deposit",
2859
+ asset=str(preview["asset"]),
2860
+ amount_raw=str(preview["amount_raw"]),
2861
+ )
2862
+ prepared["build_response"] = build
2863
+ return prepared
2864
+
2865
+ async def execute_jupiter_earn_deposit(
2866
+ self,
2867
+ asset: str,
2868
+ amount_raw: str,
2869
+ ) -> dict[str, Any]:
2870
+ prepared = await self.prepare_jupiter_earn_deposit(asset=asset, amount_raw=amount_raw)
2871
+ result = await self._execute_prepared_jupiter_lend_transaction(prepared)
2872
+ result["build_response"] = prepared.get("build_response")
2873
+ return result
2874
+
2875
+ async def preview_jupiter_earn_withdraw(
2876
+ self,
2877
+ asset: str,
2878
+ amount_raw: str,
2879
+ ) -> dict[str, Any]:
2880
+ self._require_mainnet_jupiter("Jupiter Earn")
2881
+ owner = await self.get_address()
2882
+ if not owner:
2883
+ raise WalletBackendError(
2884
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
2885
+ )
2886
+ amount_raw = _require_positive_integer_string(amount_raw, field_name="amount_raw")
2887
+ asset = validate_solana_mint(asset)
2888
+ positions = await self.get_jupiter_earn_positions(users=[owner])
2889
+ matching_positions = [
2890
+ item
2891
+ for item in positions["positions"]
2892
+ if isinstance(item, dict)
2893
+ and str(item.get("asset") or item.get("mint") or "").strip() == asset
2894
+ ]
2895
+ if not matching_positions:
2896
+ raise WalletBackendError("No Jupiter Earn position found for the requested asset.")
2897
+ return {
2898
+ "chain": "solana",
2899
+ "network": self.network,
2900
+ "mode": "preview",
2901
+ "asset_type": "jupiter-earn-withdraw",
2902
+ "owner": owner,
2903
+ "asset": asset,
2904
+ "amount_raw": amount_raw,
2905
+ "positions": matching_positions,
2906
+ "sign_only": self.sign_only,
2907
+ "can_send": self.get_capabilities().can_send_transaction,
2908
+ "source": "jupiter-lend",
2909
+ }
2910
+
2911
+ async def prepare_jupiter_earn_withdraw(
2912
+ self,
2913
+ asset: str,
2914
+ amount_raw: str,
2915
+ ) -> dict[str, Any]:
2916
+ preview = await self.preview_jupiter_earn_withdraw(asset=asset, amount_raw=amount_raw)
2917
+ owner = str(preview["owner"])
2918
+ build = await jupiter.build_earn_withdraw_transaction(
2919
+ asset=str(preview["asset"]),
2920
+ user_address=owner,
2921
+ amount_raw=str(preview["amount_raw"]),
2922
+ )
2923
+ prepared = await self._prepare_jupiter_lend_transaction(
2924
+ transaction_base64=str(build["transaction"]),
2925
+ action="withdraw",
2926
+ asset=str(preview["asset"]),
2927
+ amount_raw=str(preview["amount_raw"]),
2928
+ )
2929
+ prepared["build_response"] = build
2930
+ return prepared
2931
+
2932
+ async def execute_jupiter_earn_withdraw(
2933
+ self,
2934
+ asset: str,
2935
+ amount_raw: str,
2936
+ ) -> dict[str, Any]:
2937
+ prepared = await self.prepare_jupiter_earn_withdraw(asset=asset, amount_raw=amount_raw)
2938
+ result = await self._execute_prepared_jupiter_lend_transaction(prepared)
2939
+ result["build_response"] = prepared.get("build_response")
2940
+ return result
2941
+
2942
+ async def preview_native_transfer(
2943
+ self,
2944
+ recipient: str,
2945
+ amount_native: float,
2946
+ ) -> dict[str, Any]:
2947
+ sender = await self.get_address()
2948
+ if not sender:
2949
+ raise WalletBackendError(
2950
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
2951
+ )
2952
+ if amount_native <= 0:
2953
+ raise WalletBackendError("amount must be greater than zero.")
2954
+
2955
+ recipient = validate_solana_address(recipient)
2956
+ balance = await self._get_native_balance(sender)
2957
+ latest_blockhash = await solana_rpc.fetch_latest_blockhash(
2958
+ rpc_url=self.rpc_urls,
2959
+ commitment=self.commitment,
2960
+ )
2961
+
2962
+ amount_lamports = int(round(amount_native * solana_rpc.LAMPORTS_PER_SOL))
2963
+ estimated_fee_lamports = SOLANA_BASE_FEE_LAMPORTS
2964
+ total_lamports = amount_lamports + estimated_fee_lamports
2965
+ available_lamports = int(round(balance["balance_native"] * solana_rpc.LAMPORTS_PER_SOL))
2966
+ if total_lamports > available_lamports:
2967
+ raise WalletBackendError(
2968
+ "Insufficient SOL balance for this transfer preview, including estimated fees."
2969
+ )
2970
+
2971
+ return {
2972
+ "chain": "solana",
2973
+ "network": self.network,
2974
+ "mode": "preview",
2975
+ "from_address": sender,
2976
+ "to_address": recipient,
2977
+ "amount_native": amount_native,
2978
+ "amount_lamports": amount_lamports,
2979
+ "estimated_fee_native": estimated_fee_lamports / solana_rpc.LAMPORTS_PER_SOL,
2980
+ "estimated_fee_lamports": estimated_fee_lamports,
2981
+ "balance_native_before": balance["balance_native"],
2982
+ "estimated_balance_native_after": (
2983
+ (available_lamports - total_lamports) / solana_rpc.LAMPORTS_PER_SOL
2984
+ ),
2985
+ "latest_blockhash": latest_blockhash["blockhash"],
2986
+ "last_valid_block_height": latest_blockhash["last_valid_block_height"],
2987
+ "sign_only": self.sign_only,
2988
+ "can_send": self.get_capabilities().can_send_transaction,
2989
+ "source": "solana-rpc",
2990
+ }
2991
+
2992
+ async def send_native_transfer(
2993
+ self,
2994
+ recipient: str,
2995
+ amount_native: float,
2996
+ ) -> dict[str, Any]:
2997
+ prepared = await self.prepare_native_transfer(
2998
+ recipient=recipient,
2999
+ amount_native=amount_native,
3000
+ )
3001
+ if self.sign_only:
3002
+ raise WalletBackendError(
3003
+ "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
3004
+ )
3005
+
3006
+ submitted = await solana_rpc.send_transaction(
3007
+ transaction_base64=str(prepared["transaction_base64"]),
3008
+ rpc_url=self.rpc_urls,
3009
+ )
3010
+ signature = submitted.get("signature")
3011
+ status = None
3012
+ confirmed = False
3013
+ if isinstance(signature, str) and signature:
3014
+ status = await solana_rpc.wait_for_confirmation(
3015
+ signature=signature,
3016
+ rpc_url=self.rpc_urls,
3017
+ )
3018
+ confirmed = status is not None
3019
+
3020
+ return {
3021
+ "chain": "solana",
3022
+ "network": self.network,
3023
+ "mode": "execute",
3024
+ "from_address": prepared["from_address"],
3025
+ "to_address": prepared["to_address"],
3026
+ "amount_native": prepared["amount_native"],
3027
+ "amount_lamports": prepared["amount_lamports"],
3028
+ "estimated_fee_native": prepared["estimated_fee_native"],
3029
+ "signature": signature,
3030
+ "broadcasted": bool(signature),
3031
+ "confirmed": confirmed,
3032
+ "confirmation_status": status.get("confirmationStatus") if status else None,
3033
+ "slot": status.get("slot") if status else None,
3034
+ "sign_only": self.sign_only,
3035
+ "source": "solana-rpc",
3036
+ }
3037
+
3038
+ async def prepare_native_transfer(
3039
+ self,
3040
+ recipient: str,
3041
+ amount_native: float,
3042
+ ) -> dict[str, Any]:
3043
+ if not self.signer:
3044
+ raise WalletBackendError("Solana signer is not configured.")
3045
+
3046
+ preview = await self.preview_native_transfer(
3047
+ recipient=recipient,
3048
+ amount_native=amount_native,
3049
+ )
3050
+ lamports = int(preview["amount_lamports"])
3051
+ blockhash = str(preview["latest_blockhash"])
3052
+ sender = str(preview["from_address"])
3053
+ recipient = str(preview["to_address"])
3054
+
3055
+ message = build_legacy_sol_transfer_message(
3056
+ sender=sender,
3057
+ recipient=recipient,
3058
+ recent_blockhash=blockhash,
3059
+ lamports=lamports,
3060
+ )
3061
+ signature_bytes = self.signer.sign_bytes(message)
3062
+ transaction_bytes = serialize_legacy_transaction(signature_bytes, message)
3063
+ transaction_base64 = encode_transaction_base64(transaction_bytes)
3064
+
3065
+ return {
3066
+ "chain": "solana",
3067
+ "network": self.network,
3068
+ "mode": "prepare",
3069
+ "from_address": sender,
3070
+ "to_address": recipient,
3071
+ "amount_native": amount_native,
3072
+ "amount_lamports": lamports,
3073
+ "estimated_fee_native": preview["estimated_fee_native"],
3074
+ "transaction_base64": transaction_base64,
3075
+ "transaction_encoding": "base64",
3076
+ "transaction_format": "legacy",
3077
+ "signed": True,
3078
+ "broadcasted": False,
3079
+ "confirmed": False,
3080
+ "latest_blockhash": blockhash,
3081
+ "sign_only": self.sign_only,
3082
+ "source": "solana-rpc",
3083
+ }
3084
+
3085
+ async def request_testnet_airdrop(self, amount_native: float) -> dict[str, Any]:
3086
+ if self.network not in {"devnet", "testnet"}:
3087
+ raise WalletBackendError("Airdrop is only available on Solana devnet or testnet.")
3088
+ if amount_native <= 0:
3089
+ raise WalletBackendError("amount must be greater than zero.")
3090
+
3091
+ address = await self.get_address()
3092
+ if not address:
3093
+ raise WalletBackendError(
3094
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
3095
+ )
3096
+
3097
+ lamports = int(round(amount_native * solana_rpc.LAMPORTS_PER_SOL))
3098
+ submitted = await solana_rpc.request_airdrop(
3099
+ address=address,
3100
+ lamports=lamports,
3101
+ rpc_url=self.rpc_urls,
3102
+ commitment=self.commitment,
3103
+ )
3104
+ signature = submitted.get("signature")
3105
+ status = None
3106
+ confirmed = False
3107
+ if isinstance(signature, str) and signature:
3108
+ status = await solana_rpc.wait_for_confirmation(
3109
+ signature=signature,
3110
+ rpc_url=self.rpc_urls,
3111
+ )
3112
+ confirmed = status is not None
3113
+
3114
+ return {
3115
+ "chain": "solana",
3116
+ "network": self.network,
3117
+ "mode": "airdrop",
3118
+ "address": address,
3119
+ "amount_native": amount_native,
3120
+ "amount_lamports": lamports,
3121
+ "signature": signature,
3122
+ "confirmed": confirmed,
3123
+ "confirmation_status": status.get("confirmationStatus") if status else None,
3124
+ "slot": status.get("slot") if status else None,
3125
+ "source": "solana-rpc",
3126
+ }
3127
+
3128
+ async def _resolve_mint_decimals(self, mint: str) -> int:
3129
+ if mint == NATIVE_SOL_MINT:
3130
+ return 9
3131
+ token_info = await solana_rpc.fetch_token_supply_info(mint, rpc_url=self.rpc_urls)
3132
+ return int(token_info.get("decimals") or 0)
3133
+
3134
+ async def _resolve_token_program_id(self, mint: str) -> str:
3135
+ if mint == NATIVE_SOL_MINT:
3136
+ return TOKEN_PROGRAM_ID
3137
+ account_info = await solana_rpc.fetch_account_info(mint, rpc_url=self.rpc_urls)
3138
+ if account_info is None:
3139
+ raise WalletBackendError("Mint account was not found on Solana RPC.")
3140
+ owner = account_info.get("owner")
3141
+ if owner not in {TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID}:
3142
+ raise WalletBackendError(
3143
+ "Unsupported token program for this mint. Only SPL Token and Token-2022 are supported."
3144
+ )
3145
+ return str(owner)
3146
+
3147
+ async def _fetch_token_entries(
3148
+ self,
3149
+ owner: str,
3150
+ include_zero_balances: bool,
3151
+ ) -> list[dict[str, Any]]:
3152
+ token_accounts_legacy, token_accounts_2022 = await asyncio.gather(
3153
+ solana_rpc.fetch_token_accounts_by_owner(
3154
+ owner,
3155
+ rpc_url=self.rpc_urls,
3156
+ token_program_id=TOKEN_PROGRAM_ID,
3157
+ ),
3158
+ solana_rpc.fetch_token_accounts_by_owner(
3159
+ owner,
3160
+ rpc_url=self.rpc_urls,
3161
+ token_program_id=TOKEN_2022_PROGRAM_ID,
3162
+ ),
3163
+ )
3164
+ token_accounts = token_accounts_legacy + token_accounts_2022
3165
+
3166
+ tokens: list[dict[str, Any]] = []
3167
+ for item in token_accounts:
3168
+ pubkey = item.get("pubkey")
3169
+ parsed = (
3170
+ item.get("account", {})
3171
+ .get("data", {})
3172
+ .get("parsed", {})
3173
+ .get("info", {})
3174
+ )
3175
+ token_amount = parsed.get("tokenAmount", {})
3176
+ ui_amount = token_amount.get("uiAmount")
3177
+ raw_amount = str(token_amount.get("amount") or "0")
3178
+ if not include_zero_balances and ui_amount in (None, 0, 0.0) and raw_amount == "0":
3179
+ continue
3180
+ tokens.append(
3181
+ {
3182
+ "mint": parsed.get("mint"),
3183
+ "token_account": pubkey,
3184
+ "token_program_id": item.get("account", {}).get("owner"),
3185
+ "owner": parsed.get("owner"),
3186
+ "close_authority": parsed.get("closeAuthority"),
3187
+ "amount_raw": raw_amount,
3188
+ "amount_ui": ui_amount,
3189
+ "decimals": token_amount.get("decimals"),
3190
+ "is_native": bool(parsed.get("isNative", False)),
3191
+ "state": parsed.get("state"),
3192
+ }
3193
+ )
3194
+ return tokens
3195
+
3196
+ async def _list_empty_closeable_token_accounts(self, owner: str) -> list[dict[str, Any]]:
3197
+ portfolio_tokens = await self._fetch_token_entries(owner, include_zero_balances=True)
3198
+ candidates: list[dict[str, Any]] = []
3199
+ for token in portfolio_tokens:
3200
+ raw_amount = str(token.get("amount_raw") or "0")
3201
+ close_authority = token.get("close_authority")
3202
+ if raw_amount != "0":
3203
+ continue
3204
+ if close_authority and close_authority != owner:
3205
+ continue
3206
+ candidates.append(token)
3207
+ return candidates
3208
+
3209
+ async def preview_spl_transfer(
3210
+ self,
3211
+ recipient: str,
3212
+ mint: str,
3213
+ amount_ui: float,
3214
+ decimals: int | None = None,
3215
+ ) -> dict[str, Any]:
3216
+ sender = await self.get_address()
3217
+ if not sender:
3218
+ raise WalletBackendError(
3219
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
3220
+ )
3221
+ if amount_ui <= 0:
3222
+ raise WalletBackendError("amount must be greater than zero.")
3223
+
3224
+ recipient = validate_solana_address(recipient)
3225
+ mint = validate_solana_mint(mint)
3226
+
3227
+ try:
3228
+ from solders.pubkey import Pubkey
3229
+ from spl.token.instructions import get_associated_token_address
3230
+ except ImportError as exc:
3231
+ raise WalletBackendError(
3232
+ "solana and solders packages are required for SPL token transfers."
3233
+ ) from exc
3234
+
3235
+ sender_pubkey = Pubkey.from_string(sender)
3236
+ recipient_pubkey = Pubkey.from_string(recipient)
3237
+ mint_pubkey = Pubkey.from_string(mint)
3238
+ token_program_id = await self._resolve_token_program_id(mint)
3239
+ token_program_pubkey = Pubkey.from_string(token_program_id)
3240
+ sender_ata = str(
3241
+ get_associated_token_address(
3242
+ sender_pubkey,
3243
+ mint_pubkey,
3244
+ token_program_id=token_program_pubkey,
3245
+ )
3246
+ )
3247
+ recipient_ata = str(
3248
+ get_associated_token_address(
3249
+ recipient_pubkey,
3250
+ mint_pubkey,
3251
+ token_program_id=token_program_pubkey,
3252
+ )
3253
+ )
3254
+
3255
+ sender_ata_exists = await solana_rpc.account_exists(sender_ata, rpc_url=self.rpc_urls)
3256
+ if not sender_ata_exists:
3257
+ raise WalletBackendError("Sender token account does not exist for this mint.")
3258
+
3259
+ token_info = await solana_rpc.fetch_token_supply_info(mint, rpc_url=self.rpc_urls)
3260
+ resolved_decimals = int(
3261
+ decimals if decimals is not None else (token_info.get("decimals") or 0)
3262
+ )
3263
+ raw_amount = int(round(amount_ui * (10**resolved_decimals)))
3264
+ if raw_amount <= 0:
3265
+ raise WalletBackendError("amount is too small for the token decimals.")
3266
+
3267
+ sender_balance = await solana_rpc.fetch_token_account_balance(
3268
+ sender_ata,
3269
+ rpc_url=self.rpc_urls,
3270
+ )
3271
+ sender_raw_balance = int(sender_balance.get("amount") or 0)
3272
+ if raw_amount > sender_raw_balance:
3273
+ raise WalletBackendError("Insufficient token balance for this transfer preview.")
3274
+
3275
+ recipient_ata_exists = await solana_rpc.account_exists(recipient_ata, rpc_url=self.rpc_urls)
3276
+ latest_blockhash = await solana_rpc.fetch_latest_blockhash(
3277
+ rpc_url=self.rpc_urls,
3278
+ commitment=self.commitment,
3279
+ )
3280
+
3281
+ return {
3282
+ "chain": "solana",
3283
+ "network": self.network,
3284
+ "mode": "preview",
3285
+ "asset_type": "spl",
3286
+ "from_address": sender,
3287
+ "to_address": recipient,
3288
+ "mint": mint,
3289
+ "token_program_id": token_program_id,
3290
+ "sender_token_account": sender_ata,
3291
+ "recipient_token_account": recipient_ata,
3292
+ "recipient_token_account_exists": recipient_ata_exists,
3293
+ "amount_ui": amount_ui,
3294
+ "amount_raw": raw_amount,
3295
+ "decimals": resolved_decimals,
3296
+ "sender_balance_ui_before": sender_balance.get("ui_amount"),
3297
+ "sender_balance_raw_before": sender_raw_balance,
3298
+ "estimated_sender_balance_raw_after": sender_raw_balance - raw_amount,
3299
+ "latest_blockhash": latest_blockhash["blockhash"],
3300
+ "last_valid_block_height": latest_blockhash["last_valid_block_height"],
3301
+ "sign_only": self.sign_only,
3302
+ "can_send": self.get_capabilities().can_send_transaction,
3303
+ "source": "solana-rpc",
3304
+ }
3305
+
3306
+ async def send_spl_transfer(
3307
+ self,
3308
+ recipient: str,
3309
+ mint: str,
3310
+ amount_ui: float,
3311
+ decimals: int | None = None,
3312
+ ) -> dict[str, Any]:
3313
+ prepared = await self.prepare_spl_transfer(
3314
+ recipient=recipient,
3315
+ mint=mint,
3316
+ amount_ui=amount_ui,
3317
+ decimals=decimals,
3318
+ )
3319
+ if self.sign_only:
3320
+ raise WalletBackendError(
3321
+ "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
3322
+ )
3323
+
3324
+ submitted = await solana_rpc.send_transaction(
3325
+ transaction_base64=str(prepared["transaction_base64"]),
3326
+ rpc_url=self.rpc_urls,
3327
+ )
3328
+ signature = submitted.get("signature")
3329
+ status = None
3330
+ confirmed = False
3331
+ if isinstance(signature, str) and signature:
3332
+ status = await solana_rpc.wait_for_confirmation(
3333
+ signature=signature,
3334
+ rpc_url=self.rpc_urls,
3335
+ )
3336
+ confirmed = status is not None
3337
+
3338
+ return {
3339
+ "chain": "solana",
3340
+ "network": self.network,
3341
+ "mode": "execute",
3342
+ "asset_type": "spl",
3343
+ "from_address": prepared["from_address"],
3344
+ "to_address": prepared["to_address"],
3345
+ "mint": prepared["mint"],
3346
+ "token_program_id": prepared["token_program_id"],
3347
+ "sender_token_account": prepared["sender_token_account"],
3348
+ "recipient_token_account": prepared["recipient_token_account"],
3349
+ "recipient_token_account_exists_before": prepared[
3350
+ "recipient_token_account_exists_before"
3351
+ ],
3352
+ "recipient_token_account_created": prepared["recipient_token_account_created"],
3353
+ "amount_ui": prepared["amount_ui"],
3354
+ "amount_raw": prepared["amount_raw"],
3355
+ "decimals": prepared["decimals"],
3356
+ "signature": signature,
3357
+ "broadcasted": bool(signature),
3358
+ "confirmed": confirmed,
3359
+ "confirmation_status": status.get("confirmationStatus") if status else None,
3360
+ "slot": status.get("slot") if status else None,
3361
+ "sign_only": self.sign_only,
3362
+ "source": "solana-rpc",
3363
+ }
3364
+
3365
+ async def prepare_spl_transfer(
3366
+ self,
3367
+ recipient: str,
3368
+ mint: str,
3369
+ amount_ui: float,
3370
+ decimals: int | None = None,
3371
+ ) -> dict[str, Any]:
3372
+ if not self.signer:
3373
+ raise WalletBackendError("Solana signer is not configured.")
3374
+
3375
+ preview = await self.preview_spl_transfer(
3376
+ recipient=recipient,
3377
+ mint=mint,
3378
+ amount_ui=amount_ui,
3379
+ decimals=decimals,
3380
+ )
3381
+
3382
+ try:
3383
+ from solders.hash import Hash
3384
+ from solders.instruction import Instruction
3385
+ from solders.keypair import Keypair
3386
+ from solders.message import Message
3387
+ from solders.pubkey import Pubkey
3388
+ from solders.transaction import Transaction
3389
+ from spl.token.instructions import (
3390
+ TransferCheckedParams,
3391
+ create_associated_token_account,
3392
+ transfer_checked,
3393
+ )
3394
+ except ImportError as exc:
3395
+ raise WalletBackendError(
3396
+ "solana and solders packages are required for SPL token transfers."
3397
+ ) from exc
3398
+
3399
+ sender = str(preview["from_address"])
3400
+ recipient = str(preview["to_address"])
3401
+ mint = str(preview["mint"])
3402
+ token_program_id = str(preview["token_program_id"])
3403
+ sender_ata = str(preview["sender_token_account"])
3404
+ recipient_ata = str(preview["recipient_token_account"])
3405
+ raw_amount = int(preview["amount_raw"])
3406
+ resolved_decimals = int(preview["decimals"])
3407
+
3408
+ sender_pubkey = Pubkey.from_string(sender)
3409
+ recipient_pubkey = Pubkey.from_string(recipient)
3410
+ mint_pubkey = Pubkey.from_string(mint)
3411
+ token_program_pubkey = Pubkey.from_string(token_program_id)
3412
+ sender_ata_pubkey = Pubkey.from_string(sender_ata)
3413
+ recipient_ata_pubkey = Pubkey.from_string(recipient_ata)
3414
+ keypair = Keypair.from_bytes(self.signer.export_keypair_bytes())
3415
+
3416
+ instructions: list[Instruction] = []
3417
+ if not bool(preview["recipient_token_account_exists"]):
3418
+ instructions.append(
3419
+ create_associated_token_account(
3420
+ payer=sender_pubkey,
3421
+ owner=recipient_pubkey,
3422
+ mint=mint_pubkey,
3423
+ token_program_id=token_program_pubkey,
3424
+ )
3425
+ )
3426
+
3427
+ instructions.append(
3428
+ transfer_checked(
3429
+ TransferCheckedParams(
3430
+ program_id=token_program_pubkey,
3431
+ source=sender_ata_pubkey,
3432
+ mint=mint_pubkey,
3433
+ dest=recipient_ata_pubkey,
3434
+ owner=sender_pubkey,
3435
+ amount=raw_amount,
3436
+ decimals=resolved_decimals,
3437
+ signers=[],
3438
+ )
3439
+ )
3440
+ )
3441
+
3442
+ blockhash = Hash.from_string(str(preview["latest_blockhash"]))
3443
+ message = Message.new_with_blockhash(instructions, sender_pubkey, blockhash)
3444
+ transaction = Transaction([keypair], message, blockhash)
3445
+ transaction_base64 = encode_transaction_base64(bytes(transaction))
3446
+
3447
+ return {
3448
+ "chain": "solana",
3449
+ "network": self.network,
3450
+ "mode": "prepare",
3451
+ "asset_type": "spl",
3452
+ "from_address": sender,
3453
+ "to_address": recipient,
3454
+ "mint": mint,
3455
+ "token_program_id": token_program_id,
3456
+ "sender_token_account": sender_ata,
3457
+ "recipient_token_account": recipient_ata,
3458
+ "recipient_token_account_exists_before": bool(
3459
+ preview["recipient_token_account_exists"]
3460
+ ),
3461
+ "recipient_token_account_created": not bool(preview["recipient_token_account_exists"]),
3462
+ "amount_ui": amount_ui,
3463
+ "amount_raw": raw_amount,
3464
+ "decimals": resolved_decimals,
3465
+ "transaction_base64": transaction_base64,
3466
+ "transaction_encoding": "base64",
3467
+ "transaction_format": "legacy",
3468
+ "signed": True,
3469
+ "broadcasted": False,
3470
+ "confirmed": False,
3471
+ "latest_blockhash": str(preview["latest_blockhash"]),
3472
+ "sign_only": self.sign_only,
3473
+ "source": "solana-rpc",
3474
+ }
3475
+
3476
+ async def preview_close_empty_token_accounts(
3477
+ self,
3478
+ limit: int = 8,
3479
+ ) -> dict[str, Any]:
3480
+ owner = await self.get_address()
3481
+ if not owner:
3482
+ raise WalletBackendError(
3483
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
3484
+ )
3485
+ if limit <= 0:
3486
+ raise WalletBackendError("limit must be greater than zero.")
3487
+
3488
+ candidates = await self._list_empty_closeable_token_accounts(owner)
3489
+ selected = candidates[:limit]
3490
+ return {
3491
+ "chain": "solana",
3492
+ "network": self.network,
3493
+ "mode": "preview",
3494
+ "asset_type": "close_empty_token_accounts",
3495
+ "address": owner,
3496
+ "candidate_count": len(candidates),
3497
+ "selected_count": len(selected),
3498
+ "accounts": selected,
3499
+ "limit": limit,
3500
+ "sign_only": self.sign_only,
3501
+ "can_send": self.get_capabilities().can_send_transaction,
3502
+ "source": "solana-rpc",
3503
+ }
3504
+
3505
+ async def close_empty_token_accounts(
3506
+ self,
3507
+ limit: int = 8,
3508
+ ) -> dict[str, Any]:
3509
+ if not self.signer:
3510
+ raise WalletBackendError("Solana signer is not configured.")
3511
+ if self.sign_only:
3512
+ raise WalletBackendError(
3513
+ "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
3514
+ )
3515
+
3516
+ preview = await self.preview_close_empty_token_accounts(limit=limit)
3517
+ if not preview["accounts"]:
3518
+ return {
3519
+ "chain": "solana",
3520
+ "network": self.network,
3521
+ "mode": "execute",
3522
+ "asset_type": "close_empty_token_accounts",
3523
+ "address": preview["address"],
3524
+ "candidate_count": 0,
3525
+ "closed_accounts": [],
3526
+ "signature": None,
3527
+ "broadcasted": False,
3528
+ "confirmed": False,
3529
+ "source": "solana-rpc",
3530
+ }
3531
+
3532
+ try:
3533
+ from solders.hash import Hash
3534
+ from solders.keypair import Keypair
3535
+ from solders.message import Message
3536
+ from solders.pubkey import Pubkey
3537
+ from solders.transaction import Transaction
3538
+ from spl.token.instructions import CloseAccountParams, close_account
3539
+ except ImportError as exc:
3540
+ raise WalletBackendError(
3541
+ "solana and solders packages are required to close token accounts."
3542
+ ) from exc
3543
+
3544
+ owner = str(preview["address"])
3545
+ owner_pubkey = Pubkey.from_string(owner)
3546
+ keypair = Keypair.from_bytes(self.signer.export_keypair_bytes())
3547
+ instructions = []
3548
+ for account in preview["accounts"]:
3549
+ instructions.append(
3550
+ close_account(
3551
+ CloseAccountParams(
3552
+ program_id=Pubkey.from_string(str(account["token_program_id"])),
3553
+ account=Pubkey.from_string(str(account["token_account"])),
3554
+ dest=owner_pubkey,
3555
+ owner=owner_pubkey,
3556
+ signers=[],
3557
+ )
3558
+ )
3559
+ )
3560
+
3561
+ latest_blockhash = await solana_rpc.fetch_latest_blockhash(
3562
+ rpc_url=self.rpc_urls,
3563
+ commitment=self.commitment,
3564
+ )
3565
+ blockhash = Hash.from_string(str(latest_blockhash["blockhash"]))
3566
+ message = Message.new_with_blockhash(instructions, owner_pubkey, blockhash)
3567
+ transaction = Transaction([keypair], message, blockhash)
3568
+
3569
+ submitted = await solana_rpc.send_transaction(
3570
+ transaction_base64=encode_transaction_base64(bytes(transaction)),
3571
+ rpc_url=self.rpc_urls,
3572
+ )
3573
+ signature = submitted.get("signature")
3574
+ status = None
3575
+ confirmed = False
3576
+ if isinstance(signature, str) and signature:
3577
+ status = await solana_rpc.wait_for_confirmation(
3578
+ signature=signature,
3579
+ rpc_url=self.rpc_urls,
3580
+ )
3581
+ confirmed = status is not None
3582
+
3583
+ return {
3584
+ "chain": "solana",
3585
+ "network": self.network,
3586
+ "mode": "execute",
3587
+ "asset_type": "close_empty_token_accounts",
3588
+ "address": owner,
3589
+ "candidate_count": preview["candidate_count"],
3590
+ "closed_accounts": preview["accounts"],
3591
+ "signature": signature,
3592
+ "broadcasted": bool(signature),
3593
+ "confirmed": confirmed,
3594
+ "confirmation_status": status.get("confirmationStatus") if status else None,
3595
+ "slot": status.get("slot") if status else None,
3596
+ "source": "solana-rpc",
3597
+ }
3598
+
3599
+ async def preview_bags_fee_claim(self, token_mint: str) -> dict[str, Any]:
3600
+ self._require_mainnet_bags("Bags fee claims")
3601
+ owner = await self.get_address()
3602
+ if not owner:
3603
+ raise WalletBackendError(
3604
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
3605
+ )
3606
+ normalized_mint = validate_solana_mint(token_mint)
3607
+ positions_payload = await self.get_bags_claimable_positions(owner)
3608
+ positions = [
3609
+ item
3610
+ for item in positions_payload["positions"]
3611
+ if str(item.get("tokenMint") or item.get("token_mint") or "").strip() == normalized_mint
3612
+ ]
3613
+ return {
3614
+ "chain": "solana",
3615
+ "network": self.network,
3616
+ "mode": "preview",
3617
+ "asset_type": "bags-fee-claim",
3618
+ "owner": owner,
3619
+ "fee_claimer": owner,
3620
+ "token_mint": normalized_mint,
3621
+ "claimable_position_count": len(positions),
3622
+ "claimable_positions": positions,
3623
+ "sign_only": self.sign_only,
3624
+ "can_send": self.get_capabilities().can_send_transaction,
3625
+ "source": "bags",
3626
+ }
3627
+
3628
+ async def execute_bags_fee_claim(
3629
+ self,
3630
+ token_mint: str,
3631
+ ) -> dict[str, Any]:
3632
+ preview = await self.preview_bags_fee_claim(token_mint)
3633
+ return await self.execute_bags_fee_claim_from_preview(preview)
3634
+
3635
+ async def execute_bags_fee_claim_from_preview(
3636
+ self,
3637
+ preview: dict[str, Any],
3638
+ ) -> dict[str, Any]:
3639
+ if not self.signer:
3640
+ raise WalletBackendError("Solana signer is not configured.")
3641
+ owner = await self.get_address()
3642
+ if not owner:
3643
+ raise WalletBackendError(
3644
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
3645
+ )
3646
+ if str(preview.get("asset_type") or "").strip().lower() != "bags-fee-claim":
3647
+ raise WalletBackendError("preview payload is not a Bags fee claim preview.")
3648
+ if str(preview.get("network") or self.network).strip().lower() != self.network:
3649
+ raise WalletBackendError("preview payload network does not match the wallet backend.")
3650
+ if str(preview.get("owner") or owner) != owner:
3651
+ raise WalletBackendError("preview payload owner does not match the connected wallet.")
3652
+ if int(preview.get("claimable_position_count") or 0) <= 0:
3653
+ raise WalletBackendError("No claimable Bags fee positions were found for this token.")
3654
+
3655
+ token_mint = validate_solana_mint(str(preview.get("token_mint") or ""))
3656
+ claim_payload = await bags.build_claim_transactions(
3657
+ {
3658
+ "feeClaimer": owner,
3659
+ "tokenMint": token_mint,
3660
+ }
3661
+ )
3662
+ transactions = self._bags_extract_transaction_base64s(claim_payload)
3663
+ prepared = await self._prepare_bags_transactions(
3664
+ transaction_base64s=transactions,
3665
+ token_mint=token_mint,
3666
+ action="Bags fee claim",
3667
+ owner=owner,
3668
+ asset_type="bags-fee-claim",
3669
+ extra={
3670
+ "fee_claimer": owner,
3671
+ "claimable_position_count": int(preview.get("claimable_position_count") or 0),
3672
+ "claimable_positions": preview.get("claimable_positions"),
3673
+ "claim_response": claim_payload,
3674
+ },
3675
+ )
3676
+ result = await self._execute_prepared_bags_transactions(prepared)
3677
+ result["fee_claimer"] = owner
3678
+ result["claimable_position_count"] = int(preview.get("claimable_position_count") or 0)
3679
+ result["claimable_positions"] = preview.get("claimable_positions")
3680
+ result["claim_response"] = claim_payload
3681
+ return result
3682
+
3683
+ async def preview_bags_token_launch(
3684
+ self,
3685
+ *,
3686
+ name: str,
3687
+ symbol: str,
3688
+ description: str,
3689
+ base_mint: str,
3690
+ claimers: list[str],
3691
+ basis_points: list[int],
3692
+ initial_buy_sol: float,
3693
+ image_url: str | None = None,
3694
+ website: str | None = None,
3695
+ twitter: str | None = None,
3696
+ telegram: str | None = None,
3697
+ discord: str | None = None,
3698
+ bags_config_type: int | None = None,
3699
+ ) -> dict[str, Any]:
3700
+ self._require_mainnet_bags("Bags token launch")
3701
+ owner = await self.get_address()
3702
+ if not owner:
3703
+ raise WalletBackendError(
3704
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
3705
+ )
3706
+ normalized_name = str(name).strip()
3707
+ normalized_symbol = str(symbol).strip()
3708
+ normalized_description = str(description).strip()
3709
+ if not normalized_name:
3710
+ raise WalletBackendError("name is required.")
3711
+ if not normalized_symbol:
3712
+ raise WalletBackendError("symbol is required.")
3713
+ if not normalized_description:
3714
+ raise WalletBackendError("description is required.")
3715
+ normalized_base_mint = validate_solana_mint(base_mint)
3716
+ normalized_claimers = self._normalize_bags_claimers(claimers)
3717
+ normalized_basis_points = self._normalize_bags_basis_points(basis_points)
3718
+ if len(normalized_claimers) != len(normalized_basis_points):
3719
+ raise WalletBackendError("claimers and basis_points must have the same length.")
3720
+ if owner not in normalized_claimers:
3721
+ raise WalletBackendError(
3722
+ "claimers must explicitly include the connected wallet address as the creator fee recipient."
3723
+ )
3724
+ if isinstance(initial_buy_sol, bool) or float(initial_buy_sol) < 0:
3725
+ raise WalletBackendError("initial_buy_sol must be a non-negative number.")
3726
+ initial_buy_lamports = int(round(float(initial_buy_sol) * solana_rpc.LAMPORTS_PER_SOL))
3727
+ if initial_buy_lamports < 0:
3728
+ raise WalletBackendError("initial_buy_sol must be a non-negative number.")
3729
+ normalized_bags_config_type = (
3730
+ _coerce_non_negative_integer(bags_config_type, field_name="bags_config_type")
3731
+ if bags_config_type is not None
3732
+ else None
3733
+ )
3734
+ preview = {
3735
+ "chain": "solana",
3736
+ "network": self.network,
3737
+ "mode": "preview",
3738
+ "asset_type": "bags-token-launch",
3739
+ "owner": owner,
3740
+ "wallet": owner,
3741
+ "token_name": normalized_name,
3742
+ "token_symbol": normalized_symbol,
3743
+ "description": normalized_description,
3744
+ "image_url": str(image_url or "").strip() or None,
3745
+ "website": str(website or "").strip() or None,
3746
+ "twitter": str(twitter or "").strip() or None,
3747
+ "telegram": str(telegram or "").strip() or None,
3748
+ "discord": str(discord or "").strip() or None,
3749
+ "base_mint": normalized_base_mint,
3750
+ "claimers": normalized_claimers,
3751
+ "basis_points": normalized_basis_points,
3752
+ "claimers_count": len(normalized_claimers),
3753
+ "total_basis_points": sum(normalized_basis_points),
3754
+ "initial_buy_sol": float(initial_buy_sol),
3755
+ "initial_buy_lamports": initial_buy_lamports,
3756
+ "bags_config_type": normalized_bags_config_type,
3757
+ "sign_only": self.sign_only,
3758
+ "can_send": self.get_capabilities().can_send_transaction,
3759
+ "source": "bags",
3760
+ }
3761
+ return preview
3762
+
3763
+ async def execute_bags_token_launch(
3764
+ self,
3765
+ *,
3766
+ name: str,
3767
+ symbol: str,
3768
+ description: str,
3769
+ base_mint: str,
3770
+ claimers: list[str],
3771
+ basis_points: list[int],
3772
+ initial_buy_sol: float,
3773
+ image_url: str | None = None,
3774
+ website: str | None = None,
3775
+ twitter: str | None = None,
3776
+ telegram: str | None = None,
3777
+ discord: str | None = None,
3778
+ bags_config_type: int | None = None,
3779
+ ) -> dict[str, Any]:
3780
+ preview = await self.preview_bags_token_launch(
3781
+ name=name,
3782
+ symbol=symbol,
3783
+ description=description,
3784
+ base_mint=base_mint,
3785
+ claimers=claimers,
3786
+ basis_points=basis_points,
3787
+ initial_buy_sol=initial_buy_sol,
3788
+ image_url=image_url,
3789
+ website=website,
3790
+ twitter=twitter,
3791
+ telegram=telegram,
3792
+ discord=discord,
3793
+ bags_config_type=bags_config_type,
3794
+ )
3795
+ return await self.execute_bags_token_launch_from_preview(preview)
3796
+
3797
+ async def execute_bags_token_launch_from_preview(
3798
+ self,
3799
+ preview: dict[str, Any],
3800
+ ) -> dict[str, Any]:
3801
+ if not self.signer:
3802
+ raise WalletBackendError("Solana signer is not configured.")
3803
+ owner = await self.get_address()
3804
+ if not owner:
3805
+ raise WalletBackendError(
3806
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
3807
+ )
3808
+ if str(preview.get("asset_type") or "").strip().lower() != "bags-token-launch":
3809
+ raise WalletBackendError("preview payload is not a Bags token launch preview.")
3810
+ if str(preview.get("network") or self.network).strip().lower() != self.network:
3811
+ raise WalletBackendError("preview payload network does not match the wallet backend.")
3812
+ if str(preview.get("owner") or owner) != owner:
3813
+ raise WalletBackendError("preview payload owner does not match the connected wallet.")
3814
+ if int(preview.get("claimers_count") or 0) > 7:
3815
+ raise WalletBackendError(
3816
+ "Bags fee-share launches with more than 7 fee claimers require lookup table "
3817
+ "creation, which this backend does not generate yet."
3818
+ )
3819
+
3820
+ token_info_payload = {
3821
+ "name": str(preview["token_name"]),
3822
+ "symbol": str(preview["token_symbol"]),
3823
+ "description": str(preview["description"]),
3824
+ }
3825
+ optional_metadata = {
3826
+ "imageUrl": preview.get("image_url"),
3827
+ "website": preview.get("website"),
3828
+ "twitter": preview.get("twitter"),
3829
+ "telegram": preview.get("telegram"),
3830
+ "discord": preview.get("discord"),
3831
+ }
3832
+ for key, value in optional_metadata.items():
3833
+ if isinstance(value, str) and value.strip():
3834
+ token_info_payload[key] = value.strip()
3835
+
3836
+ token_info_response = await bags.create_token_info(token_info_payload)
3837
+ token_mint, ipfs = self._bags_extract_token_info_fields(token_info_response)
3838
+ fee_share_payload: dict[str, Any] = {
3839
+ "payer": owner,
3840
+ "baseMint": token_mint,
3841
+ "claimersArray": list(preview["claimers"]),
3842
+ "basisPointsArray": [int(value) for value in preview["basis_points"]],
3843
+ }
3844
+ if preview.get("bags_config_type") is not None:
3845
+ fee_share_payload["bagsConfigType"] = int(preview["bags_config_type"])
3846
+ fee_share_response = await bags.create_fee_share_config(fee_share_payload)
3847
+ config_key = self._bags_extract_config_key(fee_share_response)
3848
+ fee_share_execution: dict[str, Any] | None = None
3849
+ if bool(fee_share_response.get("needsCreation")):
3850
+ fee_share_transactions = self._bags_extract_fee_share_config_transaction_strings(
3851
+ fee_share_response
3852
+ )
3853
+ if not fee_share_transactions:
3854
+ raise WalletBackendError(
3855
+ "Bags fee share config requested creation but returned no transactions."
3856
+ )
3857
+ fee_share_prepared = await self._prepare_bags_transactions(
3858
+ transaction_base64s=fee_share_transactions,
3859
+ token_mint=token_mint,
3860
+ action="Bags fee share config",
3861
+ owner=owner,
3862
+ asset_type="bags-fee-share-config",
3863
+ extra={
3864
+ "wallet": owner,
3865
+ "base_mint": preview["base_mint"],
3866
+ "claimers": preview["claimers"],
3867
+ "basis_points": preview["basis_points"],
3868
+ "claimers_count": preview["claimers_count"],
3869
+ "total_basis_points": preview["total_basis_points"],
3870
+ "config_key": config_key,
3871
+ "fee_share_response": fee_share_response,
3872
+ },
3873
+ )
3874
+ fee_share_execution = await self._execute_prepared_bags_transactions(fee_share_prepared)
3875
+ launch_transaction_response = await bags.create_launch_transaction(
3876
+ {
3877
+ "ipfs": ipfs,
3878
+ "tokenMint": token_mint,
3879
+ "wallet": owner,
3880
+ "configKey": config_key,
3881
+ "initialBuyLamports": str(int(preview["initial_buy_lamports"])),
3882
+ }
3883
+ )
3884
+ transactions = self._bags_extract_serialized_transaction_strings(launch_transaction_response)
3885
+ prepared = await self._prepare_bags_transactions(
3886
+ transaction_base64s=transactions,
3887
+ token_mint=token_mint,
3888
+ action="Bags token launch",
3889
+ owner=owner,
3890
+ asset_type="bags-token-launch",
3891
+ extra={
3892
+ "wallet": owner,
3893
+ "token_name": preview["token_name"],
3894
+ "token_symbol": preview["token_symbol"],
3895
+ "base_mint": preview["base_mint"],
3896
+ "claimers": preview["claimers"],
3897
+ "basis_points": preview["basis_points"],
3898
+ "claimers_count": preview["claimers_count"],
3899
+ "total_basis_points": preview["total_basis_points"],
3900
+ "initial_buy_sol": preview["initial_buy_sol"],
3901
+ "initial_buy_lamports": preview["initial_buy_lamports"],
3902
+ "config_key": config_key,
3903
+ "ipfs": ipfs,
3904
+ "token_info_response": token_info_response,
3905
+ "fee_share_response": fee_share_response,
3906
+ "fee_share_execution": fee_share_execution,
3907
+ "launch_transaction_response": launch_transaction_response,
3908
+ },
3909
+ )
3910
+ result = await self._execute_prepared_bags_transactions(prepared)
3911
+ result.update(
3912
+ {
3913
+ "wallet": owner,
3914
+ "token_name": preview["token_name"],
3915
+ "token_symbol": preview["token_symbol"],
3916
+ "base_mint": preview["base_mint"],
3917
+ "claimers": preview["claimers"],
3918
+ "basis_points": preview["basis_points"],
3919
+ "claimers_count": preview["claimers_count"],
3920
+ "total_basis_points": preview["total_basis_points"],
3921
+ "initial_buy_sol": preview["initial_buy_sol"],
3922
+ "initial_buy_lamports": preview["initial_buy_lamports"],
3923
+ "config_key": config_key,
3924
+ "ipfs": ipfs,
3925
+ "token_info_response": token_info_response,
3926
+ "fee_share_response": fee_share_response,
3927
+ "fee_share_execution": fee_share_execution,
3928
+ "launch_transaction_response": launch_transaction_response,
3929
+ }
3930
+ )
3931
+ return result
3932
+
3933
+ async def preview_swap(
3934
+ self,
3935
+ input_mint: str,
3936
+ output_mint: str,
3937
+ amount_ui: float,
3938
+ slippage_bps: int = 50,
3939
+ ) -> dict[str, Any]:
3940
+ if self.network != "mainnet":
3941
+ raise WalletBackendError("Provider-routed swaps are only enabled for Solana mainnet.")
3942
+ if amount_ui <= 0:
3943
+ raise WalletBackendError("amount must be greater than zero.")
3944
+ if slippage_bps <= 0:
3945
+ raise WalletBackendError("slippage_bps must be greater than zero.")
3946
+
3947
+ input_mint = validate_solana_mint(input_mint)
3948
+ output_mint = validate_solana_mint(output_mint)
3949
+ if input_mint == output_mint:
3950
+ raise WalletBackendError("input_mint and output_mint must be different.")
3951
+
3952
+ input_decimals = await self._resolve_mint_decimals(input_mint)
3953
+ output_decimals = await self._resolve_mint_decimals(output_mint)
3954
+ raw_amount = int(round(amount_ui * (10**input_decimals)))
3955
+ if raw_amount <= 0:
3956
+ raise WalletBackendError("amount is too small for the input token decimals.")
3957
+
3958
+ sender = await self.get_address()
3959
+ quote_source = "jupiter-ultra"
3960
+ try:
3961
+ quote = await jupiter.fetch_ultra_order(
3962
+ input_mint=input_mint,
3963
+ output_mint=output_mint,
3964
+ amount_raw=raw_amount,
3965
+ taker=sender,
3966
+ slippage_bps=slippage_bps,
3967
+ )
3968
+ except ProviderError:
3969
+ quote = await jupiter.fetch_quote(
3970
+ input_mint=input_mint,
3971
+ output_mint=output_mint,
3972
+ amount_raw=raw_amount,
3973
+ slippage_bps=slippage_bps,
3974
+ )
3975
+ quote_source = "jupiter-metis"
3976
+
3977
+ out_amount_raw = int(quote.get("outAmount") or 0)
3978
+ other_threshold_raw = int(
3979
+ quote.get("otherAmountThreshold")
3980
+ or quote.get("minOutAmount")
3981
+ or 0
3982
+ )
3983
+ fee_summary = self._build_swap_fee_summary(
3984
+ swap_provider=quote_source,
3985
+ quote_response=quote,
3986
+ )
3987
+ return {
3988
+ "chain": "solana",
3989
+ "network": self.network,
3990
+ "mode": "preview",
3991
+ "asset_type": "swap",
3992
+ "owner": sender,
3993
+ "input_mint": input_mint,
3994
+ "output_mint": output_mint,
3995
+ "input_amount_ui": amount_ui,
3996
+ "input_amount_raw": raw_amount,
3997
+ "input_decimals": input_decimals,
3998
+ "estimated_output_amount_ui": out_amount_raw / (10**output_decimals),
3999
+ "estimated_output_amount_raw": out_amount_raw,
4000
+ "minimum_output_amount_ui": other_threshold_raw / (10**output_decimals),
4001
+ "minimum_output_amount_raw": other_threshold_raw,
4002
+ "output_decimals": output_decimals,
4003
+ "slippage_bps": int(quote.get("slippageBps") or slippage_bps),
4004
+ "price_impact_pct": quote.get("priceImpactPct"),
4005
+ "route_plan": quote.get("routePlan", []),
4006
+ "context_slot": quote.get("contextSlot"),
4007
+ "time_taken_seconds": quote.get("timeTaken"),
4008
+ "fee_summary": fee_summary,
4009
+ "estimated_total_fee_label": self._format_swap_fee_label(fee_summary),
4010
+ "quote_response": quote,
4011
+ "swap_provider": quote_source,
4012
+ "can_send": self.get_capabilities().can_send_transaction,
4013
+ "sign_only": self.sign_only,
4014
+ "source": quote_source,
4015
+ }
4016
+
4017
+ async def execute_swap(
4018
+ self,
4019
+ input_mint: str,
4020
+ output_mint: str,
4021
+ amount_ui: float,
4022
+ slippage_bps: int = 50,
4023
+ ) -> dict[str, Any]:
4024
+ preview = await self.preview_swap(
4025
+ input_mint=input_mint,
4026
+ output_mint=output_mint,
4027
+ amount_ui=amount_ui,
4028
+ slippage_bps=slippage_bps,
4029
+ )
4030
+ return await self.execute_swap_from_preview(preview)
4031
+
4032
+ async def execute_swap_from_preview(
4033
+ self,
4034
+ preview: dict[str, Any],
4035
+ ) -> dict[str, Any]:
4036
+ prepared = await self.prepare_swap_from_preview(preview)
4037
+ if self.sign_only:
4038
+ raise WalletBackendError(
4039
+ "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
4040
+ )
4041
+
4042
+ if prepared.get("swap_provider") == "jupiter-ultra":
4043
+ submitted = await jupiter.execute_ultra_order(
4044
+ signed_transaction_base64=str(prepared["transaction_base64"]),
4045
+ request_id=str(prepared["request_id"]),
4046
+ )
4047
+ onchain_signature = submitted.get("signature") or submitted.get("txid")
4048
+ else:
4049
+ submitted = await solana_rpc.send_transaction(
4050
+ transaction_base64=str(prepared["transaction_base64"]),
4051
+ rpc_url=self.rpc_urls,
4052
+ )
4053
+ onchain_signature = submitted.get("signature")
4054
+ status = None
4055
+ confirmed = False
4056
+ if isinstance(onchain_signature, str) and onchain_signature:
4057
+ status = await solana_rpc.wait_for_confirmation(
4058
+ signature=onchain_signature,
4059
+ rpc_url=self.rpc_urls,
4060
+ )
4061
+ confirmed = status is not None
4062
+
4063
+ return {
4064
+ "chain": "solana",
4065
+ "network": self.network,
4066
+ "mode": "execute",
4067
+ "asset_type": "swap",
4068
+ "owner": prepared.get("owner"),
4069
+ "input_mint": prepared["input_mint"],
4070
+ "output_mint": prepared["output_mint"],
4071
+ "input_amount_ui": prepared["input_amount_ui"],
4072
+ "estimated_output_amount_ui": prepared["estimated_output_amount_ui"],
4073
+ "minimum_output_amount_ui": prepared["minimum_output_amount_ui"],
4074
+ "minimum_output_amount_raw": prepared.get("minimum_output_amount_raw"),
4075
+ "slippage_bps": prepared["slippage_bps"],
4076
+ "price_impact_pct": prepared["price_impact_pct"],
4077
+ "signature": onchain_signature,
4078
+ "broadcasted": bool(onchain_signature),
4079
+ "confirmed": confirmed,
4080
+ "confirmation_status": status.get("confirmationStatus") if status else None,
4081
+ "slot": status.get("slot") if status else None,
4082
+ "last_valid_block_height": prepared["last_valid_block_height"],
4083
+ "prioritization_fee_lamports": prepared["prioritization_fee_lamports"],
4084
+ "compute_unit_limit": prepared["compute_unit_limit"],
4085
+ "fee_summary": prepared.get("fee_summary"),
4086
+ "estimated_total_fee_label": prepared.get("estimated_total_fee_label"),
4087
+ "request_id": prepared.get("request_id"),
4088
+ "swap_provider": prepared.get("swap_provider"),
4089
+ "swap_safety": prepared.get("swap_safety"),
4090
+ "simulation": prepared.get("simulation"),
4091
+ "execute_response": submitted,
4092
+ "source": prepared.get("swap_provider") or "jupiter-metis",
4093
+ }
4094
+
4095
+ async def prepare_swap(
4096
+ self,
4097
+ input_mint: str,
4098
+ output_mint: str,
4099
+ amount_ui: float,
4100
+ slippage_bps: int = 50,
4101
+ ) -> dict[str, Any]:
4102
+ preview = await self.preview_swap(
4103
+ input_mint=input_mint,
4104
+ output_mint=output_mint,
4105
+ amount_ui=amount_ui,
4106
+ slippage_bps=slippage_bps,
4107
+ )
4108
+ return await self.prepare_swap_from_preview(preview)
4109
+
4110
+ async def prepare_swap_from_preview(
4111
+ self,
4112
+ preview: dict[str, Any],
4113
+ ) -> dict[str, Any]:
4114
+ if not self.signer:
4115
+ raise WalletBackendError("Solana signer is not configured.")
4116
+
4117
+ sender = await self.get_address()
4118
+ if not sender:
4119
+ raise WalletBackendError(
4120
+ "No Solana wallet address configured. Set SOLANA_AGENT_PUBLIC_KEY or a signer."
4121
+ )
4122
+
4123
+ if str(preview.get("asset_type") or "").strip().lower() != "swap":
4124
+ raise WalletBackendError("preview payload is not a swap preview.")
4125
+ if str(preview.get("network") or self.network).strip().lower() != self.network:
4126
+ raise WalletBackendError("preview payload network does not match the wallet backend.")
4127
+ if str(preview.get("owner") or sender) != sender:
4128
+ raise WalletBackendError("preview payload owner does not match the connected wallet.")
4129
+
4130
+ try:
4131
+ from solders.keypair import Keypair
4132
+ from solders.message import to_bytes_versioned
4133
+ from solders.transaction import VersionedTransaction
4134
+ except ImportError as exc:
4135
+ raise WalletBackendError(
4136
+ "solana and solders packages are required for provider-routed swap execution."
4137
+ ) from exc
4138
+
4139
+ swap_provider = str(preview.get("swap_provider") or "jupiter-metis")
4140
+ request_id = None
4141
+ if swap_provider == "jupiter-ultra":
4142
+ swap_build = preview["quote_response"]
4143
+ unsigned_transaction = VersionedTransaction.from_bytes(
4144
+ base64.b64decode(str(swap_build["transaction"]))
4145
+ )
4146
+ request_id = swap_build.get("requestId")
4147
+ last_valid_block_height = swap_build.get("expireAt")
4148
+ prioritization_fee_lamports = swap_build.get("prioritizationFeeLamports")
4149
+ compute_unit_limit = swap_build.get("computeUnitLimit")
4150
+ else:
4151
+ swap_build = await jupiter.build_swap_transaction(
4152
+ user_public_key=sender,
4153
+ quote_response=preview["quote_response"],
4154
+ )
4155
+ unsigned_transaction = VersionedTransaction.from_bytes(
4156
+ base64.b64decode(str(swap_build["swapTransaction"]))
4157
+ )
4158
+ last_valid_block_height = swap_build.get("lastValidBlockHeight")
4159
+ prioritization_fee_lamports = swap_build.get("prioritizationFeeLamports")
4160
+ compute_unit_limit = swap_build.get("computeUnitLimit")
4161
+
4162
+ verification = verify_provider_swap_transaction(
4163
+ unsigned_transaction.message,
4164
+ wallet_address=sender,
4165
+ input_mint=str(preview["input_mint"]),
4166
+ output_mint=str(preview["output_mint"]),
4167
+ )
4168
+ keypair = Keypair.from_bytes(self.signer.export_keypair_bytes())
4169
+ signature = keypair.sign_message(to_bytes_versioned(unsigned_transaction.message))
4170
+ signatures = list(unsigned_transaction.signatures)
4171
+ wallet_signer_index = int(verification.get("wallet_signer_index") or 0)
4172
+ if wallet_signer_index >= len(signatures):
4173
+ raise WalletBackendError(
4174
+ "Provider swap transaction signer layout is incompatible with local signing."
4175
+ )
4176
+ signatures[wallet_signer_index] = signature
4177
+ signed_transaction = VersionedTransaction.populate(
4178
+ unsigned_transaction.message,
4179
+ signatures,
4180
+ )
4181
+ signed_transaction_base64 = encode_transaction_base64(bytes(signed_transaction))
4182
+ simulation_value: dict[str, Any] | None = None
4183
+ swap_safety: dict[str, Any]
4184
+ try:
4185
+ simulation = await solana_rpc.simulate_transaction(
4186
+ transaction_base64=signed_transaction_base64,
4187
+ rpc_url=self.rpc_urls,
4188
+ commitment=self.commitment,
4189
+ )
4190
+ simulation_value = (
4191
+ simulation.get("value") if isinstance(simulation.get("value"), dict) else {}
4192
+ )
4193
+ swap_safety = verify_provider_swap_simulation_result(
4194
+ simulation_value,
4195
+ wallet_address=sender,
4196
+ wallet_account_index=wallet_signer_index,
4197
+ input_mint=str(preview["input_mint"]),
4198
+ output_mint=str(preview["output_mint"]),
4199
+ input_amount_raw=int(preview["input_amount_raw"]),
4200
+ minimum_output_amount_raw=int(preview["minimum_output_amount_raw"]),
4201
+ )
4202
+ except ProviderError as exc:
4203
+ swap_safety = {
4204
+ "verified": False,
4205
+ "simulation_unavailable": True,
4206
+ "warning": (
4207
+ "Swap simulation could not be completed via the configured Solana RPC. "
4208
+ "Proceeding with structural provider verification to preserve swap "
4209
+ "availability."
4210
+ ),
4211
+ "error": str(exc),
4212
+ }
4213
+ fee_summary = self._build_swap_fee_summary(
4214
+ swap_provider=swap_provider,
4215
+ quote_response=preview["quote_response"],
4216
+ prioritization_fee_lamports=prioritization_fee_lamports,
4217
+ compute_unit_limit=compute_unit_limit,
4218
+ )
4219
+
4220
+ return {
4221
+ "chain": "solana",
4222
+ "network": self.network,
4223
+ "mode": "prepare",
4224
+ "asset_type": "swap",
4225
+ "owner": sender,
4226
+ "input_mint": preview["input_mint"],
4227
+ "output_mint": preview["output_mint"],
4228
+ "input_amount_ui": preview["input_amount_ui"],
4229
+ "input_amount_raw": preview["input_amount_raw"],
4230
+ "estimated_output_amount_ui": preview["estimated_output_amount_ui"],
4231
+ "minimum_output_amount_ui": preview["minimum_output_amount_ui"],
4232
+ "minimum_output_amount_raw": preview["minimum_output_amount_raw"],
4233
+ "slippage_bps": preview["slippage_bps"],
4234
+ "price_impact_pct": preview["price_impact_pct"],
4235
+ "transaction_base64": signed_transaction_base64,
4236
+ "transaction_encoding": "base64",
4237
+ "transaction_format": "versioned",
4238
+ "signed": True,
4239
+ "broadcasted": False,
4240
+ "confirmed": False,
4241
+ "last_valid_block_height": last_valid_block_height,
4242
+ "prioritization_fee_lamports": prioritization_fee_lamports,
4243
+ "compute_unit_limit": compute_unit_limit,
4244
+ "fee_summary": fee_summary,
4245
+ "estimated_total_fee_label": self._format_swap_fee_label(fee_summary),
4246
+ "verification": verification,
4247
+ "swap_safety": swap_safety,
4248
+ "simulation": simulation_value,
4249
+ "request_id": request_id,
4250
+ "swap_provider": swap_provider,
4251
+ "source": swap_provider,
4252
+ }