@agentlayer.tech/wallet 0.1.25 → 0.1.27

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.
@@ -7,6 +7,7 @@ import base64
7
7
  import binascii
8
8
  import hashlib
9
9
  import json
10
+ import time
10
11
  from decimal import Decimal, InvalidOperation
11
12
  from typing import Any
12
13
 
@@ -48,6 +49,8 @@ TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
48
49
  NATIVE_SOL_MINT = "So11111111111111111111111111111111111111112"
49
50
  STAKE_PROGRAM_ID = "Stake11111111111111111111111111111111111111"
50
51
  HOUDINI_PRIVATE_OUTPUT_DRIFT_BPS = 600
52
+ SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS = 300
53
+ SOLANA_SWAP_INTENT_DEFAULT_MAX_FEE_LAMPORTS = 6_000_000
51
54
 
52
55
 
53
56
  def _load_signing_key():
@@ -2062,6 +2065,29 @@ class SolanaWalletBackend(AgentWalletBackend):
2062
2065
  parts.append(f"route fee {route_fee_bps} bps (already reflected in quoted output)")
2063
2066
  return "; ".join(parts)
2064
2067
 
2068
+ def _swap_fee_lamports(self, payload: dict[str, Any]) -> int | None:
2069
+ fee_summary = payload.get("fee_summary")
2070
+ if isinstance(fee_summary, dict):
2071
+ network_fee = _coerce_int(fee_summary.get("network_fee_lamports"))
2072
+ if network_fee is not None:
2073
+ return network_fee
2074
+ return None
2075
+
2076
+ def _default_swap_intent_max_fee_lamports(self, fee_summary: dict[str, Any]) -> int:
2077
+ estimated_fee = _coerce_int(fee_summary.get("network_fee_lamports")) or 0
2078
+ return max(
2079
+ estimated_fee * 3,
2080
+ estimated_fee + 100_000,
2081
+ SOLANA_SWAP_INTENT_DEFAULT_MAX_FEE_LAMPORTS,
2082
+ )
2083
+
2084
+ def _swap_minimum_output_floor(self, *, out_amount_raw: int, slippage_bps: int) -> int:
2085
+ if out_amount_raw <= 0:
2086
+ return 0
2087
+ if slippage_bps <= 0:
2088
+ raise WalletBackendError("slippage_bps must be greater than zero.")
2089
+ return max(1, (out_amount_raw * max(0, 10_000 - slippage_bps)) // 10_000)
2090
+
2065
2091
  def _require_mainnet_bags(self, feature: str) -> None:
2066
2092
  if self.network != "mainnet":
2067
2093
  raise WalletBackendError(f"{feature} is only enabled for Solana mainnet.")
@@ -3739,19 +3765,7 @@ class SolanaWalletBackend(AgentWalletBackend):
3739
3765
  keypair = Keypair.from_bytes(self.signer.export_keypair_bytes())
3740
3766
  try:
3741
3767
  unsigned_transaction = VersionedTransaction.from_bytes(raw_transaction)
3742
- signature = keypair.sign_message(to_bytes_versioned(unsigned_transaction.message))
3743
- signatures = list(unsigned_transaction.signatures)
3744
- if wallet_signer_index >= len(signatures):
3745
- raise WalletBackendError(
3746
- "Provider transaction signer layout is incompatible with local signing."
3747
- )
3748
- signatures[wallet_signer_index] = signature
3749
- signed_transaction = VersionedTransaction.populate(
3750
- unsigned_transaction.message,
3751
- signatures,
3752
- )
3753
- return encode_transaction_base64(bytes(signed_transaction))
3754
- except Exception:
3768
+ except (TypeError, ValueError):
3755
3769
  unsigned_transaction = Transaction.from_bytes(raw_transaction)
3756
3770
  signatures = list(unsigned_transaction.signatures)
3757
3771
  if wallet_signer_index >= len(signatures):
@@ -3763,6 +3777,18 @@ class SolanaWalletBackend(AgentWalletBackend):
3763
3777
  unsigned_transaction.message.recent_blockhash,
3764
3778
  )
3765
3779
  return encode_transaction_base64(bytes(unsigned_transaction))
3780
+ signature = keypair.sign_message(to_bytes_versioned(unsigned_transaction.message))
3781
+ signatures = list(unsigned_transaction.signatures)
3782
+ if wallet_signer_index >= len(signatures):
3783
+ raise WalletBackendError(
3784
+ "Provider transaction signer layout is incompatible with local signing."
3785
+ )
3786
+ signatures[wallet_signer_index] = signature
3787
+ signed_transaction = VersionedTransaction.populate(
3788
+ unsigned_transaction.message,
3789
+ signatures,
3790
+ )
3791
+ return encode_transaction_base64(bytes(signed_transaction))
3766
3792
 
