diff --git a/README.md b/README.md index f88b997..b303c42 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ jup predictions positions > [!NOTE] > This CLI is designed to be LLM friendly and **all commands are non-interactive**. Set JSON output mode globally for structured responses: `jup config set --output json`, or use `-f json` flag on individual commands. +> +> Use `--dry-run` on any transacting command to preview the result without signing or submitting on-chain. In JSON mode, the response includes the unsigned base64 `transaction` for external signing. [Read the docs](./docs/) for specific guides, examples, and workflows: diff --git a/docs/lend.md b/docs/lend.md index 9a994e5..6ee724f 100644 --- a/docs/lend.md +++ b/docs/lend.md @@ -66,6 +66,7 @@ jup lend earn positions --token USDC jup lend earn deposit --token USDC --amount 100 jup lend earn deposit --token SOL --amount 1.5 --key mykey jup lend earn deposit --token USDC --raw-amount 100000000 +jup lend earn deposit --token USDC --amount 100 --dry-run ``` - `--token` (required) — underlying token to deposit (symbol or mint address) @@ -73,6 +74,7 @@ jup lend earn deposit --token USDC --raw-amount 100000000 - `--raw-amount` uses on-chain units (e.g. `100000000` = 100 USDC) - Exactly one of `--amount` or `--raw-amount` is required - `--key` overrides the active key for this transaction +- `--dry-run` previews the deposit without signing. JSON response includes the unsigned base64 `transaction`. ```js // Example JSON response: @@ -94,6 +96,7 @@ jup lend earn withdraw --token USDC --amount 50 jup lend earn withdraw --token USDC # withdraw entire position jup lend earn withdraw --token jlUSDC --amount 50 # also accepts jlToken directly jup lend earn withdraw --token USDC --raw-amount 50000000 +jup lend earn withdraw --token USDC --amount 50 --dry-run ``` - `--token` (required) — token to withdraw (accepts underlying symbol/address or jlToken symbol/address) @@ -101,6 +104,7 @@ jup lend earn withdraw --token USDC --raw-amount 50000000 - `--raw-amount` in on-chain units of the jlToken - When neither `--amount` nor `--raw-amount` is provided, withdraws the entire position - `--key` overrides the active key for this transaction +- `--dry-run` previews the withdrawal without signing. JSON response includes the unsigned base64 `transaction`. ```js // Example JSON response: diff --git a/docs/perps.md b/docs/perps.md index fe14b2c..8b06f50 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -139,12 +139,16 @@ jup perps open --asset ETH --side long --amount 10 --input USDC --leverage 3 --t # Limit order (triggers when price reaches --limit) jup perps open --asset BTC --side long --amount 10 --input USDC --leverage 2 --limit 65000 + +# Dry-run to preview without executing +jup perps open --asset SOL --side long --amount 0.2 --leverage 2 --dry-run ``` - `--side` accepts `long`, `short`, `buy` (= long), or `sell` (= short) - `--input` defaults to SOL; accepts SOL, BTC, ETH, or USDC - `--slippage` defaults to 200 (2%); set in basis points - `--tp` and `--sl` cannot be combined with `--limit` +- `--dry-run` previews the order without signing. The API simulates the transaction, returning entry price, size, leverage, liquidation price, and fees. JSON response includes the unsigned base64 `transaction`. ```js // Example JSON response (market order): @@ -188,9 +192,13 @@ jup perps set --position --tp 100 --sl 70 # Update a limit order's trigger price jup perps set --order --limit 64000 + +# Dry-run +jup perps set --position --tp 100 --sl 70 --dry-run ``` - Get the `positionPubkey` or `orderPubkey` from `jup perps positions` +- `--dry-run` previews the update without signing. JSON response includes the unsigned base64 `transaction` for each update. ```js // Example JSON response (update limit order): @@ -240,10 +248,14 @@ jup perps close --order # Cancel a TP/SL order jup perps close --tpsl + +# Dry-run +jup perps close --position --dry-run ``` - `--receive` defaults to the position's collateral token; must be USDC or the market token (e.g. BTC for a BTC position) - `--size` for partial close; omit to close entirely +- `--dry-run` previews the close without signing, showing PnL, fees, and received amount. JSON response includes the unsigned base64 `transaction`. ```js // Example JSON response (close/decrease position): diff --git a/docs/predictions.md b/docs/predictions.md index 389a23c..eede8ed 100644 --- a/docs/predictions.md +++ b/docs/predictions.md @@ -89,12 +89,14 @@ jup predictions positions --position jup predictions open --market --side yes --amount 10 jup predictions open --market --side no --amount 5 --input USDC jup predictions open --market --side y --amount 10 --key mykey +jup predictions open --market --side yes --amount 10 --dry-run ``` - `--market`: market ID from `jup predictions events` - `--side`: `yes`, `no`, `y`, `n` - `--amount`: input token amount (human-readable) - `--input`: input token symbol or mint (default: `USDC`) +- `--dry-run` previews the order without signing, showing cost, fees, and payout. JSON response includes the unsigned base64 `transaction`. ```js // Example JSON response: @@ -123,11 +125,15 @@ jup predictions close --position # Close all positions jup predictions close --position all + +# Dry-run +jup predictions close --position --dry-run ``` - The CLI auto-detects whether to sell or claim based on the market result - Claimable positions (market resolved in your favor) are claimed for the full payout - Open positions on live markets are sold at the current market price +- `--dry-run` previews the close without signing. JSON response includes the unsigned base64 `transaction`. ```js // Example JSON response (close): diff --git a/docs/spot.md b/docs/spot.md index 4107c1e..2cc3ddf 100644 --- a/docs/spot.md +++ b/docs/spot.md @@ -55,10 +55,12 @@ jup spot quote --from --to --raw-amount 1000000000 jup spot swap --from SOL --to USDC --amount 1 jup spot swap --from SOL --to USDC --amount 1 --key mykey jup spot swap --from SOL --to USDC --amount 1 --slippage 50 +jup spot swap --from SOL --to USDC --amount 1 --dry-run ``` - `--key` overrides the active key for this transaction - `--slippage` sets max slippage in basis points (e.g. `50` = 0.5%). Recommended to be emtpy: Jupiter's Real-Time Slippage Estimation (RTSE) automatically picks an optimal value. +- `--dry-run` previews the swap without signing or submitting. The API still simulates the transaction, so errors like insufficient balance are caught. In JSON mode, the response includes the unsigned base64 `transaction` for external signing. ```js // Example JSON response: @@ -113,9 +115,11 @@ jup spot portfolio --address jup spot reclaim jup spot reclaim --key mykey jup spot reclaim --token USDC +jup spot reclaim --dry-run ``` - With no options, reclaims rent from all empty Associated Token Accounts (ATA) owned by the active key's wallet +- `--dry-run` previews reclaimable amount without executing. JSON response includes the unsigned base64 `transactions` array. ```js // Example JSON response: @@ -171,10 +175,12 @@ jup spot transfer --token SOL --to --amount 1 jup spot transfer --token USDC --to --amount 50 jup spot transfer --token --to --raw-amount 1000000000 jup spot transfer --token SOL --to --amount 1 --key mykey +jup spot transfer --token SOL --to --amount 1 --dry-run ``` - Works with both SOL and any SPL token - `--token` accepts a symbol or mint address +- `--dry-run` previews the transfer without signing. JSON response includes the unsigned base64 `transaction`. ```js // Example JSON response: @@ -191,11 +197,11 @@ jup spot transfer --token SOL --to --amount 1 --key mykey ## Workflows -### Check price then swap +### Dry-run then swap ```bash -jup spot quote --from SOL --to USDC --amount 1 -# Review the quoted output and price impact +jup spot swap --from SOL --to USDC --amount 1 --dry-run +# Review the output, fees, and simulation result jup spot swap --from SOL --to USDC --amount 1 ``` diff --git a/src/commands/LendCommand.ts b/src/commands/LendCommand.ts index 107cdb2..ff30710 100644 --- a/src/commands/LendCommand.ts +++ b/src/commands/LendCommand.ts @@ -236,11 +236,47 @@ export class LendCommand { rawAmount: opts.rawAmount, }); + const apyPct = this.rateToPct(lendToken.totalRate); + + if (Config.dryRun) { + if (Output.isJson()) { + Output.json({ + dryRun: true, + token: { + id: lendToken.assetAddress, + symbol: lendToken.asset.symbol, + decimals: lendToken.asset.decimals, + }, + depositedAmount: swap.inAmount, + depositedUsd: swap.order.inUsdValue, + apyPct, + signature: null, + transaction: swap.order.transaction, + }); + return; + } + + console.log(Output.DRY_RUN_LABEL); + Output.table({ + type: "vertical", + rows: [ + { + label: "Deposited", + value: `${swap.inAmount} ${lendToken.asset.symbol} (${Output.formatDollar(swap.order.inUsdValue)})`, + }, + { + label: "APY", + value: Output.formatPercentageChange(apyPct), + }, + ], + }); + return; + } + const { positionAmount, price } = await this.fetchCurrentPosition( signer.address, lendToken ); - const apyPct = this.rateToPct(lendToken.totalRate); if (Output.isJson()) { Output.json({ @@ -254,7 +290,7 @@ export class LendCommand { positionAmount, positionUsd: positionAmount * price, apyPct, - signature: swap.result.signature, + signature: swap.result!.signature, }); return; } @@ -276,7 +312,7 @@ export class LendCommand { }, { label: "Tx Signature", - value: swap.result.signature, + value: swap.result!.signature, }, ], }); @@ -336,11 +372,47 @@ export class LendCommand { rawAmount, }); + const apyPct = this.rateToPct(lendToken.totalRate); + + if (Config.dryRun) { + if (Output.isJson()) { + Output.json({ + dryRun: true, + token: { + id: lendToken.assetAddress, + symbol: lendToken.asset.symbol, + decimals: lendToken.asset.decimals, + }, + withdrawnAmount: swap.outAmount, + withdrawnUsd: swap.order.outUsdValue, + apyPct, + signature: null, + transaction: swap.order.transaction, + }); + return; + } + + console.log(Output.DRY_RUN_LABEL); + Output.table({ + type: "vertical", + rows: [ + { + label: "Withdrawn", + value: `${swap.outAmount} ${lendToken.asset.symbol} (${Output.formatDollar(swap.order.outUsdValue)})`, + }, + { + label: "APY", + value: Output.formatPercentageChange(apyPct), + }, + ], + }); + return; + } + const { positionAmount, price } = await this.fetchCurrentPosition( signer.address, lendToken ); - const apyPct = this.rateToPct(lendToken.totalRate); if (Output.isJson()) { Output.json({ @@ -354,7 +426,7 @@ export class LendCommand { positionAmount, positionUsd: positionAmount * price, apyPct, - signature: swap.result.signature, + signature: swap.result!.signature, }); return; } @@ -376,7 +448,7 @@ export class LendCommand { }, { label: "Tx Signature", - value: swap.result.signature, + value: swap.result!.signature, }, ], }); diff --git a/src/commands/PerpsCommand.ts b/src/commands/PerpsCommand.ts index 30780c1..b5c0872 100644 --- a/src/commands/PerpsCommand.ts +++ b/src/commands/PerpsCommand.ts @@ -2,7 +2,7 @@ import type { Base64EncodedBytes } from "@solana/kit"; import chalk from "chalk"; import type { Command } from "commander"; -import { PerpsClient, type ExecuteResponse } from "../clients/PerpsClient.ts"; +import { PerpsClient } from "../clients/PerpsClient.ts"; import { Asset, resolveAsset } from "../lib/Asset.ts"; import { Config } from "../lib/Config.ts"; import { NumberConverter } from "../lib/NumberConverter.ts"; @@ -87,7 +87,10 @@ export class PerpsCommand { signer: Signer, action: string, serializedTxBase64: string - ): Promise { + ): Promise<{ action: string; txid: string | null }> { + if (Config.dryRun) { + return { action, txid: null }; + } const signedTx = await signer.signTransaction( serializedTxBase64 as Base64EncodedBytes ); @@ -354,6 +357,7 @@ export class PerpsCommand { if (Output.isJson()) { Output.json({ + ...(Config.dryRun && { dryRun: true }), type: "limit-order", positionPubkey: res.positionPubkey, asset, @@ -362,10 +366,16 @@ export class PerpsCommand { sizeUsd: NumberConverter.fromMicroUsd(res.quote.sizeUsdDelta), leverage: Number(res.quote.leverage), signature: result.txid, + ...(Config.dryRun && { + transaction: res.serializedTxBase64, + }), }); return; } + if (Config.dryRun) { + console.log(Output.DRY_RUN_LABEL); + } Output.table({ type: "vertical", rows: [ @@ -383,7 +393,9 @@ export class PerpsCommand { ), }, { label: "Leverage", value: `${res.quote.leverage}x` }, - { label: "Tx Signature", value: result.txid }, + ...(!Config.dryRun + ? [{ label: "Tx Signature", value: result.txid! }] + : []), ], }); } else { @@ -428,6 +440,7 @@ export class PerpsCommand { if (Output.isJson()) { Output.json({ + ...(Config.dryRun && { dryRun: true }), type: "market-order", positionPubkey: res.positionPubkey, asset, @@ -442,10 +455,16 @@ export class PerpsCommand { ), openFeeUsd: NumberConverter.fromMicroUsd(res.quote.openFeeUsd), signature: result.txid, + ...(Config.dryRun && { + transaction: res.serializedTxBase64, + }), }); return; } + if (Config.dryRun) { + console.log(Output.DRY_RUN_LABEL); + } Output.table({ type: "vertical", rows: [ @@ -477,7 +496,9 @@ export class PerpsCommand { NumberConverter.fromMicroUsd(res.quote.openFeeUsd) ), }, - { label: "Tx Signature", value: result.txid }, + ...(!Config.dryRun + ? [{ label: "Tx Signature", value: result.txid! }] + : []), ], }); } @@ -525,13 +546,20 @@ export class PerpsCommand { if (Output.isJson()) { Output.json({ + ...(Config.dryRun && { dryRun: true }), action: "update-limit-order", triggerPriceUsd: Number(opts.limit), signature: result.txid, + ...(Config.dryRun && { + transaction: res.serializedTxBase64, + }), }); return; } + if (Config.dryRun) { + console.log(Output.DRY_RUN_LABEL); + } Output.table({ type: "vertical", rows: [ @@ -540,7 +568,9 @@ export class PerpsCommand { label: "New Trigger Price", value: Output.formatDollar(Number(opts.limit)), }, - { label: "Tx Signature", value: result.txid }, + ...(!Config.dryRun + ? [{ label: "Tx Signature", value: result.txid! }] + : []), ], }); return; @@ -560,7 +590,8 @@ export class PerpsCommand { type: string; action: string; triggerPriceUsd: number; - signature: string; + signature: string | null; + transaction?: string; }[] = []; for (const [type, price] of [ @@ -591,6 +622,9 @@ export class PerpsCommand { action: "updated", triggerPriceUsd: Number(price), signature: result.txid, + ...(Config.dryRun && { + transaction: res.serializedTxBase64, + }), }); } else { // Create new @@ -616,19 +650,35 @@ export class PerpsCommand { action: "created", triggerPriceUsd: Number(price), signature: result.txid, + ...(Config.dryRun && { + transaction: res.serializedTxBase64, + }), }); } } if (Output.isJson()) { - Output.json({ action: "set-tpsl", updates: results }); + Output.json({ + ...(Config.dryRun && { dryRun: true }), + action: "set-tpsl", + updates: results, + }); return; } + if (Config.dryRun) { + console.log(Output.DRY_RUN_LABEL); + } for (const r of results) { - console.log( - `${r.type.toUpperCase()} ${r.action} at $${r.triggerPriceUsd} (tx: ${r.signature})` - ); + if (Config.dryRun) { + console.log( + `${r.type.toUpperCase()} ${r.action} at $${r.triggerPriceUsd}` + ); + } else { + console.log( + `${r.type.toUpperCase()} ${r.action} at $${r.triggerPriceUsd} (tx: ${r.signature})` + ); + } } } @@ -664,11 +714,22 @@ export class PerpsCommand { ); if (Output.isJson()) { - Output.json({ action: "cancel-limit-order", signature: result.txid }); + Output.json({ + ...(Config.dryRun && { dryRun: true }), + action: "cancel-limit-order", + signature: result.txid, + ...(Config.dryRun && { + transaction: res.serializedTxBase64, + }), + }); return; } - console.log(`Limit order cancelled (tx: ${result.txid})`); + if (Config.dryRun) { + console.log(`${Output.DRY_RUN_LABEL} Limit order would be cancelled`); + } else { + console.log(`Limit order cancelled (tx: ${result.txid})`); + } return; } @@ -682,11 +743,22 @@ export class PerpsCommand { ); if (Output.isJson()) { - Output.json({ action: "cancel-tpsl", signature: result.txid }); + Output.json({ + ...(Config.dryRun && { dryRun: true }), + action: "cancel-tpsl", + signature: result.txid, + ...(Config.dryRun && { + transaction: res.serializedTxBase64, + }), + }); return; } - console.log(`TP/SL cancelled (tx: ${result.txid})`); + if (Config.dryRun) { + console.log(`${Output.DRY_RUN_LABEL} TP/SL would be cancelled`); + } else { + console.log(`TP/SL cancelled (tx: ${result.txid})`); + } return; } @@ -699,6 +771,23 @@ export class PerpsCommand { throw new Error("No open positions to close."); } + if (Config.dryRun) { + if (Output.isJson()) { + Output.json({ + dryRun: true, + action: "close-all", + signatures: null, + transactions: res.serializedTxs.map((tx) => tx.serializedTxBase64), + }); + return; + } + + console.log( + `${Output.DRY_RUN_LABEL} Would close ${res.serializedTxs.length} position${res.serializedTxs.length !== 1 ? "s" : ""}` + ); + return; + } + const sigs: string[] = []; for (const tx of res.serializedTxs) { const result = await this.signAndExecute( @@ -706,7 +795,7 @@ export class PerpsCommand { "decrease-position", tx.serializedTxBase64 ); - sigs.push(result.txid); + sigs.push(result.txid!); } if (Output.isJson()) { @@ -760,6 +849,7 @@ export class PerpsCommand { if (Output.isJson()) { Output.json({ + ...(Config.dryRun && { dryRun: true }), action: entirePosition ? "close-position" : "decrease-position", positionPubkey: res.positionPubkey, sizeReducedUsd: NumberConverter.fromMicroUsd(res.quote.sizeUsdDelta), @@ -769,10 +859,16 @@ export class PerpsCommand { receivedUsd: NumberConverter.fromMicroUsd(res.quote.transferAmountUsd), feesUsd: NumberConverter.fromMicroUsd(res.quote.totalFeeUsd), signature: result.txid, + ...(Config.dryRun && { + transaction: res.serializedTxBase64, + }), }); return; } + if (Config.dryRun) { + console.log(Output.DRY_RUN_LABEL); + } Output.table({ type: "vertical", rows: [ @@ -800,7 +896,9 @@ export class PerpsCommand { NumberConverter.fromMicroUsd(res.quote.totalFeeUsd) ), }, - { label: "Tx Signature", value: result.txid }, + ...(!Config.dryRun + ? [{ label: "Tx Signature", value: result.txid! }] + : []), ], }); } diff --git a/src/commands/PredictionsCommand.ts b/src/commands/PredictionsCommand.ts index 9de2988..085062f 100644 --- a/src/commands/PredictionsCommand.ts +++ b/src/commands/PredictionsCommand.ts @@ -334,6 +334,7 @@ export class PredictionsCommand { if (Output.isJson()) { Output.json({ + ...(Config.dryRun && { dryRun: true }), action: "open", marketId: opts.market, side, @@ -344,10 +345,14 @@ export class PredictionsCommand { positionPayoutUsd, positionPubkey: order.positionPubkey, signature: result.signature, + ...(Config.dryRun && { transaction: res.transaction }), }); return; } + if (Config.dryRun) { + console.log(Output.DRY_RUN_LABEL); + } Output.table({ type: "vertical", rows: [ @@ -365,7 +370,9 @@ export class PredictionsCommand { value: Output.formatDollar(positionPayoutUsd), }, { label: "Position", value: order.positionPubkey }, - { label: "Tx Signature", value: result.signature }, + ...(!Config.dryRun + ? [{ label: "Tx Signature", value: result.signature! }] + : []), ], }); } @@ -472,7 +479,10 @@ export class PredictionsCommand { private static async signAndExecute( signer: Signer, transaction: string - ): Promise<{ signature: string }> { + ): Promise<{ signature: string | null }> { + if (Config.dryRun) { + return { signature: null }; + } const signedTx = await signer.signTransaction( transaction as Base64EncodedBytes ); @@ -495,7 +505,8 @@ export class PredictionsCommand { const results: { action: string; positionPubkey: string; - signature: string; + signature: string | null; + transaction?: string; }[] = []; for (const item of res.data) { @@ -508,11 +519,32 @@ export class PredictionsCommand { action: isClaim ? "claim" : "close", positionPubkey: pubkey, signature: execResult.signature, + ...(Config.dryRun && { transaction: item.transaction }), }); } if (Output.isJson()) { - Output.json({ action: "close-all", results }); + Output.json({ + ...(Config.dryRun && { dryRun: true }), + action: "close-all", + results, + }); + return; + } + + if (Config.dryRun) { + console.log(Output.DRY_RUN_LABEL); + Output.table({ + type: "horizontal", + headers: { + action: "Action", + positionPubkey: "Position", + }, + rows: results.map((r) => ({ + action: r.action, + positionPubkey: r.positionPubkey, + })), + }); return; } @@ -546,6 +578,7 @@ export class PredictionsCommand { if (Output.isJson()) { Output.json({ + ...(Config.dryRun && { dryRun: true }), action: "claim", event: position.eventMetadata.title, market: position.marketMetadata.title, @@ -554,10 +587,14 @@ export class PredictionsCommand { contracts, payoutUsd, signature: result.signature, + ...(Config.dryRun && { transaction: res.transaction }), }); return; } + if (Config.dryRun) { + console.log(Output.DRY_RUN_LABEL); + } Output.table({ type: "vertical", rows: [ @@ -570,7 +607,9 @@ export class PredictionsCommand { }, { label: "Payout", value: Output.formatDollar(payoutUsd) }, { label: "Position", value: opts.position }, - { label: "Tx Signature", value: result.signature }, + ...(!Config.dryRun + ? [{ label: "Tx Signature", value: result.signature! }] + : []), ], }); } else { @@ -587,6 +626,7 @@ export class PredictionsCommand { if (Output.isJson()) { Output.json({ + ...(Config.dryRun && { dryRun: true }), action: "close", event: position.eventMetadata.title, market: position.marketMetadata.title, @@ -596,10 +636,14 @@ export class PredictionsCommand { costUsd, feeUsd, signature: result.signature, + ...(Config.dryRun && { transaction: res.transaction }), }); return; } + if (Config.dryRun) { + console.log(Output.DRY_RUN_LABEL); + } Output.table({ type: "vertical", rows: [ @@ -613,7 +657,9 @@ export class PredictionsCommand { { label: "Cost", value: Output.formatDollar(costUsd) }, { label: "Fee", value: Output.formatDollar(feeUsd) }, { label: "Position", value: opts.position }, - { label: "Tx Signature", value: result.signature }, + ...(!Config.dryRun + ? [{ label: "Tx Signature", value: result.signature! }] + : []), ], }); } diff --git a/src/commands/SpotCommand.ts b/src/commands/SpotCommand.ts index 8bf9c96..04feb0b 100644 --- a/src/commands/SpotCommand.ts +++ b/src/commands/SpotCommand.ts @@ -266,8 +266,9 @@ export class SpotCommand { if (Output.isJson()) { Output.json({ + ...(Config.dryRun && { dryRun: true }), trader: signer.address, - signature: swap.result.signature, + signature: swap.result?.signature ?? null, inputToken: { id: inputToken.id, symbol: inputToken.symbol, @@ -284,10 +285,16 @@ export class SpotCommand { outUsdValue: swap.order.outUsdValue, priceImpact: swap.order.priceImpact, networkFeeLamports: swap.networkFeeLamports, + ...(Config.dryRun && { + transaction: swap.order.transaction, + }), }); return; } + if (Config.dryRun) { + console.log(Output.DRY_RUN_LABEL); + } Output.table({ type: "vertical", rows: [ @@ -307,10 +314,9 @@ export class SpotCommand { label: "Network Fee", value: `${networkFee} SOL`, }, - { - label: "Tx Signature", - value: swap.result.signature, - }, + ...(!Config.dryRun + ? [{ label: "Tx Signature", value: swap.result!.signature }] + : []), ], }); } @@ -497,6 +503,56 @@ export class SpotCommand { throw new Error(txResponse.error); } + const humanAmount = NumberConverter.fromChainAmount( + chainAmount, + token.decimals, + multiplier + ); + const value = Number(humanAmount) * (token.usdPrice ?? 0); + const networkFee = NumberConverter.fromChainAmount( + txResponse.feeAmount?.toString() ?? 0n, + Asset.SOL.decimals + ); + + if (Config.dryRun) { + if (Output.isJson()) { + Output.json({ + dryRun: true, + sender: signer.address, + recipient: opts.to, + token: { + id: token.id, + symbol: token.symbol, + decimals: token.decimals, + }, + amount: humanAmount, + value: value, + networkFeeLamports: txResponse.feeAmount, + signature: null, + transaction: txResponse.transaction, + }); + return; + } + + console.log(Output.DRY_RUN_LABEL); + Output.table({ + type: "vertical", + rows: [ + { label: "Sender", value: signer.address }, + { label: "Recipient", value: opts.to }, + { + label: "Amount", + value: `${humanAmount} ${token.symbol} (${Output.formatDollar(value)})`, + }, + { + label: "Network Fee", + value: `${networkFee} SOL`, + }, + ], + }); + return; + } + const signedTx = await signer.signTransaction( txResponse.transaction as Base64EncodedBytes ); @@ -505,13 +561,6 @@ export class SpotCommand { signedTransaction: signedTx, }); - const humanAmount = NumberConverter.fromChainAmount( - chainAmount, - token.decimals, - multiplier - ); - const value = Number(humanAmount) * (token.usdPrice ?? 0); - if (Output.isJson()) { Output.json({ sender: signer.address, @@ -529,11 +578,6 @@ export class SpotCommand { return; } - const networkFee = NumberConverter.fromChainAmount( - txResponse.feeAmount?.toString() ?? 0n, - Asset.SOL.decimals - ); - Output.table({ type: "vertical", rows: [ @@ -754,6 +798,50 @@ export class SpotCommand { throw new Error("No reclaimable token accounts found."); } + const reclaimedSol = NumberConverter.fromChainAmount( + netLamportsReclaimed, + Asset.SOL.decimals + ); + const networkFee = NumberConverter.fromChainAmount( + networkFeeLamports, + Asset.SOL.decimals + ); + const accountCount = mints.length - skippedCount; + + if (Config.dryRun) { + if (Output.isJson()) { + Output.json({ + dryRun: true, + totalLamportsReclaimed: Number(netLamportsReclaimed), + totalValueReclaimed: totalValueReclaimed, + networkFeeLamports: Number(networkFeeLamports), + signatures: null, + transactions: allTransactions.map((tx) => tx.transaction), + }); + return; + } + + console.log(Output.DRY_RUN_LABEL); + Output.table({ + type: "vertical", + rows: [ + { + label: "SOL Reclaimed", + value: `${reclaimedSol} SOL (${Output.formatDollar(totalValueReclaimed)})`, + }, + { + label: "Accounts Reclaimed", + value: `${accountCount} token account${accountCount !== 1 ? "s" : ""}`, + }, + { + label: "Network Fee", + value: `${networkFee} SOL`, + }, + ], + }); + return; + } + // Sign and execute each transaction sequentially const signatures: string[] = []; for (const tx of allTransactions) { @@ -780,16 +868,6 @@ export class SpotCommand { return; } - const reclaimedSol = NumberConverter.fromChainAmount( - netLamportsReclaimed, - Asset.SOL.decimals - ); - const networkFee = NumberConverter.fromChainAmount( - networkFeeLamports, - Asset.SOL.decimals - ); - const accountCount = mints.length - skippedCount; - Output.table({ type: "vertical", rows: [ diff --git a/src/index.ts b/src/index.ts index 69b497f..d77a317 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ program .description("Jupiter CLI for agentic workflows") .version(version) .option("-f, --format ", "Output format ('table' or 'json')") + .option("--dry-run", "Preview the transaction without signing or submitting") .hook("preAction", (thisCommand) => { const opts = thisCommand.opts(); if (opts.format) { @@ -29,6 +30,9 @@ program } Output.outputOverride = opts.format; } + if (opts.dryRun) { + Config.dryRun = true; + } }); ConfigCommand.register(program); diff --git a/src/lib/Config.ts b/src/lib/Config.ts index 68fdcf7..eefc0df 100644 --- a/src/lib/Config.ts +++ b/src/lib/Config.ts @@ -14,6 +14,8 @@ const DEFAULT_SETTINGS: Settings = { }; export class Config { + public static dryRun: boolean = false; + public static readonly CONFIG_DIR = join(homedir(), ".config", "jup"); public static readonly SETTINGS_FILE = join(this.CONFIG_DIR, "settings.json"); public static readonly KEYS_DIR = join(this.CONFIG_DIR, "keys"); diff --git a/src/lib/Output.ts b/src/lib/Output.ts index 757a0b6..67d79a2 100644 --- a/src/lib/Output.ts +++ b/src/lib/Output.ts @@ -77,6 +77,8 @@ export class Output { } } + public static readonly DRY_RUN_LABEL = chalk.bold(chalk.yellow("[DRY RUN]")); + public static formatBoolean(value: boolean | undefined): string { return value ? "✅" : "❌"; } diff --git a/src/lib/Swap.ts b/src/lib/Swap.ts index 0951dee..2c27b82 100644 --- a/src/lib/Swap.ts +++ b/src/lib/Swap.ts @@ -6,13 +6,14 @@ import { type GetOrderResponse, type PostExecuteResponse, } from "../clients/UltraClient.ts"; +import { Config } from "./Config.ts"; import { NumberConverter } from "./NumberConverter.ts"; import type { Signer } from "./Signer.ts"; export type SwapResult = { signer: Signer; order: GetOrderResponse; - result: PostExecuteResponse; + result: PostExecuteResponse | null; inputToken: Token; outputToken: Token; inAmount: string; @@ -66,6 +67,46 @@ export class Swap { throw new Error("No valid routes found."); } + let networkFeeLamports = 0; + if ( + order.prioritizationFeePayer === signer.address && + order.prioritizationFeeLamports + ) { + networkFeeLamports = order.prioritizationFeeLamports; + } + if (order.rentFeePayer === signer.address && order.rentFeeLamports) { + networkFeeLamports += order.rentFeeLamports; + } + if ( + order.signatureFeePayer === signer.address && + order.signatureFeeLamports + ) { + networkFeeLamports += order.signatureFeeLamports; + } + + if (Config.dryRun) { + const inAmount = NumberConverter.fromChainAmount( + order.inAmount, + inputToken.decimals, + inputMultiplier + ); + const outAmount = NumberConverter.fromChainAmount( + order.outAmount, + outputToken.decimals, + outputMultiplier + ); + return { + signer, + order, + result: null, + inputToken, + outputToken, + inAmount, + outAmount, + networkFeeLamports, + }; + } + const signedTx = await signer.signTransaction( order.transaction as Base64EncodedBytes ); @@ -85,23 +126,6 @@ export class Swap { outputMultiplier ); - let networkFeeLamports = 0; - if ( - order.prioritizationFeePayer === signer.address && - order.prioritizationFeeLamports - ) { - networkFeeLamports = order.prioritizationFeeLamports; - } - if (order.rentFeePayer === signer.address && order.rentFeeLamports) { - networkFeeLamports += order.rentFeeLamports; - } - if ( - order.signatureFeePayer === signer.address && - order.signatureFeeLamports - ) { - networkFeeLamports += order.signatureFeeLamports; - } - return { signer, order,