@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.
- package/.openclaw/AGENTS.md +98 -0
- package/.openclaw/extensions/agent-wallet/README.md +127 -0
- package/.openclaw/extensions/agent-wallet/index.ts +1520 -0
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +184 -0
- package/.openclaw/extensions/agent-wallet/package.json +11 -0
- package/.openclaw/extensions/agent-wallet/skills/wallet-operator/SKILL.md +20 -0
- package/CHANGELOG.md +42 -0
- package/LICENSE +104 -0
- package/README.md +332 -0
- package/RELEASING.md +204 -0
- package/agent-wallet/.env.example +62 -0
- package/agent-wallet/AGENTS.md +129 -0
- package/agent-wallet/README.md +527 -0
- package/agent-wallet/agent_wallet/__init__.py +11 -0
- package/agent-wallet/agent_wallet/approval.py +161 -0
- package/agent-wallet/agent_wallet/bootstrap.py +178 -0
- package/agent-wallet/agent_wallet/btc_user_wallets.py +217 -0
- package/agent-wallet/agent_wallet/config.py +382 -0
- package/agent-wallet/agent_wallet/encrypted_storage.py +161 -0
- package/agent-wallet/agent_wallet/evm_user_wallets.py +370 -0
- package/agent-wallet/agent_wallet/exceptions.py +9 -0
- package/agent-wallet/agent_wallet/file_ops.py +34 -0
- package/agent-wallet/agent_wallet/http_client.py +25 -0
- package/agent-wallet/agent_wallet/models.py +66 -0
- package/agent-wallet/agent_wallet/nonce_registry.py +59 -0
- package/agent-wallet/agent_wallet/openclaw_adapter.py +5128 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +626 -0
- package/agent-wallet/agent_wallet/openclaw_runtime.py +272 -0
- package/agent-wallet/agent_wallet/plugin_bundle.py +42 -0
- package/agent-wallet/agent_wallet/providers/__init__.py +1 -0
- package/agent-wallet/agent_wallet/providers/bags.py +259 -0
- package/agent-wallet/agent_wallet/providers/evm_portfolio.py +470 -0
- package/agent-wallet/agent_wallet/providers/jupiter.py +567 -0
- package/agent-wallet/agent_wallet/providers/kamino.py +215 -0
- package/agent-wallet/agent_wallet/providers/lifi.py +277 -0
- package/agent-wallet/agent_wallet/providers/solana_rpc.py +470 -0
- package/agent-wallet/agent_wallet/providers/wdk_btc_local.py +114 -0
- package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +205 -0
- package/agent-wallet/agent_wallet/sealed_keys.py +61 -0
- package/agent-wallet/agent_wallet/solana_stake.py +103 -0
- package/agent-wallet/agent_wallet/solana_tx.py +93 -0
- package/agent-wallet/agent_wallet/spending_limits.py +101 -0
- package/agent-wallet/agent_wallet/transaction_policy.py +518 -0
- package/agent-wallet/agent_wallet/user_wallets.py +355 -0
- package/agent-wallet/agent_wallet/validation.py +31 -0
- package/agent-wallet/agent_wallet/wallet_layer/__init__.py +1 -0
- package/agent-wallet/agent_wallet/wallet_layer/base.py +808 -0
- package/agent-wallet/agent_wallet/wallet_layer/base58.py +44 -0
- package/agent-wallet/agent_wallet/wallet_layer/factory.py +102 -0
- package/agent-wallet/agent_wallet/wallet_layer/solana.py +4252 -0
- package/agent-wallet/agent_wallet/wallet_layer/wdk_btc.py +272 -0
- package/agent-wallet/agent_wallet/wallet_layer/wdk_evm.py +1628 -0
- package/agent-wallet/examples/bootstrap_wallet.py +21 -0
- package/agent-wallet/examples/openclaw_runtime_onboarding.py +28 -0
- package/agent-wallet/examples/openclaw_user_wallet_example.py +31 -0
- package/agent-wallet/examples/openclaw_wallet_adapter_example.py +33 -0
- package/agent-wallet/openclaw.plugin.json +138 -0
- package/agent-wallet/pyproject.toml +31 -0
- package/agent-wallet/scripts/bootstrap_openclaw_btc.py +278 -0
- package/agent-wallet/scripts/build_release_bundle.py +188 -0
- package/agent-wallet/scripts/finalize_openclaw_local_wallet_config.py +121 -0
- package/agent-wallet/scripts/install_agent_wallet.py +505 -0
- package/agent-wallet/scripts/install_openclaw_local_config.py +226 -0
- package/agent-wallet/scripts/install_openclaw_sealed_keys.py +105 -0
- package/agent-wallet/scripts/manage_openclaw_btc_wallet.py +244 -0
- package/agent-wallet/scripts/reveal_btc_seed.sh +130 -0
- package/agent-wallet/scripts/security_utils.py +37 -0
- package/agent-wallet/scripts/setup_btc_wallet.sh +146 -0
- package/agent-wallet/scripts/switch_openclaw_wallet_network.py +106 -0
- package/agent-wallet/skills/wallet-operator/SKILL.md +128 -0
- package/bin/openclaw-agent-wallet.mjs +487 -0
- package/install-from-github.sh +134 -0
- package/package.json +61 -0
- package/setup.sh +40 -0
- package/wdk-btc-wallet/README.md +325 -0
- package/wdk-btc-wallet/bootstrap.sh +22 -0
- package/wdk-btc-wallet/package-lock.json +1839 -0
- package/wdk-btc-wallet/package.json +18 -0
- package/wdk-btc-wallet/run-local.sh +21 -0
- package/wdk-btc-wallet/src/config.js +160 -0
- package/wdk-btc-wallet/src/json.js +35 -0
- package/wdk-btc-wallet/src/local_vault.js +432 -0
- package/wdk-btc-wallet/src/network_state.js +84 -0
- package/wdk-btc-wallet/src/server.js +257 -0
- package/wdk-btc-wallet/src/wdk_btc_wallet.js +332 -0
- package/wdk-evm-wallet/README.md +183 -0
- package/wdk-evm-wallet/bootstrap.sh +8 -0
- package/wdk-evm-wallet/package-lock.json +2340 -0
- package/wdk-evm-wallet/package.json +23 -0
- package/wdk-evm-wallet/run-local.sh +12 -0
- package/wdk-evm-wallet/src/config.js +274 -0
- package/wdk-evm-wallet/src/json.js +35 -0
- package/wdk-evm-wallet/src/local_vault.js +430 -0
- package/wdk-evm-wallet/src/network_state.js +92 -0
- package/wdk-evm-wallet/src/server.js +575 -0
- 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
|
+
}
|