3767
3793
  async def _prepare_jupiter_lend_transaction(
3768
3794
  self,
@@ -3821,9 +3847,11 @@ class SolanaWalletBackend(AgentWalletBackend):
3821
3847
  raise WalletBackendError(
3822
3848
  "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
3823
3849
  )
3850
+ kamino_verified = bool((prepared.get("kamino_safety") or {}).get("verified"))
3824
3851
  submitted = await solana_rpc.send_transaction(
3825
3852
  transaction_base64=str(prepared["transaction_base64"]),
3826
3853
  rpc_url=self.rpc_urls,
3854
+ skip_preflight=source == "kamino" and kamino_verified,
3827
3855
  )
3828
3856
  signature = submitted.get("signature")
3829
3857
  status = None
@@ -3832,6 +3860,8 @@ class SolanaWalletBackend(AgentWalletBackend):
3832
3860
  status = await solana_rpc.wait_for_confirmation(
3833
3861
  signature=signature,
3834
3862
  rpc_url=self.rpc_urls,
3863
+ timeout_seconds=60.0 if source == "kamino" else 20.0,
3864
+ poll_interval_seconds=2.0 if source == "kamino" else 1.0,
3835
3865
  )
3836
3866
  confirmed = status is not None
3837
3867
  return {
@@ -3849,6 +3879,8 @@ class SolanaWalletBackend(AgentWalletBackend):
3849
3879
  "slot": status.get("slot") if status else None,
3850
3880
  "sign_only": self.sign_only,
3851
3881
  "source": source,
3882
+ "simulation": prepared.get("simulation"),
3883
+ "kamino_safety": prepared.get("kamino_safety"),
3852
3884
  }
3853
3885
 
3854
3886
  async def _execute_prepared_jupiter_lend_transaction(self, prepared: dict[str, Any]) -> dict[str, Any]:
@@ -3899,6 +3931,37 @@ class SolanaWalletBackend(AgentWalletBackend):
3899
3931
  matches.append(item)
3900
3932
  return matches
3901
3933
 
3934
+ def _resolve_kamino_obligation_selection(
3935
+ self,
3936
+ *,
3937
+ obligations: list[Any],
3938
+ obligation_address: str | None,
3939
+ action: str,
3940
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
3941
+ candidates = [item for item in obligations if isinstance(item, dict)]
3942
+ requested = str(obligation_address or "").strip()
3943
+ if requested:
3944
+ requested = validate_solana_address(requested)
3945
+ for item in candidates:
3946
+ if (
3947
+ _kamino_entry_address(
3948
+ item,
3949
+ "obligationAddress",
3950
+ "obligation",
3951
+ "address",
3952
+ "pubkey",
3953
+ "loanId",
3954
+ )
3955
+ == requested
3956
+ ):
3957
+ return candidates, item
3958
+ raise WalletBackendError(
3959
+ f"Requested obligation_address is not available for Kamino {action} in the selected market."
3960
+ )
3961
+ if len(candidates) == 1:
3962
+ return candidates, candidates[0]
3963
+ return candidates, None
3964
+
3902
3965
  async def _prepare_kamino_lend_transaction(
3903
3966
  self,
3904
3967
  *,
@@ -3907,6 +3970,7 @@ class SolanaWalletBackend(AgentWalletBackend):
3907
3970
  market: str,
3908
3971
  reserve: str,
3909
3972
  amount_ui: str,
3973
+ obligation_address: str | None = None,
3910
3974
  ) -> dict[str, Any]:
3911
3975
  if not self.signer:
3912
3976
  raise WalletBackendError("Solana signer is not configured.")
@@ -3927,12 +3991,49 @@ class SolanaWalletBackend(AgentWalletBackend):
3927
3991
  market_address=market,
3928
3992
  reserve_address=reserve,
3929
3993
  action=f"Kamino {action}",
3994
+ obligation_address=obligation_address,
3930
3995
  loaded_addresses=loaded_addresses,
3931
3996
  )
3932
3997
  signed_transaction_base64 = await self._sign_versioned_provider_transaction(
3933
3998
  transaction_base64=transaction_base64,
3934
3999
  wallet_signer_index=int(verification.get("wallet_signer_index") or 0),
3935
4000
  )
4001
+ simulation_value: dict[str, Any] | None = None
4002
+ kamino_safety: dict[str, Any]
4003
+ try:
4004
+ simulation = await solana_rpc.simulate_transaction(
4005
+ transaction_base64=signed_transaction_base64,
4006
+ rpc_url=self.rpc_urls,
4007
+ commitment=self.commitment,
4008
+ )
4009
+ simulation_value = (
4010
+ simulation.get("value") if isinstance(simulation.get("value"), dict) else {}
4011
+ )
4012
+ if isinstance(simulation_value, dict) and simulation_value.get("err") is not None:
4013
+ raise WalletBackendError(
4014
+ f"Kamino {action} transaction simulation failed.",
4015
+ code="kamino_simulation_failed",
4016
+ details={
4017
+ "simulation": simulation_value,
4018
+ "action": action,
4019
+ "market": market,
4020
+ "reserve": reserve,
4021
+ },
4022
+ )
4023
+ kamino_safety = {
4024
+ "verified": True,
4025
+ "simulation_unavailable": False,
4026
+ }
4027
+ except ProviderError as exc:
4028
+ kamino_safety = {
4029
+ "verified": False,
4030
+ "simulation_unavailable": True,
4031
+ "warning": (
4032
+ "Kamino simulation could not be completed via the configured Solana RPC. "
4033
+ "Proceeding with structural provider verification only."
4034
+ ),
4035
+ "error": str(exc),
4036
+ }
3936
4037
  return {
3937
4038
  "chain": "solana",
3938
4039
  "network": self.network,
@@ -3941,6 +4042,7 @@ class SolanaWalletBackend(AgentWalletBackend):
3941
4042
  "owner": owner,
3942
4043
  "market": market,
3943
4044
  "reserve": reserve,
4045
+ "obligation_address": obligation_address,
3944
4046
  "amount_ui": amount_ui,
3945
4047
  "transaction_base64": signed_transaction_base64,
3946
4048
  "transaction_encoding": "base64",
@@ -3949,15 +4051,30 @@ class SolanaWalletBackend(AgentWalletBackend):
3949
4051
  "broadcasted": False,
3950
4052
  "confirmed": False,
3951
4053
  "verification": verification,
4054
+ "simulation": simulation_value,
4055
+ "kamino_safety": kamino_safety,
3952
4056
  "sign_only": self.sign_only,
3953
4057
  "source": "kamino",
3954
4058
  }
3955
4059
 
4060
+ def _kamino_preview_from_approved(
4061
+ self,
4062
+ approved_preview: dict[str, Any] | None,
4063
+ *,
4064
+ asset_type: str,
4065
+ ) -> dict[str, Any] | None:
4066
+ if not isinstance(approved_preview, dict):
4067
+ return None
4068
+ if str(approved_preview.get("asset_type") or "").strip() != asset_type:
4069
+ return None
4070
+ return dict(approved_preview)
4071
+
3956
4072
  async def preview_kamino_lend_deposit(
3957
4073
  self,
3958
4074
  market: str,
3959
4075
  reserve: str,
3960
4076
  amount_ui: str,
4077
+ obligation_address: str | None = None,
3961
4078
  ) -> dict[str, Any]:
3962
4079
  self._require_mainnet_kamino("Kamino lending")
3963
4080
  owner = await self.get_address()
@@ -3995,8 +4112,13 @@ class SolanaWalletBackend(AgentWalletBackend):
3995
4112
  market: str,
3996
4113
  reserve: str,
3997
4114
  amount_ui: str,
4115
+ obligation_address: str | None = None,
4116
+ approved_preview: dict[str, Any] | None = None,
3998
4117
  ) -> dict[str, Any]:
