diff --git a/apps/agentic-server/src/routes/chat.ts b/apps/agentic-server/src/routes/chat.ts index 2b08de71..07d331ed 100644 --- a/apps/agentic-server/src/routes/chat.ts +++ b/apps/agentic-server/src/routes/chat.ts @@ -46,6 +46,7 @@ import type { SafeChainDeployment, WalletContext, } from '../utils/walletContextSimple' +import { wrapTools } from '../utils/wrapTools' const allEvmChainIds = [ ethChainId, @@ -60,31 +61,6 @@ const allEvmChainIds = [ const allSupportedChainIds = [...allEvmChainIds, solanaChainId] -function wrapTool unknown>( - name: string, - tool: { description: string; inputSchema: TSchema; execute: TExecute }, - walletContext?: WalletContext -) { - return { - description: tool.description, - inputSchema: tool.inputSchema, - execute: (args: Parameters[0]) => { - console.log(`[Tool] ${name}:`, JSON.stringify(args, null, 2)) - return tool.execute(args, walletContext) - }, - } -} - -function wrapTools( - tools: Record< - string, - { description: string; inputSchema: unknown; execute: (args: never, walletContext?: WalletContext) => unknown } - >, - walletContext?: WalletContext -) { - return Object.fromEntries(Object.entries(tools).map(([name, tool]) => [name, wrapTool(name, tool, walletContext)])) -} - function buildWalletContext( evmAddress?: string, solanaAddress?: string, @@ -380,17 +356,17 @@ If unsure whether a number is USD or tokens, ask the user. When a user requests a limit order based on a percentage change (e.g., "sell when price goes up X%", "buy if it drops X%"): -1. Call getAssetPrices to get the current USD price per token -2. Call mathCalculator: limitPrice = currentPricePerToken × (1 + percentage / 100) for increases, or × (1 - percentage / 100) for decreases +1. Call getAssetPrices for both the sell and buy assets. +2. Call mathCalculator: currentPairPrice = sellAssetUsdPrice / buyAssetUsdPrice. Then limitPrice = currentPairPrice × (1 + percentage / 100) for increases, or × (1 - percentage / 100) for decreases. 3. Pass the computed limitPrice to createLimitOrder -"Sell FOX when it goes up 2%" — FOX current price = $0.0065 -limitPrice = 0.0065 × 1.02 = 0.00663 -Do NOT use the total portfolio value or USD amount — limitPrice is always per-token. +"Sell FOX for USDC when it goes up 2%" — FOX = $0.0065, USDC = $1 +limitPrice = (0.0065 / 1) × 1.02 = 0.00663 USDC per FOX +For a crypto-to-crypto pair, divide by the buy token USD price too. Do NOT use the total portfolio value — limitPrice is always buy tokens per sell token. -Sanity check: if your computed limitPrice differs from the current market price by more than 100×, stop and confirm with the user before submitting. +If the tool flags an inverted, USD-like, or distant target, ask the user to confirm the exact price in buy tokens per sell token. Do not automatically change the price or retry with priceConfirmed=true. Use priceConfirmed=true only after the user explicitly confirms the flagged target. @@ -554,8 +530,10 @@ export async function handleChatRequest(c: Context) { knownTransactions ) - // Convert UIMessages to ModelMessages - const modelMessages = convertToModelMessages(messages as Parameters[0]) + const tools = buildTools(walletContext) + + // Apply the same output filtering to previous turns as to live tool results. + const modelMessages = convertToModelMessages(messages as Parameters[0], { tools }) const result = streamText({ model: getModel(), @@ -563,7 +541,7 @@ export async function handleChatRequest(c: Context) { system: buildSystemPrompt(evmAddress, solanaAddress, approvedChainIds, safeDeploymentState), temperature: 0.3, stopWhen: stepCountIs(5), - tools: buildTools(walletContext), + tools, // Venice-specific parameters to disable reasoning for faster responses ...(getProviderName() === 'venice' && { providerOptions: { diff --git a/apps/agentic-server/src/tools/initiateSwap.ts b/apps/agentic-server/src/tools/initiateSwap.ts index 5c41b5fb..1e25a987 100644 --- a/apps/agentic-server/src/tools/initiateSwap.ts +++ b/apps/agentic-server/src/tools/initiateSwap.ts @@ -1,6 +1,7 @@ import { fromAssetId } from '@shapeshiftoss/caip' import type { Asset, GetRateOutput } from '@shapeshiftoss/types' import { toBigInt, toBaseUnit } from '@shapeshiftoss/utils' +import BigNumber from 'bignumber.js' import { encodeFunctionData, erc20Abi, getAddress } from 'viem' import { z } from 'zod' @@ -14,6 +15,7 @@ import { isEvmChain } from '../utils/chains/helpers' import { getBebopRate } from '../utils/getBebopRate' import { getRelayRate } from '../utils/getRelayRate' import { networkToFeeSymbol } from '../utils/networkHelpers' +import { tokenAmountSchema, tokenAmountToBaseUnit } from '../utils/tokenAmount' import { createTransaction } from '../utils/transactionHelpers' import { getAddressForChain } from '../utils/walletContextSimple' import type { WalletContext } from '../utils/walletContextSimple' @@ -207,22 +209,44 @@ async function executeSwapInternal({ sellAmountCrypto: string walletContext?: WalletContext }): Promise> { - if (!Number.isFinite(parseFloat(sellAmountCrypto)) || parseFloat(sellAmountCrypto) <= 0) { - throw new Error('Sell amount must be a positive number') - } + sellAmountCrypto = tokenAmountSchema.parse(sellAmountCrypto) const { sellAsset, buyAsset } = await resolveSwapAssets(sellAssetInput, buyAssetInput, walletContext) + const sellAmountBaseUnit = tokenAmountToBaseUnit(sellAmountCrypto, sellAsset) + + // Guard likely USD-vs-token amount mismatches for expensive assets. + // Example mistake: entering "100" for ETH when intent was "$100 worth of ETH". + const sellAssetPrice = parseFloat(sellAsset.price || '0') + const sellAmountNum = parseFloat(sellAmountCrypto) + const sellValueUsd = sellAssetPrice > 0 ? sellAmountNum * sellAssetPrice : 0 + const hasCurrencyLikePrecision = /^\d+(\.\d{1,2})?$/.test(sellAmountCrypto.trim()) + const looksLikeUsdAsTokenAmount = + hasCurrencyLikePrecision && sellAssetPrice >= 10 && sellValueUsd >= 50_000 && sellAmountNum <= 100_000 + const sellAddress = getAddressForChain(walletContext, sellAsset.chainId) const buyAddress = getAddressForChain(walletContext, buyAsset.chainId) validateAddress(sellAddress, sellAsset.chainId) validateAddress(buyAddress, buyAsset.chainId) + try { + await validateSufficientBalance(sellAddress, sellAsset, sellAmountCrypto) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (looksLikeUsdAsTokenAmount && message.includes('Insufficient')) { + throw new Error( + `${message} This request may be using a USD amount as token units. ` + + `If you meant a dollar value, use the USD swap flow (e.g. "$${sellAmountCrypto} worth").` + ) + } + throw error + } + const bestRate = await fetchBestSwapRate(sellAddress, buyAddress, sellAsset, buyAsset, sellAmountCrypto) const allowanceData = await getAllowance({ - amount: toBaseUnit(sellAmountCrypto, sellAsset.precision), + amount: sellAmountBaseUnit, asset: sellAsset, from: sellAddress, spender: bestRate.approvalTarget, @@ -230,8 +254,6 @@ async function executeSwapInternal({ const needsApproval = allowanceData.isApprovalRequired - await validateSufficientBalance(sellAddress, sellAsset, sellAmountCrypto) - const approvalTx = buildApprovalTransaction( needsApproval, sellAsset, @@ -274,7 +296,9 @@ async function executeSwapInternal({ export const initiateSwapSchema = z.object({ sellAsset: assetInputSchema.describe('Asset to sell'), buyAsset: assetInputSchema.describe('Asset to buy'), - sellAmount: z.string().describe('Amount to sell in crypto tokens, e.g. 1 for 1 ETH, 0.5 for 0.5 SOL'), + sellAmount: tokenAmountSchema.describe( + 'Amount to sell in TOKEN units (not USD), e.g. "1" for 1 ETH, "0.5" for 0.5 SOL. Never pass base units (like wei), and do not pass dollar amounts here.' + ), }) export type InitiateSwapInput = z.infer @@ -326,7 +350,9 @@ export async function executeInitiateSwapUsd( throw new Error(`Unable to fetch price for ${sellAsset.symbol}. Price data may be unavailable.`) } - const sellAmountCrypto = (parseFloat(sellAmountUsd) / sellAssetPrice).toString() + const sellAmountCrypto = new BigNumber(tokenAmountSchema.parse(sellAmountUsd)) + .div(sellAssetPrice) + .toFixed(sellAsset.precision, BigNumber.ROUND_DOWN) return executeSwapInternal({ sellAssetInput, diff --git a/apps/agentic-server/src/tools/limitOrder/__tests__/validateLimitPrice.test.ts b/apps/agentic-server/src/tools/limitOrder/__tests__/validateLimitPrice.test.ts new file mode 100644 index 00000000..c9c40bf3 --- /dev/null +++ b/apps/agentic-server/src/tools/limitOrder/__tests__/validateLimitPrice.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test' + +import { validateLimitPrice } from '../validateLimitPrice' + +const arb = { symbol: 'ARB', price: '0.5' } +const usdc = { symbol: 'USDC', price: '1' } + +describe('limit price sanity checks', () => { + test('asks for clarification on the advertised sub-dollar inversion', () => { + expect(() => validateLimitPrice('2', arb, usdc)).toThrow('inverted pair price') + expect(() => validateLimitPrice('2', arb, usdc)).toThrow('confirm the exact target in USDC per 1 ARB') + expect(() => validateLimitPrice('2', arb, usdc, true)).not.toThrow() + }) + + test('accepts the market rate and ordinary percentage targets', () => { + for (const price of ['0.5', '0.525', '0.45']) expect(() => validateLimitPrice(price, arb, usdc)).not.toThrow() + // ARB $0.12 / EUL $1.39, with a 5% increase in the pair rate. + expect(() => + validateLimitPrice('0.09064748201438849', { symbol: 'ARB', price: '0.12' }, { symbol: 'EUL', price: '1.39' }) + ).not.toThrow() + }) + + test('flags USD prices used as crypto-to-crypto pair prices', () => { + const sell = { symbol: 'ARB', price: '0.12' } + const buy = { symbol: 'EUL', price: '1.39' } + expect(() => validateLimitPrice('1.39', sell, buy)).toThrow('USD token price') + expect(() => validateLimitPrice('1.39', sell, buy, true)).not.toThrow() + }) + + test('allows explicitly confirmed distant future targets', () => { + expect(() => validateLimitPrice('600', arb, usdc)).toThrow('more than 10×') + expect(() => validateLimitPrice('600', arb, usdc, true)).not.toThrow() + expect(() => validateLimitPrice('0.0001', arb, usdc)).toThrow('more than 10×') + expect(() => validateLimitPrice('0.0001', arb, usdc, true)).not.toThrow() + }) + + test('does not round tiny pair rates to zero in guidance', () => { + const sell = { symbol: 'SMALL', price: '0.000001' } + const buy = { symbol: 'WETH', price: '3000' } + expect(() => validateLimitPrice('0.000001', sell, buy)).toThrow('3.3333333e-10') + expect(() => validateLimitPrice('0.000000000333333333', sell, buy)).not.toThrow() + }) + + test('rejects invalid prices regardless of market data or confirmation', () => { + for (const value of ['0', '-1', 'NaN', 'Infinity', '1 USDC', '']) { + expect(() => validateLimitPrice(value, arb, usdc, true)).toThrow('positive number') + expect(() => validateLimitPrice(value, { symbol: 'ARB', price: '0' }, usdc)).toThrow('positive number') + } + }) + + test('does not infer an inversion from unavailable prices', () => { + for (const price of ['0', 'NaN', 'Infinity', '-1']) { + expect(() => validateLimitPrice('0.5', { symbol: 'ARB', price }, usdc)).not.toThrow() + expect(() => validateLimitPrice('0.5', arb, { symbol: 'USDC', price })).not.toThrow() + } + }) +}) diff --git a/apps/agentic-server/src/tools/limitOrder/createLimitOrder.ts b/apps/agentic-server/src/tools/limitOrder/createLimitOrder.ts index f44585a5..00e2ab41 100644 --- a/apps/agentic-server/src/tools/limitOrder/createLimitOrder.ts +++ b/apps/agentic-server/src/tools/limitOrder/createLimitOrder.ts @@ -12,22 +12,30 @@ import type { TransactionData } from '../../lib/schemas/swapSchemas' import { getAllowance } from '../../utils' import { buildApprovalTransaction } from '../../utils/approvalHelpers' import { isNativeToken, resolveAsset } from '../../utils/assetHelpers' +import { validateSufficientBalance } from '../../utils/balanceHelpers' +import { tokenAmountSchema, tokenAmountToBaseUnit } from '../../utils/tokenAmount' import { getAddressForChain } from '../../utils/walletContextSimple' import type { WalletContext } from '../../utils/walletContextSimple' +import { validateLimitPrice } from './validateLimitPrice' + export const createLimitOrderSchema = z.object({ sellAsset: z.string().describe('Token symbol or name to sell (e.g., "USDC", "WETH")'), buyAsset: z.string().describe('Token symbol or name to buy (e.g., "USDC", "WETH")'), network: cowSupportedNetworkSchema.describe('Network for the limit order'), - sellAmount: z + sellAmount: tokenAmountSchema.describe( + 'Amount to sell in TOKEN units, not USD (e.g., "100" for 100 USDC, "230" for 230 ARB). Never pass base units even if precision is 18 (e.g., not "230000000000000000000"). If the user specified a USD dollar amount, convert to token units first using getAssetPricesTool and mathCalculatorTool.' + ), + limitPrice: z .string() .describe( - 'Amount to sell in TOKEN units, not USD (e.g., "100" for 100 USDC, "0.5" for 0.5 WETH). If the user specified a USD dollar amount, convert to token units first using getAssetPricesTool and mathCalculatorTool.' + 'How much buyAsset you receive per 1 sellAsset. Do not invert the pair rate. For ARB at $0.50 USD selling for USDC at $1, the current pair rate is 0.50 USDC/ARB; 2 is a different future target. "sell A when worth X B" → limitPrice=X. Example: "worth 2 USDT" → "2". For percentage-based requests ("sell when up 5%"), compute: (sellAssetUsdPrice / buyAssetUsdPrice) × (1 + pct/100). Use getAssetPrices if uncertain.' ), - limitPrice: z - .string() + priceConfirmed: z + .boolean() + .optional() .describe( - 'How much buyAsset you receive per 1 sellAsset. "sell A when worth X B" → limitPrice=X. Example: "worth 2 USDT" → "2". For percentage-based requests ("sell when up 5%"), compute: currentPricePerToken × (1 + pct/100).' + 'Set true only after asking the user to confirm a flagged price and receiving explicit confirmation of the exact buyAsset-per-sellAsset target. Never set this automatically to bypass a validation error.' ), expirationHours: z .number() @@ -95,6 +103,10 @@ export async function executeCreateLimitOrder( resolveAsset({ symbolOrName: input.buyAsset, network: input.network }, walletContext), ]) + const sellAmountBaseUnit = tokenAmountToBaseUnit(input.sellAmount, sellAsset) + + validateLimitPrice(input.limitPrice, sellAsset, buyAsset, input.priceConfirmed) + // Get numeric chain ID directly from network (Zod schema guarantees valid network) const evmChainId = NETWORK_TO_CHAIN_ID[input.network]! @@ -120,12 +132,13 @@ export async function executeCreateLimitOrder( const buyToken = resolveCowTokenAddress(buyAsset, isNativeBuyToken) // Calculate amounts in base units - const sellAmountBaseUnit = toBaseUnit(input.sellAmount, sellAsset.precision) const buyAmountBaseUnit = calculateBuyAmount(buyAsset, input.sellAmount, input.limitPrice) // Get approval target (CoW VaultRelayer contract - same address across all chains) const approvalTarget = COW_VAULT_RELAYER_ADDRESS + await validateSufficientBalance(userAddress, sellAsset, input.sellAmount) + // Check allowance for sell token const { isApprovalRequired: needsApproval } = await getAllowance({ amount: sellAmountBaseUnit, @@ -199,7 +212,16 @@ IMPORTANT: - Currently supports: Ethereum, Gnosis, Arbitrum - Order executes automatically when market price reaches limit - If user specifies total amounts (e.g., "10 USDC for 20 USDT"), use the maths tool to calculate limitPrice (20÷10=2) -- For percentage-based requests ("sell when up X%"), compute limitPrice = currentPricePerToken × (1 + X/100) using getAssetPrices and the maths tool`, +- For percentage-based requests ("sell when up X%"), compute limitPrice = (sellAssetUsdPrice / buyAssetUsdPrice) × (1 + X/100) using getAssetPrices and the maths tool +- If a price is flagged, ask the user to confirm the exact buyAsset-per-sellAsset target. Set priceConfirmed only after their explicit confirmation; do not silently substitute the market rate.`, inputSchema: createLimitOrderSchema, execute: executeCreateLimitOrder, + toModelOutput: (result: CreateLimitOrderOutput) => ({ + type: 'text' as const, + value: JSON.stringify({ + summary: result.summary, + needsApproval: result.needsApproval, + trackingUrl: result.trackingUrl, + }), + }), } diff --git a/apps/agentic-server/src/tools/limitOrder/validateLimitPrice.ts b/apps/agentic-server/src/tools/limitOrder/validateLimitPrice.ts new file mode 100644 index 00000000..829e0abf --- /dev/null +++ b/apps/agentic-server/src/tools/limitOrder/validateLimitPrice.ts @@ -0,0 +1,50 @@ +import type { Asset } from '@shapeshiftoss/types' +import BigNumber from 'bignumber.js' + +type PricedAsset = Pick + +export function validateLimitPrice( + value: string, + sellAsset: PricedAsset, + buyAsset: PricedAsset, + priceConfirmed = false +): void { + const price = new BigNumber(value) + if (!price.isFinite() || !price.gt(0)) { + throw new Error(`Invalid limitPrice "${value}". It must be a positive number.`) + } + + const sellUsd = new BigNumber(sellAsset.price ?? '0') + const buyUsd = new BigNumber(buyAsset.price ?? '0') + // Missing prices cannot establish an inversion. Basic validation still applies. + if (!sellUsd.isFinite() || !buyUsd.isFinite() || !sellUsd.gt(0) || !buyUsd.gt(0)) return + + // Cross-multiply comparisons to avoid rounding very small pair prices to zero. + const targetSellUsd = price.times(buyUsd) + const materiallyDifferent = targetSellUsd.gte(sellUsd.times(2)) || targetSellUsd.lte(sellUsd.times('0.5')) + const nearInverse = price.times(sellUsd).minus(buyUsd).abs().lte(buyUsd.times('0.1')) + const farFromMarket = targetSellUsd.gt(sellUsd.times(10)) || targetSellUsd.lt(sellUsd.times('0.1')) + const nearUsdPrice = [sellUsd, buyUsd].some(usd => price.minus(usd).abs().lte(usd.times('0.25'))) + + let reason: string + if (materiallyDifferent && nearInverse) { + reason = 'looks like an inverted pair price' + } else if (farFromMarket && nearUsdPrice) { + reason = 'looks like a USD token price rather than a pair price' + } else if (farFromMarket) { + reason = 'differs from the current pair price by more than 10×' + } else { + return + } + + if (priceConfirmed) return + + const Price = BigNumber.clone({ DECIMAL_PLACES: 80 }) + const marketPrice = new Price(sellUsd).div(buyUsd).toPrecision(8) + throw new Error( + `limitPrice ${value} ${buyAsset.symbol}/${sellAsset.symbol} ${reason}. ` + + `The current market rate is approximately ${marketPrice} ${buyAsset.symbol} per 1 ${sellAsset.symbol}. ` + + `Ask the user to confirm the exact target in ${buyAsset.symbol} per 1 ${sellAsset.symbol}; do not silently change it. ` + + `Only after explicit confirmation, retry with that target and priceConfirmed=true.` + ) +} diff --git a/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts b/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts index d66ec251..71b28f71 100644 --- a/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts +++ b/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts @@ -22,6 +22,7 @@ import { toChecksumAddress } from '../../utils/addressValidation' import { buildApprovalTransaction } from '../../utils/approvalHelpers' import { isNativeToken, resolveAsset } from '../../utils/assetHelpers' import { calculateSafeVaultDeposit } from '../../utils/safeVaultDeposit' +import { tokenAmountSchema, tokenAmountToBaseUnit } from '../../utils/tokenAmount' import { getAddressForChain, getSafeAddressForChain } from '../../utils/walletContextSimple' import type { WalletContext } from '../../utils/walletContextSimple' @@ -41,11 +42,9 @@ export const createStopLossSchema = z.object({ .string() .describe('Token symbol or name to receive (e.g., "USDC", "USDT"). Usually a stablecoin for stop-losses.'), network: cowSupportedNetworkSchema.describe('Network for the stop-loss order'), - sellAmount: z - .string() - .describe( - 'Amount to sell in TOKEN units, not USD (e.g., "1" for 1 WETH, "100000" for 100000 PEPE). No commas, dollar signs, or token symbols. If the user specified a USD dollar amount, convert to token units first using getAssetPricesTool and mathCalculatorTool.' - ), + sellAmount: tokenAmountSchema.describe( + 'Amount to sell in TOKEN units, not USD (e.g., "1" for 1 WETH, "230" for 230 ARB, "100000" for 100000 PEPE). Never pass base units even if precision is 18 (e.g., not "230000000000000000000"). No commas, dollar signs, or token symbols. If the user specified a USD dollar amount, convert to token units first using getAssetPricesTool and mathCalculatorTool.' + ), triggerPrice: z .string() .describe( @@ -114,6 +113,8 @@ export async function executeCreateStopLoss( resolveAsset({ symbolOrName: input.sellAsset, network: input.network }, walletContext), resolveAsset({ symbolOrName: input.buyAsset, network: input.network }, walletContext), ]) + + const sellAmountBaseUnit = tokenAmountToBaseUnit(input.sellAmount, sellAsset) // Validate the user has a connected wallet on this chain getAddressForChain(walletContext, sellAsset.chainId) @@ -180,7 +181,6 @@ export async function executeCreateStopLoss( const sellTokenAddress = resolveCowTokenAddress(sellAsset, false) const buyTokenAddress = resolveCowTokenAddress(buyAsset, isNativeToken(buyAsset)) - const sellAmountBaseUnit = toBaseUnit(input.sellAmount, sellAsset.precision) const buyAmountStr = buyAmountBaseUnit // CoW StopLoss contract normalizes both oracle prices to 18 decimals before comparing: // basePrice * 1e18 / quotePrice <= strike @@ -318,4 +318,12 @@ IMPORTANT: - Use the maths tool if you need to calculate trigger prices from percentages`, inputSchema: createStopLossSchema, execute: executeCreateStopLoss, + toModelOutput: (result: CreateStopLossOutput) => ({ + type: 'text' as const, + value: JSON.stringify({ + summary: result.summary, + needsApproval: result.needsApproval, + needsDeposit: result.needsDeposit, + }), + }), } diff --git a/apps/agentic-server/src/tools/twap/createTwap.ts b/apps/agentic-server/src/tools/twap/createTwap.ts index 381431f8..b1e1b9ad 100644 --- a/apps/agentic-server/src/tools/twap/createTwap.ts +++ b/apps/agentic-server/src/tools/twap/createTwap.ts @@ -22,6 +22,7 @@ import { toChecksumAddress } from '../../utils/addressValidation' import { buildApprovalTransaction } from '../../utils/approvalHelpers' import { isNativeToken, resolveAsset } from '../../utils/assetHelpers' import { calculateSafeVaultDeposit } from '../../utils/safeVaultDeposit' +import { tokenAmountSchema, tokenAmountToBaseUnit } from '../../utils/tokenAmount' import { getAddressForChain, getSafeAddressForChain } from '../../utils/walletContextSimple' import type { WalletContext } from '../../utils/walletContextSimple' @@ -36,11 +37,9 @@ export const createTwapSchema = z.object({ sellAsset: z.string().describe('Token symbol or name to sell (e.g., "USDC", "WETH")'), buyAsset: z.string().describe('Token symbol or name to buy (e.g., "ETH", "WBTC")'), network: cowSupportedNetworkSchema.describe('Network for the TWAP/DCA order'), - totalAmount: z - .string() - .describe( - 'Total amount to sell in TOKEN units, not USD (e.g., "1000" for 1000 USDC, "0.5" for 0.5 WETH). If the user specified a USD dollar amount, convert to token units first using getAssetPricesTool and mathCalculatorTool.' - ), + totalAmount: tokenAmountSchema.describe( + 'Total amount to sell in TOKEN units, not USD (e.g., "1000" for 1000 USDC, "230" for 230 ARB, "0.5" for 0.5 WETH). Never pass base units even if precision is 18 (e.g., not "230000000000000000000"). If the user specified a USD dollar amount, convert to token units first using getAssetPricesTool and mathCalculatorTool.' + ), durationSeconds: z .number() .min(120) @@ -105,6 +104,8 @@ export async function executeCreateTwap( resolveAsset({ symbolOrName: input.sellAsset, network: input.network }, walletContext), resolveAsset({ symbolOrName: input.buyAsset, network: input.network }, walletContext), ]) + + const sellAmountBaseUnit = tokenAmountToBaseUnit(input.totalAmount, sellAsset) getAddressForChain(walletContext, sellAsset.chainId) if (isNativeToken(sellAsset)) { @@ -123,7 +124,6 @@ export async function executeCreateTwap( throw new Error('Trade interval is too short. Minimum interval is 2 minutes between trades.') } - const sellAmountBaseUnit = toBaseUnit(input.totalAmount, sellAsset.precision) const partSellAmount = toBigInt(sellAmountBaseUnit) / BigInt(numParts) if (partSellAmount === 0n) { @@ -271,4 +271,13 @@ IMPORTANT: - Native tokens (ETH) must be wrapped (WETH) to sell`, inputSchema: createTwapSchema, execute: executeCreateTwap, + toModelOutput: (result: CreateTwapOutput) => ({ + type: 'text' as const, + value: JSON.stringify({ + summary: result.summary, + needsApproval: result.needsApproval, + needsDeposit: result.needsDeposit, + warnings: result.warnings ?? [], + }), + }), } diff --git a/apps/agentic-server/src/utils/__tests__/safeVaultAmount.test.ts b/apps/agentic-server/src/utils/__tests__/safeVaultAmount.test.ts new file mode 100644 index 00000000..716b864f --- /dev/null +++ b/apps/agentic-server/src/utils/__tests__/safeVaultAmount.test.ts @@ -0,0 +1,51 @@ +import type { Asset } from '@shapeshiftoss/types' +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' + +import * as balanceHelpers from '../balanceHelpers' +import * as committedBalances from '../committedBalances' +import { calculateSafeVaultDeposit } from '../safeVaultDeposit' + +const safeAddress = '0x0000000000000000000000000000000000000001' +const walletAddress = '0x0000000000000000000000000000000000000002' +const tokenAddress = '0x0000000000000000000000000000000000000003' +const asset = { symbol: 'USDC', precision: 6, chainId: 'eip155:1', assetId: `eip155:1/erc20:${tokenAddress}` } as Asset +let getBalance: ReturnType> +let getCommitted: ReturnType> + +beforeEach(() => { + getBalance = spyOn(balanceHelpers, 'getBalance') + getCommitted = spyOn(committedBalances, 'getCommittedAmountForToken') +}) + +afterEach(() => { + getBalance.mockRestore() + getCommitted.mockRestore() +}) + +const createOrder = (sellAmountBaseUnit: string) => + calculateSafeVaultDeposit({ + walletContext: { connectedWallets: { 'eip155:1': { address: walletAddress } } }, + safeAddress, + sellAsset: asset, + sellAmountBaseUnit, + evmChainId: 1, + sellTokenAddress: tokenAddress, + }) + +describe('Safe order amount checks', () => { + test('counts only uncommitted Safe funds plus wallet funds', async () => { + getCommitted.mockResolvedValue(200_000000n) + getBalance.mockImplementation(address => Promise.resolve(address === safeAddress ? '600000000' : '600000000')) + const result = await createOrder('1000000000') + expect(result.depositAmount).toBe(600_000000n) + expect(result.totalNeeded).toBe(1200_000000n) + }) + + test('rejects a USDC base-unit mistake using the combined available funds', async () => { + getCommitted.mockResolvedValue(200_000000n) + getBalance.mockResolvedValue('600000000') + const error = await createOrder('1000000000000000').catch((error: unknown) => error) + expect(error).toBeInstanceOf(Error) + expect(String(error)).toContain('1000000000 base units = 1000 USDC') + }) +}) diff --git a/apps/agentic-server/src/utils/__tests__/tokenAmount.test.ts b/apps/agentic-server/src/utils/__tests__/tokenAmount.test.ts new file mode 100644 index 00000000..133044b1 --- /dev/null +++ b/apps/agentic-server/src/utils/__tests__/tokenAmount.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test' + +import { initiateSwapSchema } from '../../tools/initiateSwap' +import { createLimitOrderSchema } from '../../tools/limitOrder/createLimitOrder' +import { createStopLossSchema } from '../../tools/stopLoss/createStopLoss' +import { createTwapSchema } from '../../tools/twap/createTwap' +import { assertSufficientTokenBalance, getBaseUnitAmountHint, tokenAmountToBaseUnit } from '../tokenAmount' + +const usdc = { symbol: 'USDC', precision: 6 } +const weth = { symbol: 'WETH', precision: 18 } + +const amountSchemas = { + swap: initiateSwapSchema.shape.sellAmount, + limit: createLimitOrderSchema.shape.sellAmount, + stopLoss: createStopLossSchema.shape.sellAmount, + twap: createTwapSchema.shape.totalAmount, +} + +describe('token amounts', () => { + for (const [name, schema] of Object.entries(amountSchemas)) { + test(`${name} rejects malformed, non-positive and exponential amounts`, () => { + for (const amount of ['1e18', '1e-6', '-1', '+1', '0', '0.00', 'Infinity', 'NaN', '1,000', '1 ETH', '$100', '']) { + expect(schema.safeParse(amount).success).toBe(false) + } + expect(schema.parse(' 0.5 ')).toBe('0.5') + expect(schema.parse('000000000000001')).toBe('000000000000001') + expect(schema.parse('1000000000000000')).toBe('1000000000000000') + }) + } + + test('rejects the USDC and WETH base-unit mistakes with actionable guidance', () => { + expect(() => assertSufficientTokenBalance('1000000000', usdc, '1000000000')).toThrow('1000 USDC') + expect(() => assertSufficientTokenBalance('1000000000000000000', weth, '1000000000000000000')).toThrow('1 WETH') + expect(getBaseUnitAmountHint('1000000000', usdc, '1000000000')).toContain('do not automatically rescale') + }) + + test('accepts large funded amounts without guessing their units', () => { + expect(() => assertSufficientTokenBalance('1000000000000000', usdc, '1000000000000000000000')).not.toThrow() + expect(() => assertSufficientTokenBalance('100', weth, '100000000000000000000')).not.toThrow() + expect(getBaseUnitAmountHint('1000000000000000', usdc, '1000000000000000000000')).toBe('') + }) + + test('preserves precision and rejects amounts that would be rounded', () => { + expect(tokenAmountToBaseUnit('0.000001', usdc)).toBe('1') + expect(tokenAmountToBaseUnit('0.000000000000000001', weth)).toBe('1') + expect(tokenAmountToBaseUnit('000000000000001.0000000', usdc)).toBe('1000000') + expect(() => tokenAmountToBaseUnit('0.0000001', usdc)).toThrow('6 decimal places') + expect(() => tokenAmountToBaseUnit('1e18', weth)).toThrow() + }) + + test('does not suggest a conversion when neither interpretation fits the funds', () => { + expect(() => assertSufficientTokenBalance('1000000000', usdc, '0')).toThrow('Insufficient USDC balance') + expect(getBaseUnitAmountHint('1000000000', usdc, '0')).toBe('') + expect(getBaseUnitAmountHint('100', { symbol: 'WHOLE', precision: 0 }, '50')).toBe('') + }) +}) diff --git a/apps/agentic-server/src/utils/__tests__/toolModelOutput.test.ts b/apps/agentic-server/src/utils/__tests__/toolModelOutput.test.ts new file mode 100644 index 00000000..4d95912f --- /dev/null +++ b/apps/agentic-server/src/utils/__tests__/toolModelOutput.test.ts @@ -0,0 +1,106 @@ +import type { LanguageModelV2, LanguageModelV2CallOptions } from '@ai-sdk/provider' +import { convertToModelMessages, generateText, stepCountIs } from 'ai' +import type { UIMessage } from 'ai' +import { describe, expect, test } from 'bun:test' +import { z } from 'zod' + +import { createLimitOrderTool } from '../../tools/limitOrder/createLimitOrder' +import { createStopLossTool } from '../../tools/stopLoss/createStopLoss' +import { createTwapTool } from '../../tools/twap/createTwap' +import { wrapTools } from '../wrapTools' + +const rawOutput = { + summary: { sellAsset: { symbol: 'USDC', amount: '1000' } }, + needsApproval: true, + needsDeposit: true, + warnings: ['small trade'], + trackingUrl: 'https://explorer.cow.fi', + orderParams: { sellAmount: '1000000000' }, + signingData: { message: { sellAmount: '1000000000' } }, + sellAmountBaseUnit: '1000000000', + sellPrecision: 6, + approvalTx: { data: '0xprivate' }, + depositTx: { data: '0xprivate' }, + safeTransaction: { data: '0xprivate' }, + conditionalOrderParams: { staticInput: '0xprivate' }, +} + +// Exercise the real output hooks with a fixture containing every private field. +const orderTools = { createLimitOrderTool, createStopLossTool, createTwapTool } + +describe('order tool model output', () => { + for (const [name, definition] of Object.entries(orderTools)) { + const tools = wrapTools({ + [name]: { + ...definition, + inputSchema: z.object({}), + execute: () => rawOutput, + toModelOutput: (output: typeof rawOutput) => definition.toModelOutput(output as never), + }, + }) + + test(`${name} filters the next model step without changing the UI result`, async () => { + const calls: LanguageModelV2CallOptions[] = [] + const responses: Awaited>[] = [ + { + content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: name, input: '{}' }], + finishReason: 'tool-calls', + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + warnings: [], + }, + { + content: [{ type: 'text', text: 'Order ready' }], + finishReason: 'stop', + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + warnings: [], + }, + ] + const model: LanguageModelV2 = { + specificationVersion: 'v2', + provider: 'test', + modelId: 'test', + supportedUrls: {}, + doGenerate: options => { + calls.push(options) + return Promise.resolve(responses[calls.length - 1]!) + }, + doStream: () => { + throw new Error('Not used') + }, + } + const result = await generateText({ model, tools, prompt: 'Create an order', stopWhen: stepCountIs(2) }) + expect(result.steps[0]?.toolResults[0]?.output).toEqual(rawOutput) + const prompt = JSON.stringify(calls[1]?.prompt) + expect(prompt).toContain('1000') + expect(prompt).toContain('needsApproval') + expect(prompt).not.toContain('1000000000') + expect(prompt).not.toContain('0xprivate') + expect(prompt).not.toContain('signingData') + }) + + test(`${name} filters replayed history and preserves the stored UI message`, () => { + const messages: UIMessage[] = [ + { + id: 'message-1', + role: 'assistant', + parts: [ + { + type: 'dynamic-tool', + toolName: name, + toolCallId: 'call-1', + state: 'output-available', + input: {}, + output: rawOutput, + }, + ], + }, + ] + const before = JSON.stringify(messages) + const converted = JSON.stringify(convertToModelMessages(messages, { tools })) + expect(converted).toContain('1000') + expect(converted).not.toContain('1000000000') + expect(converted).not.toContain('0xprivate') + expect(JSON.stringify(messages)).toBe(before) + }) + } +}) diff --git a/apps/agentic-server/src/utils/balanceHelpers.ts b/apps/agentic-server/src/utils/balanceHelpers.ts index 6e60536b..fdbeb525 100644 --- a/apps/agentic-server/src/utils/balanceHelpers.ts +++ b/apps/agentic-server/src/utils/balanceHelpers.ts @@ -1,9 +1,10 @@ import type { Asset } from '@shapeshiftoss/types' import { chainIdToNetwork } from '@shapeshiftoss/types' -import { fromBaseUnit, toBigInt, toBaseUnit } from '@shapeshiftoss/utils' import { executeGetAccount } from '../tools/getAccount' +import { assertSufficientTokenBalance } from './tokenAmount' + export async function getBalance(address: string, asset: Asset): Promise { const network = chainIdToNetwork[asset.chainId] ?? 'ethereum' @@ -18,10 +19,5 @@ export async function getBalance(address: string, asset: Asset): Promise export async function validateSufficientBalance(address: string, asset: Asset, requiredAmount: string): Promise { const balance = await getBalance(address, asset) - const requiredAmountBaseUnit = toBaseUnit(requiredAmount, asset.precision) - - if (toBigInt(balance) < toBigInt(requiredAmountBaseUnit)) { - const available = fromBaseUnit(balance, asset.precision) - throw new Error(`Insufficient ${asset.symbol} balance. Required: ${requiredAmount}, Available: ${available}`) - } + assertSufficientTokenBalance(requiredAmount, asset, balance) } diff --git a/apps/agentic-server/src/utils/safeVaultDeposit.ts b/apps/agentic-server/src/utils/safeVaultDeposit.ts index 4f92fcc4..7b41be1e 100644 --- a/apps/agentic-server/src/utils/safeVaultDeposit.ts +++ b/apps/agentic-server/src/utils/safeVaultDeposit.ts @@ -8,6 +8,7 @@ import type { TransactionData } from '../lib/schemas/swapSchemas' import { toChecksumAddress } from './addressValidation' import { getBalance } from './balanceHelpers' import { getCommittedAmountForToken } from './committedBalances' +import { getBaseUnitAmountHint } from './tokenAmount' import { createTransaction } from './transactionHelpers' import { getAddressForChain } from './walletContextSimple' import type { WalletContext } from './walletContextSimple' @@ -56,7 +57,8 @@ export async function calculateSafeVaultDeposit(params: SafeVaultDepositParams): `Insufficient ${sellAsset.symbol} balance. ` + `Required: ${requiredHuman} ${sellAsset.symbol}, ` + `Safe balance: ${safeBalanceHuman} (${committedAmount > 0n ? `${fromBaseUnit(committedAmount.toString(), sellAsset.precision)} committed to active orders` : 'none committed'}), ` + - `Wallet balance: ${eoaBalanceHuman}` + `Wallet balance: ${eoaBalanceHuman}` + + getBaseUnitAmountHint(requiredHuman, sellAsset, (availableSafeBalance + eoaBalanceBigInt).toString()) ) } } diff --git a/apps/agentic-server/src/utils/tokenAmount.ts b/apps/agentic-server/src/utils/tokenAmount.ts new file mode 100644 index 00000000..2660f1be --- /dev/null +++ b/apps/agentic-server/src/utils/tokenAmount.ts @@ -0,0 +1,56 @@ +import type { Asset } from '@shapeshiftoss/types' +import BigNumber from 'bignumber.js' +import { z } from 'zod' + +type AmountAsset = Pick + +export const tokenAmountSchema = z + .string() + .trim() + .regex( + /^\d+(\.\d+)?$/, + 'Use a plain decimal token amount, such as "1" or "0.5", without exponents, signs or separators.' + ) + .refine(value => new BigNumber(value).isFinite() && new BigNumber(value).gt(0), 'Amount must be positive.') + +export function tokenAmountToBaseUnit(value: string, asset: AmountAsset): string { + const amount = new BigNumber(tokenAmountSchema.parse(value)) + if (amount.decimalPlaces()! > asset.precision) { + throw new Error( + `${asset.symbol} supports at most ${asset.precision} decimal places. Use a token amount without rounding.` + ) + } + return amount.shiftedBy(asset.precision).toFixed(0) +} + +// A large integer alone is ambiguous. Only suggest a base-unit mistake when the +// token amount exceeds funds and the base-unit interpretation would fit them. +export function getBaseUnitAmountHint(value: string, asset: AmountAsset, availableBaseUnit: string): string { + const amount = new BigNumber(value) + const available = new BigNumber(availableBaseUnit) + if ( + asset.precision <= 0 || + !amount.isInteger() || + !amount.gt(0) || + amount.gt(available) || + amount.shiftedBy(asset.precision).lte(available) + ) + return '' + + const converted = amount.shiftedBy(-asset.precision).toFixed() + return ( + ` This may be a base-unit amount: ${amount.toFixed()} base units = ${converted} ${asset.symbol}. ` + + `Amounts must be in token units. Ask the user to clarify the amount; do not automatically rescale it.` + ) +} + +export function assertSufficientTokenBalance(value: string, asset: AmountAsset, availableBaseUnit: string): void { + const required = tokenAmountToBaseUnit(value, asset) + if (new BigNumber(required).lte(availableBaseUnit)) return + + const available = new BigNumber(availableBaseUnit).shiftedBy(-asset.precision).toFixed() + throw new Error( + `Insufficient ${asset.symbol} balance. Required: ${value}, Available: ${available}` + + getBaseUnitAmountHint(value, asset, availableBaseUnit) + ) +} diff --git a/apps/agentic-server/src/utils/wrapTools.ts b/apps/agentic-server/src/utils/wrapTools.ts new file mode 100644 index 00000000..962950c4 --- /dev/null +++ b/apps/agentic-server/src/utils/wrapTools.ts @@ -0,0 +1,25 @@ +import type { WalletContext } from './walletContextSimple' + +type WalletTool = { + description: string + inputSchema: unknown + execute: (args: never, walletContext?: WalletContext) => unknown +} + +export function wrapTool(name: string, tool: T, walletContext?: WalletContext) { + return { + ...tool, + execute: (args: Parameters[0]) => { + console.log(`[Tool] ${name}:`, JSON.stringify(args, null, 2)) + return tool.execute(args, walletContext) + }, + } +} + +export function wrapTools>(tools: T, walletContext?: WalletContext) { + return Object.fromEntries( + Object.entries(tools).map(([name, tool]) => [name, wrapTool(name, tool, walletContext)]) + ) as { + [K in keyof T]: ReturnType> + } +}