@agentlayer.tech/wallet 0.1.25 → 0.1.26

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.
@@ -17,7 +17,7 @@ In practice this means the agent works through explicit tools for:
17
17
  - EVM native balance, ERC-20 balance/metadata, fee-rate, receipt, Velora swap quote/execute, Aave V3 account/reserve/position flows, and transfer flows through the local `wdk-evm-wallet` backend
18
18
  - wallet address, balances, and portfolio reads
19
19
  - native SOL and SPL token transfers
20
- - Jupiter swap and price lookup
20
+ - Jupiter swap and price lookup, including Solana swap intent execution that refreshes quotes inside user-approved limits
21
21
  - Jupiter Earn read/deposit/withdraw flows
22
22
  - Kamino lending read/deposit/withdraw/borrow/repay flows
23
23
  - native Solana staking, stake deactivation, and stake withdrawal
@@ -76,7 +76,7 @@ function cachePreviewForApproval(userId, toolName, payload) {
76
76
  const cacheToolName = approvalPreviewToolName(toolName);
77
77
  if (!payload || payload.ok !== true || !payload.data || typeof payload.data !== "object") return;
78
78
  const approvalSource = payload.data;
79
- if (!["preview", "prepare"].includes(String(approvalSource.mode || ""))) return;
79
+ if (!["preview", "prepare", "intent_preview"].includes(String(approvalSource.mode || ""))) return;
80
80
  if (!approvalSource.confirmation_summary || typeof approvalSource.confirmation_summary !== "object") return;
81
81
  pruneApprovalPreviewCache();
82
82
  const digest = previewDigest(approvalSource);
@@ -119,7 +119,21 @@ function cachedPreviewForToken(userId, toolName, token) {
119
119
  return cached.preview && typeof cached.preview === "object" ? cached.preview : null;
120
120
  }
121
121
 
122
- function requiresApprovedPreviewPayload(toolName) {
122
+ function isSolanaSwapIntentPayload(payload) {
123
+ return (
124
+ payload &&
125
+ typeof payload === "object" &&
126
+ (String(payload.asset_type || "") === "solana-swap-intent" ||
127
+ String(payload.mode || "") === "intent_preview")
128
+ );
129
+ }
130
+
131
+ function isSolanaSwapIntentExecute(params) {
132
+ return String(params?.mode || "") === "intent_execute";
133
+ }
134
+
135
+ function requiresApprovedPreviewPayload(toolName, params = null) {
136
+ if (toolName === "swap_solana_tokens" && isSolanaSwapIntentExecute(params)) return false;
123
137
  return PREVIEW_BOUND_SWAP_TOOLS.has(toolName);
124
138
  }
125
139
 
@@ -536,7 +550,7 @@ async function issueApprovalToken(api, config, userId, toolName, previewPayload)
536
550
  if (!summary || typeof summary !== "object") {
537
551
  throw new Error(`No confirmation_summary available for ${toolName}.`);
538
552
  }
539
- const summaryForToken = PREVIEW_BOUND_SWAP_TOOLS.has(toolName)
553
+ const summaryForToken = PREVIEW_BOUND_SWAP_TOOLS.has(toolName) && !isSolanaSwapIntentPayload(previewPayload)
540
554
  ? { ...summary, _preview_digest: previewDigest(previewPayload) }
541
555
  : { ...summary };
542
556
  const extraArgs = [
@@ -560,7 +574,12 @@ async function issueApprovalToken(api, config, userId, toolName, previewPayload)
560
574
  }
561
575
 
562
576
  async function attachApprovalForExecute(api, config, userId, toolName, effectiveParams) {
563
- if (String(effectiveParams.mode || "") !== "execute") return null;
577
+ if (!["execute", "intent_execute"].includes(String(effectiveParams.mode || ""))) return null;
578
+ if (toolName === "swap_solana_tokens" && String(effectiveParams.mode || "") === "execute") {
579
+ throw new Error(
580
+ "Legacy exact-preview execute is disabled for Solana Jupiter swaps in OpenClaw. Use intent_preview, ask for explicit chat confirmation, then call intent_execute. The intent path binds approval to risk limits instead of a fragile Jupiter quote payload."
581
+ );
582
+ }
564
583
 
565
584
  const cached = latestCachedPreview(userId, toolName);
566
585
  if (cached?.preview && cached?.summary) {
@@ -571,14 +590,14 @@ async function attachApprovalForExecute(api, config, userId, toolName, effective
571
590
  toolName,
572
591
  cached.preview
573
592
  );
574
- if (requiresApprovedPreviewPayload(toolName)) {
593
+ if (requiresApprovedPreviewPayload(toolName, effectiveParams)) {
575
594
  effectiveParams._approved_preview = cached.preview;
576
595
  }
577
596
  return cached;
578
597
  }
579
598
 
580
599
  if (
581
- requiresApprovedPreviewPayload(toolName) &&
600
+ requiresApprovedPreviewPayload(toolName, effectiveParams) &&
582
601
  typeof effectiveParams.approval_token === "string" &&
583
602
  effectiveParams.approval_token.trim() &&
584
603
  effectiveParams._approved_preview === undefined
@@ -1312,7 +1331,7 @@ const solanaToolDefinitions = [
1312
1331
  },
1313
1332
  {
1314
1333
  name: "swap_solana_tokens",
1315
- description: `Preview, prepare, or execute a Solana token swap via Jupiter. Preview or prepare first. After the user explicitly confirms the shown summary in chat, call execute; the OpenClaw plugin handles the internal execution authorization automatically. ${WALLET_TOOL_ONLY_GUIDANCE}`,
1334
+ description: `Preview or execute a Solana token swap via Jupiter. Use intent_preview followed by intent_execute after the user explicitly confirms the intent summary in chat; intent_execute fetches a fresh quote and only executes inside the approved limits. Do not use legacy execute for Solana swaps. The OpenClaw plugin handles internal execution authorization automatically. ${WALLET_TOOL_ONLY_GUIDANCE}`,
1316
1335
  optional: true,
1317
1336
  parameters: {
1318
1337
  type: "object",
@@ -1320,8 +1339,12 @@ const solanaToolDefinitions = [
1320
1339
  input_mint: { type: "string" },
1321
1340
  output_mint: { type: "string" },
1322
1341
  amount: { type: "number" },
1323
- slippage_bps: { type: "integer" },
1324
- mode: { type: "string", enum: ["preview", "prepare", "execute"] },
1342
+ slippage_bps: { type: "integer", description: "Optional slippage tolerance in basis points. Defaults to 300 (3%) for Solana swaps." },
1343
+ minimum_output_amount_raw: { type: "integer", description: "Optional approved minimum output in raw output token units for intent_preview. For intent swaps, overly strict values are clamped to the slippage floor." },
1344
+ max_fee_lamports: { type: "integer" },
1345
+ valid_for_seconds: { type: "integer", description: "Optional intent validity window in seconds. Intent swaps use at least 120 seconds, max 120." },
1346
+ max_attempts: { type: "integer", description: "Optional number of fresh quote/simulate/execute attempts. Intent swaps use at least 3 attempts, max 5." },
1347
+ mode: { type: "string", enum: ["preview", "intent_preview", "intent_execute"] },
1325
1348
  purpose: { type: "string" },
1326
1349
  user_intent: { type: "boolean" },
1327
1350
  },
@@ -76,7 +76,7 @@ function cachePreviewForApproval(userId, toolName, payload) {
76
76
  const cacheToolName = approvalPreviewToolName(toolName);
77
77
  if (!payload || payload.ok !== true || !payload.data || typeof payload.data !== "object") return;
78
78
  const approvalSource = payload.data;
79
- if (!["preview", "prepare"].includes(String(approvalSource.mode || ""))) return;
79
+ if (!["preview", "prepare", "intent_preview"].includes(String(approvalSource.mode || ""))) return;
80
80
  if (!approvalSource.confirmation_summary || typeof approvalSource.confirmation_summary !== "object") return;
81
81
  pruneApprovalPreviewCache();
82
82
  const digest = previewDigest(approvalSource);
@@ -119,7 +119,21 @@ function cachedPreviewForToken(userId, toolName, token) {
119
119
  return cached.preview && typeof cached.preview === "object" ? cached.preview : null;
120
120
  }
121
121
 
122
- function requiresApprovedPreviewPayload(toolName) {
122
+ function isSolanaSwapIntentPayload(payload) {
123
+ return (
124
+ payload &&
125
+ typeof payload === "object" &&
126
+ (String(payload.asset_type || "") === "solana-swap-intent" ||
127
+ String(payload.mode || "") === "intent_preview")
128
+ );
129
+ }
130
+
131
+ function isSolanaSwapIntentExecute(params) {
132
+ return String(params?.mode || "") === "intent_execute";
133
+ }
134
+
135
+ function requiresApprovedPreviewPayload(toolName, params = null) {
136
+ if (toolName === "swap_solana_tokens" && isSolanaSwapIntentExecute(params)) return false;
123
137
  return PREVIEW_BOUND_SWAP_TOOLS.has(toolName);
124
138
  }
125
139
 
@@ -536,7 +550,7 @@ async function issueApprovalToken(api, config, userId, toolName, previewPayload)
536
550
  if (!summary || typeof summary !== "object") {
537
551
  throw new Error(`No confirmation_summary available for ${toolName}.`);
538
552
  }
539
- const summaryForToken = PREVIEW_BOUND_SWAP_TOOLS.has(toolName)
553
+ const summaryForToken = PREVIEW_BOUND_SWAP_TOOLS.has(toolName) && !isSolanaSwapIntentPayload(previewPayload)
540
554
  ? { ...summary, _preview_digest: previewDigest(previewPayload) }
541
555
  : { ...summary };
542
556
  const extraArgs = [
@@ -560,7 +574,12 @@ async function issueApprovalToken(api, config, userId, toolName, previewPayload)
560
574
  }
561
575
 
562
576
  async function attachApprovalForExecute(api, config, userId, toolName, effectiveParams) {
563
- if (String(effectiveParams.mode || "") !== "execute") return null;
577
+ if (!["execute", "intent_execute"].includes(String(effectiveParams.mode || ""))) return null;
578
+ if (toolName === "swap_solana_tokens" && String(effectiveParams.mode || "") === "execute") {
579
+ throw new Error(
580
+ "Legacy exact-preview execute is disabled for Solana Jupiter swaps in OpenClaw. Use intent_preview, ask for explicit chat confirmation, then call intent_execute. The intent path binds approval to risk limits instead of a fragile Jupiter quote payload."
581
+ );
582
+ }
564
583
 
565
584
  const cached = latestCachedPreview(userId, toolName);
566
585
  if (cached?.preview && cached?.summary) {
@@ -571,14 +590,14 @@ async function attachApprovalForExecute(api, config, userId, toolName, effective
571
590
  toolName,
572
591
  cached.preview
573
592
  );
574
- if (requiresApprovedPreviewPayload(toolName)) {
593
+ if (requiresApprovedPreviewPayload(toolName, effectiveParams)) {
575
594
  effectiveParams._approved_preview = cached.preview;
576
595
  }
577
596
  return cached;
578
597
  }
579
598
 
580
599
  if (
581
- requiresApprovedPreviewPayload(toolName) &&
600
+ requiresApprovedPreviewPayload(toolName, effectiveParams) &&
582
601
  typeof effectiveParams.approval_token === "string" &&
583
602
  effectiveParams.approval_token.trim() &&
584
603
  effectiveParams._approved_preview === undefined
@@ -1312,7 +1331,7 @@ const solanaToolDefinitions = [
1312
1331
  },
1313
1332
  {
1314
1333
  name: "swap_solana_tokens",
1315
- description: `Preview, prepare, or execute a Solana token swap via Jupiter. Preview or prepare first. After the user explicitly confirms the shown summary in chat, call execute; the OpenClaw plugin handles the internal execution authorization automatically. ${WALLET_TOOL_ONLY_GUIDANCE}`,
1334
+ description: `Preview or execute a Solana token swap via Jupiter. Use intent_preview followed by intent_execute after the user explicitly confirms the intent summary in chat; intent_execute fetches a fresh quote and only executes inside the approved limits. Do not use legacy execute for Solana swaps. The OpenClaw plugin handles internal execution authorization automatically. ${WALLET_TOOL_ONLY_GUIDANCE}`,
1316
1335
  optional: true,
1317
1336
  parameters: {
1318
1337
  type: "object",
@@ -1320,8 +1339,12 @@ const solanaToolDefinitions = [
1320
1339
  input_mint: { type: "string" },
1321
1340
  output_mint: { type: "string" },
1322
1341
  amount: { type: "number" },
1323
- slippage_bps: { type: "integer" },
1324
- mode: { type: "string", enum: ["preview", "prepare", "execute"] },
1342
+ slippage_bps: { type: "integer", description: "Optional slippage tolerance in basis points. Defaults to 300 (3%) for Solana swaps." },
1343
+ minimum_output_amount_raw: { type: "integer", description: "Optional approved minimum output in raw output token units for intent_preview. For intent swaps, overly strict values are clamped to the slippage floor." },
1344
+ max_fee_lamports: { type: "integer" },
1345
+ valid_for_seconds: { type: "integer", description: "Optional intent validity window in seconds. Intent swaps use at least 120 seconds, max 120." },
1346
+ max_attempts: { type: "integer", description: "Optional number of fresh quote/simulate/execute attempts. Intent swaps use at least 3 attempts, max 5." },
1347
+ mode: { type: "string", enum: ["preview", "intent_preview", "intent_execute"] },
1325
1348
  purpose: { type: "string" },
1326
1349
  user_intent: { type: "boolean" },
1327
1350
  },
@@ -222,6 +222,10 @@
222
222
  "type": "string",
223
223
  "description": "Optional Jupiter Swap API base URL."
224
224
  },
225
+ "jupiterSwapV2BaseUrl": {
226
+ "type": "string",
227
+ "description": "Optional Jupiter Swap API V2 base URL."
228
+ },
225
229
  "jupiterUltraBaseUrl": {
226
230
  "type": "string",
227
231
  "description": "Optional Jupiter Ultra API base URL."
@@ -11,9 +11,12 @@ Safety rules:
11
11
  - Use Kamino market/reserve reads before Kamino writes when the user needs lending context.
12
12
  - Use Aave account reads before Aave writes when the user needs EVM lending context.
13
13
  - For transfers, native staking, swaps, Aave writes, Jupiter Earn writes, and Kamino writes, use `preview` before `prepare` or `execute`.
14
+ - For Solana Jupiter swaps through `swap_solana_tokens`, prefer `intent_preview` then `intent_execute` after explicit chat confirmation. The user confirms risk limits; the backend refreshes the quote and only executes inside those limits.
15
+ - Solana swap intent defaults to at least 300 bps (3%) slippage, 120 seconds validity, and 3 fresh execution attempts. The backend computes the approved minimum output from the indicative output and slippage, clamps hand-tightened minimums to that floor, then executes through Jupiter Swap API V2 `/order` + `/execute` when available.
16
+ - Do not use legacy `execute` for Solana Jupiter swaps in OpenClaw. Exact quote-bound approval is too fragile for active markets and will be rejected by the bridge.
14
17
  - For `swap_solana_privately`, use `preview` and then `execute` after explicit user approval. Do not use `prepare` for this tool.
15
18
  - Use `prepare` only when the user clearly intends to produce an execution plan.
16
- - Use `execute` only after the host issues an `approval_token` bound to the exact previewed operation.
19
+ - Use `execute` only after the user explicitly confirms the shown summary in chat. OpenClaw handles the internal approval token; do not ask for `/approve`, buttons, popups, or a manual token. For Solana swap intents, the token is bound to the approved intent limits instead of one fragile quote.
17
20
  - On `mainnet`, require an approval token that includes explicit mainnet confirmation before any execution.
18
21
  - Before any `mainnet` execute, restate the network, operation type, asset, amount, and destination, validator, or stake account.
19
22
  - If a preview or prepare result includes `confirmation_summary` or `mainnet_warning`, surface that summary before asking for confirmation.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.1.26 - 2026-05-26
6
+
7
+ - Reworked Solana Jupiter swaps to prefer intent approvals, so OpenClaw confirms
8
+ risk limits and executes against a fresh quote instead of binding approval to
9
+ a fragile exact quote payload.
10
+ - Added Jupiter Swap API V2 `/order` + `/execute` routing with managed landing
11
+ support and fallback routing for Solana swaps.
12
+ - Hardened Solana swap intent defaults to 3% slippage, a 120-second execution
13
+ window, three fresh execution attempts, and safer minimum-output handling.
14
+ - Fixed Jupiter V2 execution payload compatibility by sending
15
+ `lastValidBlockHeight` in the string form expected by the API.
16
+ - Disabled legacy exact-preview Solana swap execute in the OpenClaw bridge to
17
+ prevent stale approval-token mismatches on active markets.
18
+
5
19
  ## v0.1.24 - 2026-05-23
6
20
 
7
21
  - Fixed the published npm package CLI metadata so
@@ -88,7 +88,7 @@ Current safe tools:
88
88
  - `transfer_sol`
89
89
  - `stake_sol_native`
90
90
  - `transfer_spl_token`
91
- - `swap_solana_tokens`
91
+ - `swap_solana_tokens` - Solana Jupiter swaps; prefer `intent_preview` -> chat confirmation -> `intent_execute` so execution refreshes the quote inside approved limits.
92
92
  - `swap_solana_privately` - Houdini-backed private Solana payout flow for same-token `SOL->SOL` or `USDC->USDC` transfers to a destination wallet.
93
93
  - `get_solana_private_swap_status`
94
94
  - `get_jupiter_earn_tokens`
@@ -45,6 +45,7 @@ class Settings(BaseSettings):
45
45
  wdk_evm_account_index: int = 0
46
46
 
47
47
  jupiter_api_base_url: str = "https://lite-api.jup.ag/swap/v1"
48
+ jupiter_swap_v2_api_base_url: str = "https://api.jup.ag/swap/v2"
48
49
  jupiter_ultra_api_base_url: str = "https://lite-api.jup.ag/ultra/v1"
49
50
  jupiter_price_api_base_url: str = "https://lite-api.jup.ag/price/v3"
50
51
  jupiter_portfolio_api_base_url: str = "https://api.jup.ag/portfolio/v1"
@@ -352,6 +352,28 @@ class OpenClawWalletAdapter:
352
352
  summary[key] = value
353
353
  return summary
354
354
 
355
+ if asset_type == "solana-swap-intent":
356
+ return {
357
+ "operation": action_label,
358
+ "network": str(payload.get("network") or getattr(self.backend, "network", "unknown")),
359
+ "owner": payload.get("owner"),
360
+ "input_mint": payload.get("input_mint"),
361
+ "output_mint": payload.get("output_mint"),
362
+ "input_amount_ui": payload.get("input_amount_ui"),
363
+ "input_amount_raw": payload.get("input_amount_raw"),
364
+ "minimum_output_amount_raw": payload.get("minimum_output_amount_raw"),
365
+ "minimum_output_amount_ui": payload.get("minimum_output_amount_ui"),
366
+ "max_slippage_bps": payload.get("max_slippage_bps"),
367
+ "slippage_bps": payload.get("slippage_bps"),
368
+ "max_fee_lamports": payload.get("max_fee_lamports"),
369
+ "valid_until_epoch_seconds": payload.get("valid_until_epoch_seconds"),
370
+ "valid_for_seconds": payload.get("valid_for_seconds"),
371
+ "max_attempts": payload.get("max_attempts"),
372
+ "allowed_providers": payload.get("allowed_providers"),
373
+ "recipient_policy": payload.get("recipient_policy"),
374
+ "spend_policy": payload.get("spend_policy"),
375
+ }
376
+
355
377
  if asset_type == "solana-lifi-cross-chain-swap":
356
378
  return {
357
379
  "operation": action_label,
@@ -2451,7 +2473,9 @@ class OpenClawWalletAdapter:
2451
2473
  name="swap_solana_tokens",
2452
2474
  description=(
2453
2475
  "Preview or execute a Solana token swap through Jupiter routing. "
2454
- "Use preview first, then execute only after explicit user approval."
2476
+ "Prefer intent_preview, then intent_execute after explicit chat confirmation; "
2477
+ "intent_execute fetches a fresh quote and only sends if it remains inside the approved limits. "
2478
+ "OpenClaw should not use legacy execute for Solana swaps because exact Jupiter quote payloads expire quickly."
2455
2479
  ),
2456
2480
  input_schema={
2457
2481
  "type": "object",
@@ -2470,12 +2494,28 @@ class OpenClawWalletAdapter:
2470
2494
  },
2471
2495
  "slippage_bps": {
2472
2496
  "type": "integer",
2473
- "description": "Optional slippage tolerance in basis points. Defaults to 50.",
2497
+ "description": "Optional slippage tolerance in basis points. Defaults to 300 (3%) for Solana swaps.",
2498
+ },
2499
+ "minimum_output_amount_raw": {
2500
+ "type": "integer",
2501
+ "description": "Optional approved minimum output in raw output token units for intent_preview. For intent swaps, overly strict values are clamped to the slippage floor.",
2502
+ },
2503
+ "max_fee_lamports": {
2504
+ "type": "integer",
2505
+ "description": "Optional maximum Solana network fee in lamports for intent_preview.",
2506
+ },
2507
+ "valid_for_seconds": {
2508
+ "type": "integer",
2509
+ "description": "Optional intent validity window in seconds. Intent swaps use at least 120 seconds, max 120.",
2510
+ },
2511
+ "max_attempts": {
2512
+ "type": "integer",
2513
+ "description": "Optional number of fresh quote/simulate/execute attempts. Intent swaps use at least 3 attempts, max 5.",
2474
2514
  },
2475
2515
  "mode": {
2476
2516
  "type": "string",
2477
- "enum": ["preview", "prepare", "execute"],
2478
- "description": "preview returns a quote, prepare returns an execution plan without signed transaction bytes, execute attempts to swap.",
2517
+ "enum": ["preview", "prepare", "execute", "intent_preview", "intent_execute"],
2518
+ "description": "intent_preview returns approved risk limits; intent_execute requotes and executes atomically inside those limits. Legacy preview/prepare/execute remains supported.",
2479
2519
  },
2480
2520
  "purpose": {
2481
2521
  "type": "string",
@@ -5248,7 +5288,11 @@ class OpenClawWalletAdapter:
5248
5288
  input_mint = args.get("input_mint")
5249
5289
  output_mint = args.get("output_mint")
5250
5290
  amount = args.get("amount")
5251
- slippage_bps = args.get("slippage_bps", 50)
5291
+ slippage_bps = args.get("slippage_bps", 300)
5292
+ minimum_output_amount_raw = args.get("minimum_output_amount_raw")
5293
+ max_fee_lamports = args.get("max_fee_lamports")
5294
+ valid_for_seconds = args.get("valid_for_seconds", 120)
5295
+ max_attempts = args.get("max_attempts", 3)
5252
5296
  mode = args.get("mode")
5253
5297
  purpose = args.get("purpose")
5254
5298
  user_intent = args.get("user_intent", False)
@@ -5262,10 +5306,28 @@ class OpenClawWalletAdapter:
5262
5306
  raise WalletBackendError("amount must be a positive number.")
5263
5307
  if not isinstance(slippage_bps, int) or slippage_bps <= 0:
5264
5308
  raise WalletBackendError("slippage_bps must be a positive integer.")
5265
- if mode not in {"preview", "prepare", "execute"}:
5266
- raise WalletBackendError("mode must be 'preview', 'prepare' or 'execute'.")
5309
+ if minimum_output_amount_raw is not None and (
5310
+ not isinstance(minimum_output_amount_raw, int) or minimum_output_amount_raw <= 0
5311
+ ):
5312
+ raise WalletBackendError("minimum_output_amount_raw must be a positive integer when provided.")
5313
+ if max_fee_lamports is not None and (
5314
+ not isinstance(max_fee_lamports, int) or max_fee_lamports < 0
5315
+ ):
5316
+ raise WalletBackendError("max_fee_lamports must be a non-negative integer when provided.")
5317
+ if not isinstance(valid_for_seconds, int) or valid_for_seconds <= 0 or valid_for_seconds > 120:
5318
+ raise WalletBackendError("valid_for_seconds must be an integer between 1 and 120.")
5319
+ if not isinstance(max_attempts, int) or max_attempts <= 0 or max_attempts > 5:
5320
+ raise WalletBackendError("max_attempts must be an integer between 1 and 5.")
5321
+ if mode not in {"preview", "prepare", "execute", "intent_preview", "intent_execute"}:
5322
+ raise WalletBackendError(
5323
+ "mode must be 'preview', 'prepare', 'execute', 'intent_preview' or 'intent_execute'."
5324
+ )
5267
5325
  if not isinstance(purpose, str) or not purpose.strip():
5268
5326
  raise WalletBackendError("purpose is required.")
5327
+ if mode in {"intent_preview", "intent_execute"}:
5328
+ slippage_bps = max(slippage_bps, 300)
5329
+ valid_for_seconds = max(valid_for_seconds, 120)
5330
+ max_attempts = max(max_attempts, 3)
5269
5331
 
5270
5332
  if mode == "preview":
5271
5333
  preview = await self.backend.preview_swap(
@@ -5284,6 +5346,27 @@ class OpenClawWalletAdapter:
5284
5346
  ),
5285
5347
  )
5286
5348
 
5349
+ if mode == "intent_preview":
5350
+ intent_preview = await self.backend.preview_swap_intent(
5351
+ input_mint=input_mint.strip(),
5352
+ output_mint=output_mint.strip(),
5353
+ amount_ui=float(amount),
5354
+ slippage_bps=slippage_bps,
5355
+ minimum_output_amount_raw=minimum_output_amount_raw,
5356
+ max_fee_lamports=max_fee_lamports,
5357
+ valid_for_seconds=valid_for_seconds,
5358
+ max_attempts=max_attempts,
5359
+ )
5360
+ return AgentToolResult(
5361
+ tool=tool_name,
5362
+ ok=True,
5363
+ data=self._annotate_sensitive_payload(
5364
+ intent_preview,
5365
+ action_label="Swap intent",
5366
+ mode="preview",
5367
+ ),
5368
+ )
5369
+
5287
5370
  if mode == "prepare":
5288
5371
  self._require_prepare_intent(user_intent)
5289
5372
  preview = await self.backend.preview_swap(
@@ -5305,6 +5388,103 @@ class OpenClawWalletAdapter:
5305
5388
  ),
5306
5389
  )
5307
5390
 
5391
+ if mode == "intent_execute":
5392
+ approval_payload = inspect_approval_token(
5393
+ approval_token,
5394
+ tool_name=tool_name,
5395
+ network=str(getattr(self.backend, "network", "unknown")),
5396
+ require_mainnet_confirmation=self._is_mainnet_for_backend(self.backend),
5397
+ )
5398
+ approval_summary = approval_payload.get("binding", {}).get("summary")
5399
+ if not isinstance(approval_summary, dict):
5400
+ raise WalletBackendError(
5401
+ "approval_token does not match the requested operation. Generate a new intent preview and approval before execute."
5402
+ )
5403
+ expected_summary = {
5404
+ "operation": "Swap intent",
5405
+ "network": str(getattr(self.backend, "network", "unknown")),
5406
+ "input_mint": input_mint.strip(),
5407
+ "output_mint": output_mint.strip(),
5408
+ }
5409
+ for key, expected_value in expected_summary.items():
5410
+ if approval_summary.get(key) != expected_value:
5411
+ raise WalletBackendError(
5412
+ "approval_token does not match the requested swap intent. Generate a fresh intent preview and approval before execute."
5413
+ )
5414
+ current_owner = await self.backend.get_address()
5415
+ approved_owner = approval_summary.get("owner")
5416
+ if approved_owner and current_owner and str(approved_owner) != str(current_owner):
5417
+ raise WalletBackendError(
5418
+ "approval_token does not match the active wallet owner. Generate a fresh intent preview and approval before execute."
5419
+ )
5420
+ try:
5421
+ approved_amount = float(approval_summary.get("input_amount_ui"))
5422
+ approved_slippage = int(
5423
+ approval_summary.get("max_slippage_bps")
5424
+ if approval_summary.get("max_slippage_bps") is not None
5425
+ else approval_summary.get("slippage_bps")
5426
+ )
5427
+ except (TypeError, ValueError):
5428
+ raise WalletBackendError(
5429
+ "approval_token does not match the requested swap intent. Generate a fresh intent preview and approval before execute."
5430
+ )
5431
+ if approved_amount != float(amount) or approved_slippage != slippage_bps:
5432
+ raise WalletBackendError(
5433
+ "approval_token does not match the requested swap intent. Generate a fresh intent preview and approval before execute."
5434
+ )
5435
+ if approval_summary.get("recipient_policy") != "owner-only":
5436
+ raise WalletBackendError("approved swap intent recipient policy is invalid.")
5437
+ if approval_summary.get("spend_policy") != "exact-input":
5438
+ raise WalletBackendError("approved swap intent spend policy is invalid.")
5439
+
5440
+ approval_summary_copy = dict(approval_summary)
5441
+ self._require_execute_approval(
5442
+ approval_token=approval_token,
5443
+ tool_name=tool_name,
5444
+ summary=approval_summary_copy,
5445
+ action_label="Swap intent",
5446
+ )
5447
+ try:
5448
+ approved_min_output_raw = (
5449
+ int(approval_summary_copy["minimum_output_amount_raw"])
5450
+ if approval_summary_copy.get("minimum_output_amount_raw") is not None
5451
+ else None
5452
+ )
5453
+ approved_max_fee_lamports = (
5454
+ int(approval_summary_copy["max_fee_lamports"])
5455
+ if approval_summary_copy.get("max_fee_lamports") is not None
5456
+ else None
5457
+ )
5458
+ approved_valid_until = (
5459
+ int(approval_summary_copy["valid_until_epoch_seconds"])
5460
+ if approval_summary_copy.get("valid_until_epoch_seconds") is not None
5461
+ else None
5462
+ )
5463
+ approved_max_attempts = int(approval_summary_copy.get("max_attempts") or max_attempts)
5464
+ except (TypeError, ValueError):
5465
+ raise WalletBackendError(
5466
+ "approval_token does not contain valid swap intent limits. Generate a fresh intent preview and approval before execute."
5467
+ )
5468
+ result = await self.backend.execute_swap_intent(
5469
+ input_mint=input_mint.strip(),
5470
+ output_mint=output_mint.strip(),
5471
+ amount_ui=float(amount),
5472
+ slippage_bps=slippage_bps,
5473
+ minimum_output_amount_raw=approved_min_output_raw,
5474
+ max_fee_lamports=approved_max_fee_lamports,
5475
+ valid_until_epoch_seconds=approved_valid_until,
5476
+ max_attempts=approved_max_attempts,
5477
+ )
5478
+ return AgentToolResult(
5479
+ tool=tool_name,
5480
+ ok=True,
5481
+ data=self._annotate_sensitive_payload(
5482
+ result,
5483
+ action_label="Swap",
5484
+ mode="execute",
5485
+ ),
5486
+ )
5487
+
5308
5488
  approval_payload = inspect_approval_token(
5309
5489
  approval_token,
5310
5490
  tool_name=tool_name,
@@ -93,6 +93,7 @@ def _apply_config_overrides(config: dict[str, Any]) -> None:
93
93
  ),
94
94
  "openclawHome": ("OPENCLAW_HOME", config.get("openclawHome"), True),
95
95
  "jupiterBaseUrl": ("JUPITER_API_BASE_URL", config.get("jupiterBaseUrl"), True),
96
+ "jupiterSwapV2BaseUrl": ("JUPITER_SWAP_V2_API_BASE_URL", config.get("jupiterSwapV2BaseUrl"), True),
96
97
  "jupiterUltraBaseUrl": ("JUPITER_ULTRA_API_BASE_URL", config.get("jupiterUltraBaseUrl"), True),
97
98
  "jupiterPriceBaseUrl": ("JUPITER_PRICE_API_BASE_URL", config.get("jupiterPriceBaseUrl"), True),
98
99
  "jupiterPortfolioBaseUrl": (
@@ -91,6 +91,13 @@ def _direct_jupiter_enabled() -> bool:
91
91
  return bool(settings.jupiter_api_key.strip())
92
92
 
93
93
 
94
+ def _swap_v2_base_url() -> str:
95
+ return os.getenv(
96
+ "JUPITER_SWAP_V2_API_BASE_URL",
97
+ settings.jupiter_swap_v2_api_base_url,
98
+ ).strip().rstrip("/")
99
+
100
+
94
101
  def _unwrap_gateway_payload(
95
102
  status_code: int,
96
103
  payload: Any,
@@ -367,6 +374,52 @@ async def fetch_ultra_order(
367
374
  return data
368
375
 
369
376
 
377
+ async def fetch_swap_v2_order(
378
+ *,
379
+ input_mint: str,
380
+ output_mint: str,
381
+ amount_raw: int,
382
+ taker: str,
383
+ slippage_bps: int | str | None = None,
384
+ exclude_routers: list[str] | None = None,
385
+ swap_mode: str = "ExactIn",
386
+ ) -> dict[str, Any]:
387
+ """Fetch a Jupiter Swap API V2 meta-aggregator order."""
388
+ client = get_client()
389
+ params: dict[str, Any] = {
390
+ "inputMint": input_mint,
391
+ "outputMint": output_mint,
392
+ "amount": str(amount_raw),
393
+ "taker": taker,
394
+ }
395
+ if swap_mode != "ExactIn":
396
+ params["swapMode"] = swap_mode
397
+ if slippage_bps is not None:
398
+ params["slippageBps"] = str(slippage_bps)
399
+ if exclude_routers:
400
+ params["excludeRouters"] = ",".join(str(item).strip() for item in exclude_routers if str(item).strip())
401
+
402
+ response = await client.get(
403
+ f"{_swap_v2_base_url()}/order",
404
+ params=params,
405
+ headers=_headers(),
406
+ )
407
+ if response.status_code != 200:
408
+ raise ProviderError("jupiter-v2", f"HTTP {response.status_code}: {response.text[:300]}")
409
+ data = response.json()
410
+ if not isinstance(data, dict):
411
+ raise ProviderError("jupiter-v2", "Unexpected order response from Jupiter Swap V2.")
412
+ if data.get("error") or data.get("errorCode"):
413
+ raise ProviderError(
414
+ "jupiter-v2",
415
+ str(data.get("error") or data.get("errorCode") or "Unknown Swap V2 order error."),
416
+ details=data,
417
+ )
418
+ if "outAmount" not in data:
419
+ raise ProviderError("jupiter-v2", "Unexpected order response from Jupiter Swap V2.")
420
+ return data
421
+
422
+
370
423
  async def build_swap_transaction(
371
424
  *,
372
425
  user_public_key: str,
@@ -477,6 +530,42 @@ async def execute_ultra_order(
477
530
  return data
478
531
 
479
532
 
533
+ async def execute_swap_v2_order(
534
+ *,
535
+ signed_transaction_base64: str,
536
+ request_id: str,
537
+ last_valid_block_height: int | str | None = None,
538
+ ) -> dict[str, Any]:
539
+ """Execute a signed Jupiter Swap API V2 order."""
540
+ client = get_client()
541
+ body: dict[str, Any] = {
542
+ "signedTransaction": signed_transaction_base64,
543
+ "requestId": request_id,
544
+ }
545
+ if last_valid_block_height is not None:
546
+ body["lastValidBlockHeight"] = str(last_valid_block_height)
547
+ response = await client.post(
548
+ f"{_swap_v2_base_url()}/execute",
549
+ json=body,
550
+ headers={**_headers(), "Content-Type": "application/json"},
551
+ )
552
+ if response.status_code != 200:
553
+ raise ProviderError("jupiter-v2", f"HTTP {response.status_code}: {response.text[:300]}")
554
+ data = response.json()
555
+ if not isinstance(data, dict):
556
+ raise ProviderError("jupiter-v2", "Unexpected execute response from Jupiter Swap V2.")
557
+ if data.get("error") or data.get("errorCode"):
558
+ raise ProviderError(
559
+ "jupiter-v2",
560
+ str(data.get("error") or data.get("errorCode") or "Unknown Swap V2 execute error."),
561
+ details=data,
562
+ )
563
+ if str(data.get("status") or "").strip().lower() == "failed":
564
+ message = data.get("error") or data.get("code") or "Swap V2 execute failed."
565
+ raise ProviderError("jupiter-v2", str(message), details=data)
566
+ return data
567
+
568
+
480
569
  async def fetch_prices(
481
570
  *,
482
571
  mints: list[str],
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from abc import ABC, abstractmethod
6
+ import time
6
7
  from dataclasses import asdict, dataclass, field
7
8
  from typing import Any
8
9
 
@@ -731,16 +732,74 @@ class AgentWalletBackend(ABC):
731
732
  input_mint: str,
732
733
  output_mint: str,
733
734
  amount_ui: float,
734
- slippage_bps: int = 50,
735
+ slippage_bps: int = 300,
735
736
  ) -> dict[str, Any]:
736
737
  raise WalletBackendError(f"{self.name} does not support swap previews.")
737
738
 
739
+ async def preview_swap_intent(
740
+ self,
741
+ input_mint: str,
742
+ output_mint: str,
743
+ amount_ui: float,
744
+ slippage_bps: int = 300,
745
+ minimum_output_amount_raw: int | None = None,
746
+ max_fee_lamports: int | None = None,
747
+ valid_for_seconds: int = 30,
748
+ max_attempts: int = 2,
749
+ ) -> dict[str, Any]:
750
+ preview = await self.preview_swap(
751
+ input_mint=input_mint,
752
+ output_mint=output_mint,
753
+ amount_ui=amount_ui,
754
+ slippage_bps=slippage_bps,
755
+ )
756
+ fee_summary = preview.get("fee_summary") if isinstance(preview.get("fee_summary"), dict) else {}
757
+ network_fee_lamports = fee_summary.get("network_fee_lamports")
758
+ if max_fee_lamports is None and isinstance(network_fee_lamports, int):
759
+ max_fee_lamports = max(network_fee_lamports * 3, network_fee_lamports + 100_000)
760
+ resolved_min_raw = minimum_output_amount_raw
761
+ if resolved_min_raw is None and isinstance(preview.get("minimum_output_amount_raw"), int):
762
+ resolved_min_raw = int(preview["minimum_output_amount_raw"])
763
+ output_decimals = preview.get("output_decimals")
764
+ minimum_output_amount_ui = preview.get("minimum_output_amount_ui")
765
+ if resolved_min_raw is not None and isinstance(output_decimals, int):
766
+ minimum_output_amount_ui = int(resolved_min_raw) / (10**output_decimals)
767
+ return {
768
+ "chain": preview.get("chain", "solana"),
769
+ "network": preview.get("network", getattr(self, "network", "unknown")),
770
+ "mode": "intent_preview",
771
+ "asset_type": "solana-swap-intent",
772
+ "owner": preview.get("owner"),
773
+ "input_mint": preview.get("input_mint", input_mint),
774
+ "output_mint": preview.get("output_mint", output_mint),
775
+ "input_amount_ui": preview.get("input_amount_ui", amount_ui),
776
+ "input_amount_raw": preview.get("input_amount_raw"),
777
+ "minimum_output_amount_raw": resolved_min_raw,
778
+ "minimum_output_amount_ui": minimum_output_amount_ui,
779
+ "indicative_output_amount_ui": preview.get("estimated_output_amount_ui"),
780
+ "indicative_output_amount_raw": preview.get("estimated_output_amount_raw"),
781
+ "max_slippage_bps": slippage_bps,
782
+ "slippage_bps": slippage_bps,
783
+ "max_fee_lamports": max_fee_lamports,
784
+ "valid_for_seconds": valid_for_seconds,
785
+ "valid_until_epoch_seconds": int(time.time()) + valid_for_seconds,
786
+ "max_attempts": max_attempts,
787
+ "allowed_providers": ["jupiter-ultra", "jupiter-metis"],
788
+ "recipient_policy": "owner-only",
789
+ "spend_policy": "exact-input",
790
+ "indicative_swap_provider": preview.get("swap_provider"),
791
+ "indicative_fee_summary": fee_summary,
792
+ "can_send": preview.get("can_send"),
793
+ "sign_only": preview.get("sign_only"),
794
+ "source": "swap-intent",
795
+ }
796
+
738
797
  async def prepare_swap(
739
798
  self,
740
799
  input_mint: str,
741
800
  output_mint: str,
742
801
  amount_ui: float,
743
- slippage_bps: int = 50,
802
+ slippage_bps: int = 300,
744
803
  ) -> dict[str, Any]:
745
804
  raise WalletBackendError(f"{self.name} does not support swap preparation.")
746
805
 
@@ -752,7 +811,7 @@ class AgentWalletBackend(ABC):
752
811
  input_mint=str(preview["input_mint"]),
753
812
  output_mint=str(preview["output_mint"]),
754
813
  amount_ui=float(preview["input_amount_ui"]),
755
- slippage_bps=int(preview.get("slippage_bps") or 50),
814
+ slippage_bps=int(preview.get("slippage_bps") or 300),
756
815
  )
757
816
 
758
817
  async def execute_swap(
@@ -760,7 +819,7 @@ class AgentWalletBackend(ABC):
760
819
  input_mint: str,
761
820
  output_mint: str,
762
821
  amount_ui: float,
763
- slippage_bps: int = 50,
822
+ slippage_bps: int = 300,
764
823
  ) -> dict[str, Any]:
765
824
  raise WalletBackendError(f"{self.name} does not support swaps.")
766
825
 
@@ -772,8 +831,55 @@ class AgentWalletBackend(ABC):
772
831
  input_mint=str(preview["input_mint"]),
773
832
  output_mint=str(preview["output_mint"]),
774
833
  amount_ui=float(preview["input_amount_ui"]),
775
- slippage_bps=int(preview.get("slippage_bps") or 50),
834
+ slippage_bps=int(preview.get("slippage_bps") or 300),
835
+ )
836
+
837
+ async def execute_swap_intent(
838
+ self,
839
+ *,
840
+ input_mint: str,
841
+ output_mint: str,
842
+ amount_ui: float,
843
+ slippage_bps: int = 300,
844
+ minimum_output_amount_raw: int | None = None,
845
+ max_fee_lamports: int | None = None,
846
+ valid_until_epoch_seconds: int | None = None,
847
+ max_attempts: int = 2,
848
+ ) -> dict[str, Any]:
849
+ if valid_until_epoch_seconds is not None and int(time.time()) > int(valid_until_epoch_seconds):
850
+ raise WalletBackendError("Approved swap intent has expired. Create a fresh intent preview.")
851
+ preview = await self.preview_swap(
852
+ input_mint=input_mint,
853
+ output_mint=output_mint,
854
+ amount_ui=amount_ui,
855
+ slippage_bps=slippage_bps,
776
856
  )
857
+ output_raw = preview.get("estimated_output_amount_raw")
858
+ if (
859
+ minimum_output_amount_raw is not None
860
+ and isinstance(output_raw, int)
861
+ and output_raw < int(minimum_output_amount_raw)
862
+ ):
863
+ raise WalletBackendError(
864
+ "Fresh swap quote is below the approved minimum output. Funds were not moved."
865
+ )
866
+ fee_summary = preview.get("fee_summary") if isinstance(preview.get("fee_summary"), dict) else {}
867
+ network_fee_lamports = fee_summary.get("network_fee_lamports")
868
+ if (
869
+ max_fee_lamports is not None
870
+ and isinstance(network_fee_lamports, int)
871
+ and network_fee_lamports > int(max_fee_lamports)
872
+ ):
873
+ raise WalletBackendError("Fresh swap fee exceeds the approved fee limit. Funds were not moved.")
874
+ result = await self.execute_swap_from_preview(preview)
875
+ result["intent_execution"] = {
876
+ "approved_minimum_output_amount_raw": minimum_output_amount_raw,
877
+ "approved_max_fee_lamports": max_fee_lamports,
878
+ "fresh_quote_used": True,
879
+ "attempt_count": 1,
880
+ "max_attempts": max_attempts,
881
+ }
882
+ return result
777
883
 
778
884
  async def get_bags_claimable_positions(
779
885
  self,
@@ -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.")
@@ -5426,7 +5452,8 @@ class SolanaWalletBackend(AgentWalletBackend):
5426
5452
  input_mint: str,
5427
5453
  output_mint: str,
5428
5454
  amount_ui: float,
5429
- slippage_bps: int = 50,
5455
+ slippage_bps: int = SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS,
5456
+ exclude_routers: list[str] | None = None,
5430
5457
  ) -> dict[str, Any]:
5431
5458
  if self.network != "mainnet":
5432
5459
  raise WalletBackendError("Provider-routed swaps are only enabled for Solana mainnet.")
@@ -5447,29 +5474,38 @@ class SolanaWalletBackend(AgentWalletBackend):
5447
5474
  raise WalletBackendError("amount is too small for the input token decimals.")
5448
5475
 
5449
5476
  sender = await self.get_address()
5450
- quote_source = "jupiter-ultra"
5477
+ quote_source = "jupiter-v2-order"
5451
5478
  try:
5452
- quote = await jupiter.fetch_ultra_order(
5479
+ quote = await jupiter.fetch_swap_v2_order(
5453
5480
  input_mint=input_mint,
5454
5481
  output_mint=output_mint,
5455
5482
  amount_raw=raw_amount,
5456
5483
  taker=sender,
5457
- slippage_bps=slippage_bps,
5484
+ exclude_routers=exclude_routers,
5458
5485
  )
5459
5486
  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"
5487
+ quote_source = "jupiter-ultra"
5488
+ try:
5489
+ quote = await jupiter.fetch_ultra_order(
5490
+ input_mint=input_mint,
5491
+ output_mint=output_mint,
5492
+ amount_raw=raw_amount,
5493
+ taker=sender,
5494
+ slippage_bps=slippage_bps,
5495
+ )
5496
+ except ProviderError:
5497
+ quote = await jupiter.fetch_quote(
5498
+ input_mint=input_mint,
5499
+ output_mint=output_mint,
5500
+ amount_raw=raw_amount,
5501
+ slippage_bps=slippage_bps,
5502
+ )
5503
+ quote_source = "jupiter-metis"
5467
5504
 
5468
5505
  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
5506
+ other_threshold_raw = self._swap_minimum_output_floor(
5507
+ out_amount_raw=out_amount_raw,
5508
+ slippage_bps=slippage_bps,
5473
5509
  )
5474
5510
  fee_summary = self._build_swap_fee_summary(
5475
5511
  swap_provider=quote_source,
@@ -5505,12 +5541,115 @@ class SolanaWalletBackend(AgentWalletBackend):
5505
5541
  "source": quote_source,
5506
5542
  }
5507
5543
 
5544
+ async def preview_swap_intent(
5545
+ self,
5546
+ input_mint: str,
5547
+ output_mint: str,
5548
+ amount_ui: float,
5549
+ slippage_bps: int = SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS,
5550
+ minimum_output_amount_raw: int | None = None,
5551
+ max_fee_lamports: int | None = None,
5552
+ valid_for_seconds: int = 120,
5553
+ max_attempts: int = 3,
5554
+ ) -> dict[str, Any]:
5555
+ if valid_for_seconds <= 0 or valid_for_seconds > 120:
5556
+ raise WalletBackendError("valid_for_seconds must be between 1 and 120.")
5557
+ if max_attempts <= 0 or max_attempts > 5:
5558
+ raise WalletBackendError("max_attempts must be between 1 and 5.")
5559
+ slippage_bps = max(int(slippage_bps), SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS)
5560
+ max_attempts = max(int(max_attempts), 3)
5561
+
5562
+ indicative = await self.preview_swap(
5563
+ input_mint=input_mint,
5564
+ output_mint=output_mint,
5565
+ amount_ui=amount_ui,
5566
+ slippage_bps=slippage_bps,
5567
+ )
5568
+ indicative_output_raw = int(indicative.get("estimated_output_amount_raw") or 0)
5569
+ slippage_floor_raw = self._swap_minimum_output_floor(
5570
+ out_amount_raw=indicative_output_raw,
5571
+ slippage_bps=slippage_bps,
5572
+ )
5573
+ requested_min_output_raw = (
5574
+ int(minimum_output_amount_raw)
5575
+ if minimum_output_amount_raw is not None
5576
+ else None
5577
+ )
5578
+ if requested_min_output_raw is not None:
5579
+ min_output_raw = min(requested_min_output_raw, slippage_floor_raw)
5580
+ minimum_output_policy = (
5581
+ "explicit_clamped_to_slippage_floor"
5582
+ if requested_min_output_raw > slippage_floor_raw
5583
+ else "explicit"
5584
+ )
5585
+ else:
5586
+ min_output_raw = slippage_floor_raw
5587
+ minimum_output_policy = "slippage_floor"
5588
+ if min_output_raw <= 0:
5589
+ raise WalletBackendError("minimum_output_amount_raw could not be derived from the indicative quote.")
5590
+ output_decimals = int(indicative.get("output_decimals") or 0)
5591
+ min_output_ui = min_output_raw / (10**output_decimals)
5592
+
5593
+ fee_summary = (
5594
+ indicative.get("fee_summary")
5595
+ if isinstance(indicative.get("fee_summary"), dict)
5596
+ else {}
5597
+ )
5598
+ fee_limit = (
5599
+ int(max_fee_lamports)
5600
+ if max_fee_lamports is not None
5601
+ else self._default_swap_intent_max_fee_lamports(fee_summary)
5602
+ )
5603
+ if fee_limit < 0:
5604
+ raise WalletBackendError("max_fee_lamports must be non-negative.")
5605
+
5606
+ return {
5607
+ "chain": "solana",
5608
+ "network": self.network,
5609
+ "mode": "intent_preview",
5610
+ "asset_type": "solana-swap-intent",
5611
+ "owner": indicative.get("owner"),
5612
+ "input_mint": indicative["input_mint"],
5613
+ "output_mint": indicative["output_mint"],
5614
+ "input_amount_ui": indicative["input_amount_ui"],
5615
+ "input_amount_raw": indicative["input_amount_raw"],
5616
+ "input_decimals": indicative.get("input_decimals"),
5617
+ "output_decimals": indicative.get("output_decimals"),
5618
+ "indicative_output_amount_ui": indicative.get("estimated_output_amount_ui"),
5619
+ "indicative_output_amount_raw": indicative.get("estimated_output_amount_raw"),
5620
+ "minimum_output_amount_ui": min_output_ui,
5621
+ "minimum_output_amount_raw": min_output_raw,
5622
+ "requested_minimum_output_amount_raw": requested_min_output_raw,
5623
+ "minimum_output_policy": minimum_output_policy,
5624
+ "max_slippage_bps": slippage_bps,
5625
+ "slippage_bps": slippage_bps,
5626
+ "max_fee_lamports": fee_limit,
5627
+ "max_fee_sol": fee_limit / solana_rpc.LAMPORTS_PER_SOL,
5628
+ "valid_for_seconds": valid_for_seconds,
5629
+ "valid_until_epoch_seconds": int(time.time()) + valid_for_seconds,
5630
+ "max_attempts": max_attempts,
5631
+ "allowed_providers": ["jupiter-v2-order", "jupiter-ultra", "jupiter-metis"],
5632
+ "recipient_policy": "owner-only",
5633
+ "spend_policy": "exact-input",
5634
+ "indicative_swap_provider": indicative.get("swap_provider"),
5635
+ "indicative_price_impact_pct": indicative.get("price_impact_pct"),
5636
+ "indicative_route_plan": indicative.get("route_plan", []),
5637
+ "indicative_fee_summary": fee_summary,
5638
+ "intent_note": (
5639
+ "This is an intent approval preview. Execute will fetch a fresh quote and "
5640
+ "only sign/send if it remains inside these approved limits."
5641
+ ),
5642
+ "can_send": self.get_capabilities().can_send_transaction,
5643
+ "sign_only": self.sign_only,
5644
+ "source": "swap-intent",
5645
+ }
5646
+
5508
5647
  async def execute_swap(
5509
5648
  self,
5510
5649
  input_mint: str,
5511
5650
  output_mint: str,
5512
5651
  amount_ui: float,
5513
- slippage_bps: int = 50,
5652
+ slippage_bps: int = SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS,
5514
5653
  ) -> dict[str, Any]:
5515
5654
  preview = await self.preview_swap(
5516
5655
  input_mint=input_mint,
@@ -5520,17 +5659,23 @@ class SolanaWalletBackend(AgentWalletBackend):
5520
5659
  )
5521
5660
  return await self.execute_swap_from_preview(preview)
5522
5661
 
5523
- async def execute_swap_from_preview(
5662
+ async def _submit_prepared_swap(
5524
5663
  self,
5525
- preview: dict[str, Any],
5664
+ prepared: dict[str, Any],
5526
5665
  ) -> dict[str, Any]:
5527
- prepared = await self.prepare_swap_from_preview(preview)
5528
5666
  if self.sign_only:
5529
5667
  raise WalletBackendError(
5530
5668
  "This wallet backend is in sign-only mode. Disable sign_only to broadcast transactions."
5531
5669
  )
5532
5670
 
5533
- if prepared.get("swap_provider") == "jupiter-ultra":
5671
+ if prepared.get("swap_provider") == "jupiter-v2-order":
5672
+ submitted = await jupiter.execute_swap_v2_order(
5673
+ signed_transaction_base64=str(prepared["transaction_base64"]),
5674
+ request_id=str(prepared["request_id"]),
5675
+ last_valid_block_height=_coerce_int(prepared.get("last_valid_block_height")),
5676
+ )
5677
+ onchain_signature = submitted.get("signature") or submitted.get("txid")
5678
+ elif prepared.get("swap_provider") == "jupiter-ultra":
5534
5679
  submitted = await jupiter.execute_ultra_order(
5535
5680
  signed_transaction_base64=str(prepared["transaction_base64"]),
5536
5681
  request_id=str(prepared["request_id"]),
@@ -5583,12 +5728,132 @@ class SolanaWalletBackend(AgentWalletBackend):
5583
5728
  "source": prepared.get("swap_provider") or "jupiter-metis",
5584
5729
  }
5585
5730
 
5731
+ async def execute_swap_from_preview(
5732
+ self,
5733
+ preview: dict[str, Any],
5734
+ ) -> dict[str, Any]:
5735
+ prepared = await self.prepare_swap_from_preview(preview)
5736
+ return await self._submit_prepared_swap(prepared)
5737
+
5738
+ async def execute_swap_intent(
5739
+ self,
5740
+ *,
5741
+ input_mint: str,
5742
+ output_mint: str,
5743
+ amount_ui: float,
5744
+ slippage_bps: int = SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS,
5745
+ minimum_output_amount_raw: int | None = None,
5746
+ max_fee_lamports: int | None = None,
5747
+ valid_until_epoch_seconds: int | None = None,
5748
+ max_attempts: int = 3,
5749
+ ) -> dict[str, Any]:
5750
+ if valid_until_epoch_seconds is not None and int(time.time()) > int(valid_until_epoch_seconds):
5751
+ raise WalletBackendError("Approved swap intent has expired. Create a fresh intent preview.")
5752
+ if max_attempts <= 0 or max_attempts > 5:
5753
+ raise WalletBackendError("max_attempts must be between 1 and 5.")
5754
+ slippage_bps = max(int(slippage_bps), SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS)
5755
+ max_attempts = max(int(max_attempts), 3)
5756
+
5757
+ attempts: list[dict[str, Any]] = []
5758
+ last_error: str | None = None
5759
+ for attempt_index in range(max_attempts):
5760
+ if valid_until_epoch_seconds is not None and int(time.time()) > int(valid_until_epoch_seconds):
5761
+ break
5762
+ try:
5763
+ exclude_routers = ["jupiterz"] if attempt_index > 0 else None
5764
+ preview = await self.preview_swap(
5765
+ input_mint=input_mint,
5766
+ output_mint=output_mint,
5767
+ amount_ui=amount_ui,
5768
+ slippage_bps=slippage_bps,
5769
+ exclude_routers=exclude_routers,
5770
+ )
5771
+ estimated_output_raw = int(preview.get("estimated_output_amount_raw") or 0)
5772
+ if (
5773
+ minimum_output_amount_raw is not None
5774
+ and estimated_output_raw < int(minimum_output_amount_raw)
5775
+ ):
5776
+ attempts.append(
5777
+ {
5778
+ "attempt": attempt_index + 1,
5779
+ "swap_provider": preview.get("swap_provider"),
5780
+ "rejected": "quote_below_minimum_output",
5781
+ "estimated_output_amount_raw": estimated_output_raw,
5782
+ "minimum_output_amount_raw": int(minimum_output_amount_raw),
5783
+ }
5784
+ )
5785
+ last_error = "Fresh swap quote is below the approved minimum output."
5786
+ continue
5787
+
5788
+ prepared = await self.prepare_swap_from_preview(preview)
5789
+ prepared_fee = self._swap_fee_lamports(prepared)
5790
+ if (
5791
+ max_fee_lamports is not None
5792
+ and prepared_fee is not None
5793
+ and prepared_fee > int(max_fee_lamports)
5794
+ ):
5795
+ attempts.append(
5796
+ {
5797
+ "attempt": attempt_index + 1,
5798
+ "swap_provider": prepared.get("swap_provider"),
5799
+ "rejected": "fee_above_limit",
5800
+ "fee_lamports": prepared_fee,
5801
+ "max_fee_lamports": int(max_fee_lamports),
5802
+ }
5803
+ )
5804
+ last_error = "Fresh swap fee exceeds the approved fee limit."
5805
+ continue
5806
+
5807
+ result = await self._submit_prepared_swap(prepared)
5808
+ result["intent_execution"] = {
5809
+ "approved_minimum_output_amount_raw": minimum_output_amount_raw,
5810
+ "approved_max_fee_lamports": max_fee_lamports,
5811
+ "fresh_quote_used": True,
5812
+ "attempt_count": attempt_index + 1,
5813
+ "max_attempts": max_attempts,
5814
+ "attempts": attempts
5815
+ + [
5816
+ {
5817
+ "attempt": attempt_index + 1,
5818
+ "swap_provider": prepared.get("swap_provider"),
5819
+ "status": "submitted",
5820
+ }
5821
+ ],
5822
+ }
5823
+ return result
5824
+ except (WalletBackendError, ProviderError) as exc:
5825
+ last_error = str(exc)
5826
+ attempts.append(
5827
+ {
5828
+ "attempt": attempt_index + 1,
5829
+ "rejected": "execution_error",
5830
+ "error": str(exc),
5831
+ }
5832
+ )
5833
+ if "sign-only mode" in str(exc).lower():
5834
+ break
5835
+ if attempt_index + 1 < max_attempts:
5836
+ await asyncio.sleep(min(0.5 * (attempt_index + 1), 1.5))
5837
+
5838
+ reason_suffix = f" Last reason: {last_error}" if last_error else ""
5839
+ raise WalletBackendError(
5840
+ "Solana swap intent execution failed within the approved limits. Funds were not moved."
5841
+ + reason_suffix,
5842
+ details={
5843
+ "reason": last_error,
5844
+ "attempts": attempts,
5845
+ "minimum_output_amount_raw": minimum_output_amount_raw,
5846
+ "max_fee_lamports": max_fee_lamports,
5847
+ "max_attempts": max_attempts,
5848
+ },
5849
+ )
5850
+
5586
5851
  async def prepare_swap(
5587
5852
  self,
5588
5853
  input_mint: str,
5589
5854
  output_mint: str,
5590
5855
  amount_ui: float,
5591
- slippage_bps: int = 50,
5856
+ slippage_bps: int = SOLANA_SWAP_DEFAULT_SLIPPAGE_BPS,
5592
5857
  ) -> dict[str, Any]:
5593
5858
  preview = await self.preview_swap(
5594
5859
  input_mint=input_mint,
@@ -5629,13 +5894,23 @@ class SolanaWalletBackend(AgentWalletBackend):
5629
5894
 
5630
5895
  swap_provider = str(preview.get("swap_provider") or "jupiter-metis")
5631
5896
  request_id = None
5632
- if swap_provider == "jupiter-ultra":
5897
+ if swap_provider in {"jupiter-v2-order", "jupiter-ultra"}:
5633
5898
  swap_build = preview["quote_response"]
5634
5899
  unsigned_transaction = VersionedTransaction.from_bytes(
5635
5900
  base64.b64decode(str(swap_build["transaction"]))
5636
5901
  )
5637
5902
  request_id = swap_build.get("requestId")
5638
- last_valid_block_height = swap_build.get("expireAt")
5903
+ blockhash_metadata = swap_build.get("blockhashWithMetadata")
5904
+ last_valid_block_height = (
5905
+ blockhash_metadata.get("lastValidBlockHeight")
5906
+ if isinstance(blockhash_metadata, dict)
5907
+ else None
5908
+ )
5909
+ if last_valid_block_height is None:
5910
+ last_valid_block_height = (
5911
+ swap_build.get("lastValidBlockHeight")
5912
+ or swap_build.get("expireAt")
5913
+ )
5639
5914
  prioritization_fee_lamports = swap_build.get("prioritizationFeeLamports")
5640
5915
  compute_unit_limit = swap_build.get("computeUnitLimit")
5641
5916
  else:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openclaw-agent-wallet"
7
- version = "0.1.25"
7
+ version = "0.1.26"
8
8
  description = "Plugin-friendly wallet backend for OpenClaw agents"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -12,7 +12,7 @@ Use this skill before calling OpenClaw wallet tools. It is the routing guide for
12
12
  1. Start with `get_wallet_capabilities` when the active chain, signing support, or available tools are unclear.
13
13
  2. Use `get_wallet_address` before asking for deposits or confirming a recipient/source wallet.
14
14
  3. Use `get_wallet_balance` before spending, swapping, bridging, staking, lending, or claiming.
15
- 4. Use `preview` first for every write action. Use `prepare` only after explicit user intent. In OpenClaw, use `execute` only after the user explicitly confirms the shown summary in chat; do not ask the user for `/approve`, buttons, popups, or a manual token.
15
+ 4. Use `preview` first for every write action. For Solana Jupiter swaps, prefer `intent_preview` then `intent_execute` after explicit chat confirmation so execution can refresh the quote inside approved limits. Solana swap intents are normalized by the backend to at least 300 bps slippage, 120 seconds validity, and 3 fresh execution attempts; do not pass a hand-tightened `minimum_output_amount_raw` unless the user explicitly set that floor. Use `prepare` only after explicit user intent. In OpenClaw, use `execute` only after the user explicitly confirms the shown summary in chat; do not ask the user for `/approve`, buttons, popups, or a manual token.
16
16
  5. `prepare` returns an execution plan only; it must not return signed transaction bytes.
17
17
  6. On mainnet, restate the network and material terms before `execute`; the OpenClaw plugin handles the internal execution authorization after chat confirmation.
18
18
  7. If backend is `sign_only`, do not execute; use `prepare` and state that nothing was broadcast.
@@ -76,7 +76,11 @@ Use this skill before calling OpenClaw wallet tools. It is the routing guide for
76
76
  ## Swap Commands
77
77
 
78
78
  - Solana same-chain Jupiter swap: `swap_solana_tokens`
79
- - Params: `input_mint`, `output_mint`, `amount` in UI units, optional `slippage_bps`, `mode`, `purpose`.
79
+ - Params: `input_mint`, `output_mint`, `amount` in UI units, optional `slippage_bps`, `minimum_output_amount_raw`, `max_fee_lamports`, `valid_for_seconds`, `max_attempts`, `mode`, `purpose`.
80
+ - Prefer `mode=intent_preview`, show the intent limits to the user, then after chat confirmation call `mode=intent_execute` with the same semantic params. This confirms risk limits, not a stale quote fingerprint.
81
+ - Default Solana swap slippage is 300 bps (3%). The backend computes the approved minimum output from the indicative output and slippage, not from a strict RFQ threshold.
82
+ - The primary execution path uses Jupiter Swap API V2 `/order` + `/execute`; if a JupiterZ/RFQ route fails, the backend retries with a non-JupiterZ route when possible.
83
+ - Do not use legacy `execute` for Solana Jupiter swaps in OpenClaw; exact quote-bound approval is too fragile for active markets.
80
84
  - Use for SOL<->SPL or SPL<->SPL on Solana. Do not use LI.FI for Solana-only swaps.
81
85
  - EVM same-chain Velora swap: `swap_evm_tokens`
82
86
  - Params: `token_in`, `token_out`, `amount_in_raw` base-unit string, `mode`, `purpose`, optional `network`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayer.tech/wallet",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "NPM installer for the OpenClaw Agent Wallet local runtime.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -74,4 +74,4 @@
74
74
  "evm"
75
75
  ],
76
76
  "license": "SEE LICENSE IN LICENSE"
77
- }
77
+ }