3999
- preview = await self.preview_kamino_lend_deposit(
4118
+ preview = self._kamino_preview_from_approved(
4119
+ approved_preview,
4120
+ asset_type="kamino-lend-deposit",
4121
+ ) or await self.preview_kamino_lend_deposit(
4000
4122
  market=market,
4001
4123
  reserve=reserve,
4002
4124
  amount_ui=amount_ui,
@@ -4023,11 +4145,14 @@ class SolanaWalletBackend(AgentWalletBackend):
4023
4145
  market: str,
4024
4146
  reserve: str,
4025
4147
  amount_ui: str,
4148
+ obligation_address: str | None = None,
4149
+ approved_preview: dict[str, Any] | None = None,
4026
4150
  ) -> dict[str, Any]:
4027
4151
  prepared = await self.prepare_kamino_lend_deposit(
4028
4152
  market=market,
4029
4153
  reserve=reserve,
4030
4154
  amount_ui=amount_ui,
4155
+ approved_preview=approved_preview,
4031
4156
  )
4032
4157
  result = await self._execute_prepared_provider_transaction(prepared, source="kamino")
4033
4158
  result["build_response"] = prepared.get("build_response")
@@ -4038,6 +4163,7 @@ class SolanaWalletBackend(AgentWalletBackend):
4038
4163
  market: str,
4039
4164
  reserve: str,
4040
4165
  amount_ui: str,
4166
+ obligation_address: str | None = None,
4041
4167
  ) -> dict[str, Any]:
4042
4168
  self._require_mainnet_kamino("Kamino lending")
4043
4169
  owner = await self.get_address()
@@ -4062,6 +4188,19 @@ class SolanaWalletBackend(AgentWalletBackend):
4062
4188
  )
4063
4189
  if not obligation_matches:
4064
4190
  raise WalletBackendError("No Kamino obligation found for the requested reserve.")
4191
+ obligation_options, selected_obligation = self._resolve_kamino_obligation_selection(
4192
+ obligations=obligation_matches,
4193
+ obligation_address=obligation_address,
4194
+ action="withdraw",
4195
+ )
4196
+ selected_obligation_address = _kamino_entry_address(
4197
+ selected_obligation,
4198
+ "obligationAddress",
4199
+ "obligation",
4200
+ "address",
4201
+ "pubkey",
4202
+ "loanId",
4203
+ )
4065
4204
  return {
4066
4205
  "chain": "solana",
4067
4206
  "network": self.network,
@@ -4072,7 +4211,14 @@ class SolanaWalletBackend(AgentWalletBackend):
4072
4211
  "reserve": reserve,
4073
4212
  "amount_ui": amount_ui,
4074
4213
  "reserve_info": reserve_entry,
4075
- "obligations": obligation_matches,
4214
+ "obligations": obligation_options,
4215
+ "obligation_options": [
4216
+ _kamino_entry_address(item, "obligationAddress", "obligation", "address", "pubkey", "loanId")
4217
+ for item in obligation_options
4218
+ if _kamino_entry_address(item, "obligationAddress", "obligation", "address", "pubkey", "loanId")
4219
+ ],
4220
+ "obligation_address": selected_obligation_address or None,
4221
+ "requires_obligation_address": selected_obligation is None and len(obligation_options) > 1,
4076
4222
  "sign_only": self.sign_only,
4077
4223
  "can_send": self.get_capabilities().can_send_transaction,
4078
4224
  "source": "kamino",
@@ -4083,12 +4229,23 @@ class SolanaWalletBackend(AgentWalletBackend):
4083
4229
  market: str,
4084
4230
  reserve: str,
4085
4231
  amount_ui: str,
4232
+ obligation_address: str | None = None,
4233
+ approved_preview: dict[str, Any] | None = None,
4086
4234
  ) -> dict[str, Any]:
4087
- preview = await self.preview_kamino_lend_withdraw(
4235
+ preview = self._kamino_preview_from_approved(
4236
+ approved_preview,
4237
+ asset_type="kamino-lend-withdraw",
4238
+ ) or await self.preview_kamino_lend_withdraw(
4088
4239
  market=market,
4089
4240
  reserve=reserve,
4090
4241
  amount_ui=amount_ui,
4242
+ obligation_address=obligation_address,
4091
4243
  )
4244
+ selected_obligation_address = str(preview.get("obligation_address") or "").strip()
4245
+ if bool(preview.get("requires_obligation_address")) and not selected_obligation_address:
4246
+ raise WalletBackendError(
4247
+ "Kamino withdraw requires obligation_address when multiple obligations match the selected market/reserve."
4248
+ )
4092
4249
  owner = str(preview["owner"])
4093
4250
  build = await kamino.build_lend_withdraw_transaction(
4094
4251
  wallet=owner,
@@ -4102,6 +4259,7 @@ class SolanaWalletBackend(AgentWalletBackend):
4102
4259
  market=str(preview["market"]),
4103
4260
  reserve=str(preview["reserve"]),
4104
4261
  amount_ui=str(preview["amount_ui"]),
4262
+ obligation_address=selected_obligation_address or None,
4105
4263
  )
4106
4264
  prepared["build_response"] = build
4107
4265
  return prepared
@@ -4111,11 +4269,15 @@ class SolanaWalletBackend(AgentWalletBackend):
4111
4269
  market: str,
4112
4270
  reserve: str,
4113
4271
  amount_ui: str,
4272
+ obligation_address: str | None = None,
4273
+ approved_preview: dict[str, Any] | None = None,
4114
4274
  ) -> dict[str, Any]:
