Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gasless-high-value-bypass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nansen-cli": patch
---

Skip native gas pre-check for trades >= $10 USD, where gasless/solver-paid routes (e.g. Relay) are viable. When gas is insufficient on smaller trades, the error now also suggests increasing the trade value as an alternative to topping up gas.
53 changes: 52 additions & 1 deletion src/__tests__/trade-validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { validateQuoteInput, fetchNativeBalance, fetchTokenBalance, validateBalance, resolvePercentAmount, validateGasBalance } from '../trade-validation.js';
import { validateQuoteInput, fetchNativeBalance, fetchTokenBalance, validateBalance, resolvePercentAmount, validateGasBalance, GASLESS_MIN_TRADE_USD } from '../trade-validation.js';

describe('validateQuoteInput', () => {
const validSolana = {
Expand Down Expand Up @@ -873,6 +873,57 @@ describe('validateGasBalance', () => {
const result = await validateGasBalance({ chain: 'solana', walletAddress: 'SomeWallet1111111111111111111111111111111111' });
expect(result.hasSufficientNative).toBe(true);
});

it('bypasses gas check when trade value is >= GASLESS_MIN_TRADE_USD (gasless eligible)', async () => {
// fetch should NOT be called — the check is skipped before any RPC
global.fetch = vi.fn();

const result = await validateGasBalance({
chain: 'solana',
walletAddress: 'SomeWallet1111111111111111111111111111111111',
tradeValueUsd: String(GASLESS_MIN_TRADE_USD),
});
expect(result.hasSufficientNative).toBe(true);
expect(global.fetch).not.toHaveBeenCalled();
});

it('bypasses gas check when trade value is well above $10', async () => {
global.fetch = vi.fn();

const result = await validateGasBalance({
chain: 'base',
walletAddress: '0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4',
tradeValueUsd: '500',
});
expect(result.hasSufficientNative).toBe(true);
expect(global.fetch).not.toHaveBeenCalled();
});

it('still validates gas for trades below GASLESS_MIN_TRADE_USD', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ jsonrpc: '2.0', id: 1, result: { value: 0 } }),
});

await expect(validateGasBalance({
chain: 'solana',
walletAddress: 'SomeWallet1111111111111111111111111111111111',
tradeValueUsd: String(GASLESS_MIN_TRADE_USD - 0.01),
})).rejects.toThrow(/Insufficient SOL for gas/);
});

it('error message includes gasless suggestion when gas is low', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ jsonrpc: '2.0', id: 1, result: { value: 0 } }),
});

await expect(validateGasBalance({
chain: 'solana',
walletAddress: 'SomeWallet1111111111111111111111111111111111',
tradeValueUsd: '5.00',
})).rejects.toThrow(new RegExp(`\\$${GASLESS_MIN_TRADE_USD}\\+`));
});
});

describe('quote handler balance validation integration', () => {
Expand Down
15 changes: 13 additions & 2 deletions src/trade-validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ const FEE_BUFFER = { solana: 0.005, base: 0.00004 };
const HIGH_PERCENTAGE_THRESHOLD = 95;
const AUTO_ADJUST_THRESHOLD_PERCENT = 2;

// Trades at or above this USD value can use gasless/solver-paid routes (e.g. Relay),
// so the native gas pre-check is skipped for them.
export const GASLESS_MIN_TRADE_USD = 10;

/**
* Check if an address is USDC or the native token for a chain (case-insensitive for EVM).
*/
Expand Down Expand Up @@ -306,12 +310,19 @@ export async function resolvePercentAmount({ chain, from, walletAddress, percent
*
* Returns { hasSufficientNative } or throws on validation failure.
* Best-effort: if RPC fails, returns passing result.
*
* Gasless bypass: trades >= $10 USD can use solver-paid options (e.g. Relay),
* so the gas check is skipped in that case.
*/
export async function validateGasBalance({ chain, walletAddress }) {
export async function validateGasBalance({ chain, walletAddress, tradeValueUsd }) {
const normalizedChain = chain.toLowerCase();
const minGas = MIN_GAS_AMOUNTS[normalizedChain];
if (minGas === undefined) return { hasSufficientNative: true };

// High-value trades can use gasless/solver-paid routes — skip the check.
const tradeUsd = parseFloat(tradeValueUsd) || 0;
if (tradeUsd >= GASLESS_MIN_TRADE_USD) return { hasSufficientNative: true };

const balance = await fetchNativeBalance(normalizedChain, walletAddress);

// RPC failure — proceed without validation.
Expand All @@ -323,7 +334,7 @@ export async function validateGasBalance({ chain, walletAddress }) {

const symbol = NATIVE_SYMBOLS[normalizedChain] || 'native token';
throw new Error(
`Insufficient ${symbol} for gas fees. Wallet has ${balance} ${symbol} but needs at least ${minGas} ${symbol}. Fund the wallet before trading.`
`Insufficient ${symbol} for gas fees. Wallet has ${balance} ${symbol} but needs at least ${minGas} ${symbol}. Either fund the wallet with ${symbol} or trade a value of $${GASLESS_MIN_TRADE_USD}+ to use gasless options.`
);
}

Expand Down
4 changes: 3 additions & 1 deletion src/trading.js
Original file line number Diff line number Diff line change
Expand Up @@ -1331,8 +1331,10 @@ CROSS-CHAIN NOTES (when using --to-chain):
response.quotes.forEach((q, i) => log(formatQuote(q, i)));

// Gas balance validation — check that the wallet has enough native token for gas.
// High-value trades (>= $10) can use gasless/solver-paid routes and bypass this check.
try {
await validateGasBalance({ chain, walletAddress });
const tradeValueUsd = response.quotes[0]?.inUsdValue;
await validateGasBalance({ chain, walletAddress, tradeValueUsd });
} catch (gasErr) {
throw new CommandError(`Error: ${gasErr.message}`, 'INSUFFICIENT_GAS');
}
Expand Down
Loading