4115
4275
  prepared = await self.prepare_kamino_lend_withdraw(
4116
4276
  market=market,
4117
4277
  reserve=reserve,
4118
4278
  amount_ui=amount_ui,
4279
+ obligation_address=obligation_address,
4280
+ approved_preview=approved_preview,
4119
4281
  )
4120
4282
  result = await self._execute_prepared_provider_transaction(prepared, source="kamino")
4121
4283
  result["build_response"] = prepared.get("build_response")
@@ -4126,6 +4288,7 @@ class SolanaWalletBackend(AgentWalletBackend):
4126
4288
  market: str,
4127
4289
  reserve: str,
4128
4290
  amount_ui: str,
4291
+ obligation_address: str | None = None,
4129
4292
  ) -> dict[str, Any]:
4130
4293
  self._require_mainnet_kamino("Kamino lending")
4131
4294
  owner = await self.get_address()
@@ -4146,6 +4309,19 @@ class SolanaWalletBackend(AgentWalletBackend):
4146
4309
  obligations = await self.get_kamino_lend_user_obligations(market=market, user=owner)
4147
4310
  if int(obligations["obligation_count"]) <= 0:
4148
4311
  raise WalletBackendError("Kamino borrow requires an existing obligation in the selected market.")
4312
+ obligation_options, selected_obligation = self._resolve_kamino_obligation_selection(
4313
+ obligations=list(obligations["obligations"]),
4314
+ obligation_address=obligation_address,
4315
+ action="borrow",
4316
+ )
4317
+ selected_obligation_address = _kamino_entry_address(
4318
+ selected_obligation,
4319
+ "obligationAddress",
4320
+ "obligation",
4321
+ "address",
4322
+ "pubkey",
4323
+ "loanId",
4324
+ )
4149
4325
  return {
4150
4326
  "chain": "solana",
4151
4327
  "network": self.network,
@@ -4156,7 +4332,14 @@ class SolanaWalletBackend(AgentWalletBackend):
4156
4332
  "reserve": reserve,
4157
4333
  "amount_ui": amount_ui,
4158
4334
  "reserve_info": reserve_entry,
4159
- "obligations": obligations["obligations"],
4335
+ "obligations": obligation_options,
4336
+ "obligation_options": [
4337
+ _kamino_entry_address(item, "obligationAddress", "obligation", "address", "pubkey", "loanId")
4338
+ for item in obligation_options
4339
+ if _kamino_entry_address(item, "obligationAddress", "obligation", "address", "pubkey", "loanId")
4340
+ ],
4341
+ "obligation_address": selected_obligation_address or None,
4342
+ "requires_obligation_address": selected_obligation is None and len(obligation_options) > 1,
4160
4343
  "sign_only": self.sign_only,
4161
4344
  "can_send": self.get_capabilities().can_send_transaction,
4162
4345
  "source": "kamino",
@@ -4167,12 +4350,23 @@ class SolanaWalletBackend(AgentWalletBackend):
4167
4350
  market: str,
4168
4351
  reserve: str,
4169
4352
  amount_ui: str,
4353
+ obligation_address: str | None = None,
4354
+ approved_preview: dict[str, Any] | None = None,
4170
4355
  ) -> dict[str, Any]:
4171
- preview = await self.preview_kamino_lend_borrow(
4356
+ preview = self._kamino_preview_from_approved(
4357
+ approved_preview,
4358
+ asset_type="kamino-lend-borrow",
4359
+ ) or await self.preview_kamino_lend_borrow(
4172
4360
  market=market,
4173
4361
  reserve=reserve,
4174
4362
  amount_ui=amount_ui,
4363
+ obligation_address=obligation_address,
4175
4364
  )
4365
+ selected_obligation_address = str(preview.get("obligation_address") or "").strip()
4366
+ if bool(preview.get("requires_obligation_address")) and not selected_obligation_address:
4367
+ raise WalletBackendError(
4368
+ "Kamino borrow requires obligation_address when multiple obligations exist in the selected market."
4369
+ )
4176
4370
  owner = str(preview["owner"])
4177
4371
  build = await kamino.build_lend_borrow_transaction(
4178
4372
  wallet=owner,
@@ -4186,6 +4380,7 @@ class SolanaWalletBackend(AgentWalletBackend):
4186
4380
  market=str(preview["market"]),
4187
4381
  reserve=str(preview["reserve"]),
4188
4382
  amount_ui=str(preview["amount_ui"]),
4383
+ obligation_address=selected_obligation_address or None,
4189
4384
  )
4190
4385
  prepared["build_response"] = build
4191
4386
  return prepared
@@ -4195,11 +4390,15 @@ class SolanaWalletBackend(AgentWalletBackend):
4195
4390
  market: str,
4196
4391
  reserve: str,
4197
4392
  amount_ui: str,
4393
+ obligation_address: str | None = None,
4394
+ approved_preview: dict[str, Any] | None = None,
4198
4395
  ) -> dict[str, Any]:
4199
4396
  prepared = await self.prepare_kamino_lend_borrow(
4200
4397
  market=market,
4201
4398
  reserve=reserve,
4202
4399
  amount_ui=amount_ui,
4400
+ obligation_address=obligation_address,
4401
+ approved_preview=approved_preview,
4203
4402
  )
4204
4403
  result = await self._execute_prepared_provider_transaction(prepared, source="kamino")
4205
4404
  result["build_response"] = prepared.get("build_response")
@@ -4210,6 +4409,7 @@ class SolanaWalletBackend(AgentWalletBackend):
4210
4409
  market: str,
4211
4410
  reserve: str,
4212
4411
  amount_ui: str,
4412
+ obligation_address: str | None = None,
4213
4413
  ) -> dict[str, Any]:
4214
4414
  self._require_mainnet_kamino("Kamino lending")
4215
4415
  owner = await self.get_address()
@@ -4234,6 +4434,19 @@ class SolanaWalletBackend(AgentWalletBackend):
4234
4434
  )
4235
4435
  if not obligation_matches:
4236
4436
  raise WalletBackendError("No Kamino debt position found for the requested reserve.")
4437
+ obligation_options, selected_obligation = self._resolve_kamino_obligation_selection(
4438
+ obligations=obligation_matches,
4439
+ obligation_address=obligation_address,
4440
+ action="repay",
4441
+ )
4442
+ selected_obligation_address = _kamino_entry_address(
4443
+ selected_obligation,
4444
+ "obligationAddress",
4445
+ "obligation",
4446
+ "address",
4447
+ "pubkey",
4448
+ "loanId",
4449
+ )
4237
4450
  return {
4238
4451
  "chain": "solana",
4239
4452
  "network": self.network,
@@ -4244,7 +4457,14 @@ class SolanaWalletBackend(AgentWalletBackend):
4244
4457
  "reserve": reserve,
4245
4458
  "amount_ui": amount_ui,
4246
4459
  "reserve_info": reserve_entry,
4247
- "obligations": obligation_matches,
4460
+ "obligations": obligation_options,
4461
+ "obligation_options": [
4462
+ _kamino_entry_address(item, "obligationAddress", "obligation", "address", "pubkey", "loanId")
4463
+ for item in obligation_options
4464
+ if _kamino_entry_address(item, "obligationAddress", "obligation", "address", "pubkey", "loanId")
4465
+ ],
4466
+ "obligation_address": selected_obligation_address or None,
4467
+ "requires_obligation_address": selected_obligation is None and len(obligation_options) > 1,
4248
4468
  "sign_only": self.sign_only,
4249
4469
  "can_send": self.get_capabilities().can_send_transaction,
4250
4470
  "source": "kamino",
@@ -4255,12 +4475,23 @@ class SolanaWalletBackend(AgentWalletBackend):
4255
4475
  market: str,
4256
4476
  reserve: str,
4257
4477
  amount_ui: str,
4478
+ obligation_address: str | None = None,
4479
+ approved_preview: dict[str, Any] | None = None,
4258
4480
  ) -> dict[str, Any]:
4259
- preview = await self.preview_kamino_lend_repay(
4481
+ preview = self._kamino_preview_from_approved(
4482
+ approved_preview,
4483
+ asset_type="kamino-lend-repay",
4484
+ ) or await self.preview_kamino_lend_repay(
4260
4485
  market=market,
4261
4486
  reserve=reserve,
4262
4487
  amount_ui=amount_ui,
4488
+ obligation_address=obligation_address,
4263
4489
  )
4490
+ selected_obligation_address = str(preview.get("obligation_address") or "").strip()
4491
+ if bool(preview.get("requires_obligation_address")) and not selected_obligation_address:
4492
+ raise WalletBackendError(
4493
+ "Kamino repay requires obligation_address when multiple debt obligations match the selected market/reserve."
4494
+ )
4264
4495
  owner = str(preview["owner"])
4265
4496
  build = await kamino.build_lend_repay_transaction(
4266
4497
  wallet=owner,
@@ -4274,6 +4505,7 @@ class SolanaWalletBackend(AgentWalletBackend):
4274
4505
  market=str(preview["market"]),
4275
4506
  reserve=str(preview["reserve"]),
4276
4507
  amount_ui=str(preview["amount_ui"]),
4508
+ obligation_address=selected_obligation_address or None,
4277
4509
  )
4278
4510
  prepared["build_response"] = build
4279
4511
  return prepared
@@ -4283,11 +4515,15 @@ class SolanaWalletBackend(AgentWalletBackend):
4283
4515
  market: str,
4284
4516
  reserve: str,
4285
4517
  amount_ui: str,
4518
+ obligation_address: str | None = None,
4519
+ approved_preview: dict[str, Any] | None = None,
4286
4520
  ) -> dict[str, Any]:
4287
4521
  prepared = await self.prepare_kamino_lend_repay(
4288
4522
  market=market,
4289
4523
  reserve=reserve,
4290
4524
  amount_ui=amount_ui,
4525
+ obligation_address=obligation_address,
4526
+ approved_preview=approved_preview,
4291
4527
  )
4292
4528
  result = await self._execute_prepared_provider_transaction(prepared, source="kamino")
4293
4529
  result["build_response"] = prepared.get("build_response")
@@ -5426,7 +5662,8 @@ class SolanaWalletBackend(AgentWalletBackend):
5426
5662
  input_mint: str,
5427
5663
  output_mint: str,
5428
5664
  amount_ui: float,
5429
- slippage_bps: int = 50,
5665
+ slippage_bps: int = SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS,
5666
+ exclude_routers: list[str] | None = None,
5430
5667
  ) -> dict[str, Any]:
5431
5668
  if self.network != "mainnet":
5432
5669
  raise WalletBackendError("Provider-routed swaps are only enabled for Solana mainnet.")
@@ -5447,29 +5684,38 @@ class SolanaWalletBackend(AgentWalletBackend):
5447
5684
  raise WalletBackendError("amount is too small for the input token decimals.")
5448
5685
 
5449
5686
  sender = await self.get_address()
5450
- quote_source = "jupiter-ultra"
5687
+ quote_source = "jupiter-v2-order"
5451
5688
  try:
5452
- quote = await jupiter.fetch_ultra_order(
5689
+ quote = await jupiter.fetch_swap_v2_order(
5453
5690
  input_mint=input_mint,
5454
5691
  output_mint=output_mint,
5455
5692
  amount_raw=raw_amount,
5456
5693
  taker=sender,
5457
- slippage_bps=slippage_bps,
5694
+ exclude_routers=exclude_routers,
5458
5695
  )
5459
5696
  except ProviderError:
5460
- quote = await jupiter.fetch_quote(
5461
- input_mint=input_mint,
5462
- output_mint=output_mint,
5463
- amount_raw=raw_amount,
5464
- slippage_bps=slippage_bps,
5465
- )
5466
- quote_source = "jupiter-metis"
5697
+ quote_source = "jupiter-ultra"
5698
+ try:
5699
+ quote = await jupiter.fetch_ultra_order(
5700
+ input_mint=input_mint,
5701
+ output_mint=output_mint,
5702
+ amount_raw=raw_amount,
5703
+ taker=sender,
5704
+ slippage_bps=slippage_bps,
5705
+ )
5706
+ except ProviderError:
5707
+ quote = await jupiter.fetch_quote(
5708
+ input_mint=input_mint,
5709
+ output_mint=output_mint,
5710
+ amount_raw=raw_amount,
5711
+ slippage_bps=slippage_bps,
5712
+ )
5713
+ quote_source = "jupiter-metis"
5467
5714
 
5468
5715
  out_amount_raw = int(quote.get("outAmount") or 0)
5469
- other_threshold_raw = int(
5470
- quote.get("otherAmountThreshold")
5471
- or quote.get("minOutAmount")
5472
- or 0
5716
+ other_threshold_raw = self._swap_minimum_output_floor(
5717
+ out_amount_raw=out_amount_raw,
5718
+ slippage_bps=slippage_bps,
5473
5719
  )
5474
5720
  fee_summary = self._build_swap_fee_summary(
5475
5721
  swap_provider=quote_source,
@@ -5505,12 +5751,115 @@ class SolanaWalletBackend(AgentWalletBackend):
5505
5751
  "source": quote_source,
5506
5752
  }
5507
5753
 
5754
+ async def preview_swap_intent(
5755
+ self,
5756
+ input_mint: str,
5757
+ output_mint: str,
5758
+ amount_ui: float,
5759
+ slippage_bps: int = SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS,
5760
+ minimum_output_amount_raw: int | None = None,
5761
+ max_fee_lamports: int | None = None,
5762
+ valid_for_seconds: int = 120,
5763
+ max_attempts: int = 3,
5764
+ ) -> dict[str, Any]:
5765
+ if valid_for_seconds <= 0 or valid_for_seconds > 120:
5766
+ raise WalletBackendError("valid_for_seconds must be between 1 and 120.")
5767
+ if max_attempts <= 0 or max_attempts > 5:
5768
+ raise WalletBackendError("max_attempts must be between 1 and 5.")
5769
+ slippage_bps = max(int(slippage_bps), SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS)
5770
+ max_attempts = max(int(max_attempts), 3)
5771
+
5772
+ indicative = await self.preview_swap(
5773
+ input_mint=input_mint,
5774
+ output_mint=output_mint,
5775
+ amount_ui=amount_ui,
5776
+ slippage_bps=slippage_bps,
5777
+ )
5778
+ indicative_output_raw = int(indicative.get("estimated_output_amount_raw") or 0)
5779
+ slippage_floor_raw = self._swap_minimum_output_floor(
5780
+ out_amount_raw=indicative_output_raw,
5781
+ slippage_bps=slippage_bps,
5782
+ )
5783
+ requested_min_output_raw = (
5784
+ int(minimum_output_amount_raw)
5785
+ if minimum_output_amount_raw is not None
5786
+ else None
5787
+ )
5788
+ if requested_min_output_raw is not None:
5789
+ min_output_raw = min(requested_min_output_raw, slippage_floor_raw)
5790
+ minimum_output_policy = (
5791
+ "explicit_clamped_to_slippage_floor"
5792
+ if requested_min_output_raw > slippage_floor_raw
5793
+ else "explicit"
5794
+ )
5795
+ else:
5796
+ min_output_raw = slippage_floor_raw
5797
+ minimum_output_policy = "slippage_floor"
5798
+ if min_output_raw <= 0:
5799
+ raise WalletBackendError("minimum_output_amount_raw could not be derived from the indicative quote.")
5800
+ output_decimals = int(indicative.get("output_decimals") or 0)
5801
+ min_output_ui = min_output_raw / (10**output_decimals)
5802
+
5803
+ fee_summary = (
5804
+ indicative.get("fee_summary")
5805
+ if isinstance(indicative.get("fee_summary"), dict)
5806
+ else {}
5807
+ )
5808
+ fee_limit = (
5809
+ int(max_fee_lamports)
5810
+ if max_fee_lamports is not None
5811
+ else self._default_swap_intent_max_fee_lamports(fee_summary)
5812
+ )
5813
+ if fee_limit < 0:
5814
+ raise WalletBackendError("max_fee_lamports must be non-negative.")
5815
+
5816
+ return {
5817
+ "chain": "solana",
5818
+ "network": self.network,
5819
+ "mode": "intent_preview",
5820
+ "asset_type": "solana-swap-intent",
5821
+ "owner": indicative.get("owner"),
5822
+ "input_mint": indicative["input_mint"],
5823
+ "output_mint": indicative["output_mint"],
5824
+ "input_amount_ui": indicative["input_amount_ui"],
5825
+ "input_amount_raw": indicative["input_amount_raw"],
5826
+ "input_decimals": indicative.get("input_decimals"),
5827
+ "output_decimals": indicative.get("output_decimals"),
5828
+ "indicative_output_amount_ui": indicative.get("estimated_output_amount_ui"),
5829
+ "indicative_output_amount_raw": indicative.get("estimated_output_amount_raw"),
5830
+ "minimum_output_amount_ui": min_output_ui,
5831
+ "minimum_output_amount_raw": min_output_raw,
5832
+ "requested_minimum_output_amount_raw": requested_min_output_raw,
5833
+ "minimum_output_policy": minimum_output_policy,
5834
+ "max_slippage_bps": slippage_bps,
5835
+ "slippage_bps": slippage_bps,
5836
+ "max_fee_lamports": fee_limit,
5837
+ "max_fee_sol": fee_limit / solana_rpc.LAMPORTS_PER_SOL,
5838
+ "valid_for_seconds": valid_for_seconds,
5839
+ "valid_until_epoch_seconds": int(time.time()) + valid_for_seconds,
5840
+ "max_attempts": max_attempts,
5841
+ "allowed_providers": ["jupiter-v2-order", "jupiter-ultra", "jupiter-metis"],
5842
+ "recipient_policy": "owner-only",
5843
+ "spend_policy": "exact-input",
5844
+ "indicative_swap_provider": indicative.get("swap_provider"),
5845
+ "indicative_price_impact_pct": indicative.get("price_impact_pct"),
5846
+ "indicative_route_plan": indicative.get("route_plan", []),
5847
+ "indicative_fee_summary": fee_summary,
5848
+ "intent_note": (
5849
+ "This is an intent approval preview. Execute will fetch a fresh quote and "
5850
+ "only sign/send if it remains inside these approved limits."
5851
+ ),
5852
+ "can_send": self.get_capabilities().can_send_transaction,
5853
+ "sign_only": self.sign_only,
5854
+ "source": "swap-intent",
5855
+ }
5856
+
5508
5857
  async def execute_swap(
5509
5858
  self,
5510
5859
  input_mint: str,
5511
5860
  output_mint: str,
5512
5861
  amount_ui: float,
5513
- slippage_bps: int = 50,
5862
+ slippage_bps: int = SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS,
5514
5863
  ) -> dict[str, Any]:
5515
5864
  preview = await self.preview_swap(
5516
5865
  input_mint=input_mint,
@@ -5520,17 +5869,23 @@ class SolanaWalletBackend(AgentWalletBackend):
5520
5869
  )
5521
5870
  return await self.execute_swap_from_preview(preview)
5522
5871
 
5523
- async def execute_swap_from_preview(
5872
+ async def _submit_prepared_swap(
5524
5873
  self,
5525
- preview: dict[str, Any],
5874
+ prepared: dict[str, Any],
5526
5875
  ) -> dict[str, Any]:
5527
- prepared = await self.prepare_swap_from_preview(preview)
5528
5876
  if self.sign_only:
5529
5877
  raise WalletBackendError(
5530
5878
  "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
5531
5879
  )
5532
5880
 
5533
- if prepared.get("swap_provider") == "jupiter-ultra":
5881
+ if prepared.get("swap_provider") == "jupiter-v2-order":
5882
+ submitted = await jupiter.execute_swap_v2_order(
5883
+ signed_transaction_base64=str(prepared["transaction_base64"]),
5884
+ request_id=str(prepared["request_id"]),
5885
+ last_valid_block_height=_coerce_int(prepared.get("last_valid_block_height")),
5886
+ )
5887
+ onchain_signature = submitted.get("signature") or submitted.get("txid")
5888
+ elif prepared.get("swap_provider") == "jupiter-ultra":
5534
5889
  submitted = await jupiter.execute_ultra_order(
5535
5890
  signed_transaction_base64=str(prepared["transaction_base64"]),
5536
5891
  request_id=str(prepared["request_id"]),
@@ -5583,12 +5938,132 @@ class SolanaWalletBackend(AgentWalletBackend):
5583
5938
  "source": prepared.get("swap_provider") or "jupiter-metis",
5584
5939
  }
5585
5940
 
5941
+ async def execute_swap_from_preview(
5942
+ self,
5943
+ preview: dict[str, Any],
5944
+ ) -> dict[str, Any]:
5945
+ prepared = await self.prepare_swap_from_preview(preview)
5946
+ return await self._submit_prepared_swap(prepared)
5947
+
5948
+ async def execute_swap_intent(
5949
+ self,
5950
+ *,
5951
+ input_mint: str,
5952
+ output_mint: str,
5953
+ amount_ui: float,
5954
+ slippage_bps: int = SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS,
5955
+ minimum_output_amount_raw: int | None = None,
5956
+ max_fee_lamports: int | None = None,
5957
+ valid_until_epoch_seconds: int | None = None,
5958
+ max_attempts: int = 3,
5959
+ ) -> dict[str, Any]:
5960
+ if valid_until_epoch_seconds is not None and int(time.time()) > int(valid_until_epoch_seconds):
5961
+ raise WalletBackendError("Approved swap intent has expired. Create a fresh intent preview.")
5962
+ if max_attempts <= 0 or max_attempts > 5:
5963
+ raise WalletBackendError("max_attempts must be between 1 and 5.")
5964
+ slippage_bps = max(int(slippage_bps), SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS)
5965
+ max_attempts = max(int(max_attempts), 3)
5966
+
5967
+ attempts: list[dict[str, Any]] = []
5968
+ last_error: str | None = None
5969
+ for attempt_index in range(max_attempts):
5970
+ if valid_until_epoch_seconds is not None and int(time.time()) > int(valid_until_epoch_seconds):
5971
+ break
5972
+ try:
5973
+ exclude_routers = ["jupiterz"] if attempt_index > 0 else None
5974
+ preview = await self.preview_swap(
5975
+ input_mint=input_mint,
5976
+ output_mint=output_mint,
5977
+ amount_ui=amount_ui,
5978
+ slippage_bps=slippage_bps,
5979
+ exclude_routers=exclude_routers,
5980
+ )
5981
+ estimated_output_raw = int(preview.get("estimated_output_amount_raw") or 0)
5982
+ if (
5983
+ minimum_output_amount_raw is not None
5984
+ and estimated_output_raw < int(minimum_output_amount_raw)
5985
+ ):
5986
+ attempts.append(
5987
+ {
5988
+ "attempt": attempt_index + 1,
5989
+ "swap_provider": preview.get("swap_provider"),
5990
+ "rejected": "quote_below_minimum_output",
5991
+ "estimated_output_amount_raw": estimated_output_raw,
5992
+ "minimum_output_amount_raw": int(minimum_output_amount_raw),
5993
+ }
5994
+ )
5995
+ last_error = "Fresh swap quote is below the approved minimum output."
5996
+ continue
5997
+
5998
+ prepared = await self.prepare_swap_from_preview(preview)
5999
+ prepared_fee = self._swap_fee_lamports(prepared)
6000
+ if (
6001
+ max_fee_lamports is not None
6002
+ and prepared_fee is not None
6003
+ and prepared_fee > int(max_fee_lamports)
6004
+ ):
6005
+ attempts.append(
6006
+ {
6007
+ "attempt": attempt_index + 1,
6008
+ "swap_provider": prepared.get("swap_provider"),
6009
+ "rejected": "fee_above_limit",
6010
+ "fee_lamports": prepared_fee,
6011
+ "max_fee_lamports": int(max_fee_lamports),
6012
+ }
6013
+ )
6014
+ last_error = "Fresh swap fee exceeds the approved fee limit."
6015
+ continue
6016
+
6017
+ result = await self._submit_prepared_swap(prepared)
6018
+ result["intent_execution"] = {
6019
+ "approved_minimum_output_amount_raw": minimum_output_amount_raw,
6020
+ "approved_max_fee_lamports": max_fee_lamports,
6021
+ "fresh_quote_used": True,
6022
+ "attempt_count": attempt_index + 1,
6023
+ "max_attempts": max_attempts,
6024
+ "attempts": attempts
6025
+ + [
6026
+ {
6027
+ "attempt": attempt_index + 1,
6028
+ "swap_provider": prepared.get("swap_provider"),
6029
+ "status": "submitted",
6030
+ }
6031
+ ],
6032
+ }
6033
+ return result
6034
+ except (WalletBackendError, ProviderError) as exc:
6035
+ last_error = str(exc)
6036
+ attempts.append(
6037
+ {
6038
+ "attempt": attempt_index + 1,
6039
+ "rejected": "execution_error",
6040
+ "error": str(exc),
6041
+ }
6042
+ )
6043
+ if "sign-only mode" in str(exc).lower():
6044
+ break
6045
+ if attempt_index + 1 < max_attempts:
6046
+ await asyncio.sleep(min(0.5 * (attempt_index + 1), 1.5))
6047
+
6048
+ reason_suffix = f" Last reason: {last_error}" if last_error else ""
6049
+ raise WalletBackendError(
6050
+ "Solana swap intent execution failed within the approved limits. Funds were not moved."
6051
+ + reason_suffix,
6052
+ details={
6053
+ "reason": last_error,
6054
+ "attempts": attempts,
6055
+ "minimum_output_amount_raw": minimum_output_amount_raw,
6056
+ "max_fee_lamports": max_fee_lamports,
6057
+ "max_attempts": max_attempts,
6058
+ },
6059
+ )
6060
+
5586
6061
  async def prepare_swap(
5587
6062
  self,
5588
6063
  input_mint: str,
5589
6064
  output_mint: str,
5590
6065
  amount_ui: float,
5591
- slippage_bps: int = 50,
6066
+ slippage_bps: int = SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS,
5592
6067
  ) -> dict[str, Any]:
5593
6068
  preview = await self.preview_swap(
5594
6069
  input_mint=input_mint,
@@ -5629,13 +6104,23 @@ class SolanaWalletBackend(AgentWalletBackend):
5629
6104
 
5630
6105
  swap_provider = str(preview.get("swap_provider") or "jupiter-metis")
5631
6106
  request_id = None
5632
- if swap_provider == "jupiter-ultra":
6107
+ if swap_provider in {"jupiter-v2-order", "jupiter-ultra"}:
5633
6108
  swap_build = preview["quote_response"]
5634
6109
  unsigned_transaction = VersionedTransaction.from_bytes(
5635
6110
  base64.b64decode(str(swap_build["transaction"]))
5636
6111
  )
5637
6112
  request_id = swap_build.get("requestId")
5638
- last_valid_block_height = swap_build.get("expireAt")
6113
+ blockhash_metadata = swap_build.get("blockhashWithMetadata")
6114
+ last_valid_block_height = (
6115
+ blockhash_metadata.get("lastValidBlockHeight")
6116
+ if isinstance(blockhash_metadata, dict)
6117
+ else None
6118
+ )
6119
+ if last_valid_block_height is None:
6120
+ last_valid_block_height = (
6121
+ swap_build.get("lastValidBlockHeight")
6122
+ or swap_build.get("expireAt")
6123
+ )
5639
6124
  prioritization_fee_lamports = swap_build.get("prioritizationFeeLamports")
5640
6125
  compute_unit_limit = swap_build.get("computeUnitLimit")
5641
6126
  else: