diff --git a/.gitignore b/.gitignore index 4684df71..e238a76e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ node_modules # IDE - Cursor .cursor/* +# Nx local cache (never commit) +.nx/ + # misc /.sass-cache /connect.lock diff --git a/apps/agentic-server/src/tools/initiateSwap.ts b/apps/agentic-server/src/tools/initiateSwap.ts index 5c41b5fb..f6263c06 100644 --- a/apps/agentic-server/src/tools/initiateSwap.ts +++ b/apps/agentic-server/src/tools/initiateSwap.ts @@ -213,6 +213,15 @@ async function executeSwapInternal({ const { sellAsset, buyAsset } = await resolveSwapAssets(sellAssetInput, buyAssetInput, walletContext) + // 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) @@ -230,7 +239,18 @@ async function executeSwapInternal({ const needsApproval = allowanceData.isApprovalRequired - await validateSufficientBalance(sellAddress, sellAsset, sellAmountCrypto) + 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 approvalTx = buildApprovalTransaction( needsApproval, @@ -274,7 +294,15 @@ 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: z + .string() + .refine(val => !/^\d{15,}/.test(val.trim()), { + message: + 'sellAmount looks like a base-unit value (15+ digits). Use human-readable token amounts (e.g. "1" for 1 ETH, not "1000000000000000000").', + }) + .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 diff --git a/apps/agentic-server/src/tools/limitOrder/createLimitOrder.ts b/apps/agentic-server/src/tools/limitOrder/createLimitOrder.ts index e8e68134..ceae0742 100644 --- a/apps/agentic-server/src/tools/limitOrder/createLimitOrder.ts +++ b/apps/agentic-server/src/tools/limitOrder/createLimitOrder.ts @@ -4,6 +4,7 @@ import { toBaseUnit } from '@shapeshiftoss/utils' import BigNumber from 'bignumber.js' import { z } from 'zod' +import { getSimplePrices } from '../../lib/asset/coingecko' import { resolveCowTokenAddress } from '../../lib/composableCow' import { COW_VAULT_RELAYER_ADDRESS, prepareCowLimitOrder } from '../../lib/cow' import type { CowOrderSigningData } from '../../lib/cow/types' @@ -21,13 +22,17 @@ export const createLimitOrderSchema = z.object({ network: cowSupportedNetworkSchema.describe('Network for the limit order'), sellAmount: z .string() + .refine(val => !/^\d{15,}/.test(val.trim()), { + message: + 'sellAmount looks like a base-unit value (15+ digits). Use human-readable token amounts (e.g. "230" for 230 ARB, not "230000000000000000000").', + }) .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.' + '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( - '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).' + 'How much buyAsset you receive per 1 sellAsset. NEVER invert — for sub-dollar tokens (e.g. ARB at $0.50 USD selling for USDC), limitPrice ≈ 0.50, NOT 2. "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). Use getAssetPrices if uncertain.' ), expirationHours: z .number() @@ -93,6 +98,54 @@ export async function executeCreateLimitOrder( resolveAsset({ symbolOrName: input.buyAsset, network: input.network }, walletContext), ]) + const limitPriceNum = Number(input.limitPrice) + if (!Number.isFinite(limitPriceNum) || limitPriceNum <= 0) { + throw new Error(`Invalid limitPrice "${input.limitPrice}". It must be a positive number.`) + } + + // Sanity-check limitPrice against current market rate to catch LLM inversion/base-unit errors + const priceResults = await getSimplePrices([sellAsset.assetId, buyAsset.assetId]) + const sellUsdPrice = Number(priceResults.find(p => p.assetId === sellAsset.assetId)?.price ?? '0') + const buyUsdPrice = Number(priceResults.find(p => p.assetId === buyAsset.assetId)?.price ?? '0') + if (sellUsdPrice > 0 && buyUsdPrice > 0) { + const marketLimitPrice = sellUsdPrice / buyUsdPrice + const ratio = limitPriceNum / marketLimitPrice + if (!Number.isFinite(ratio) || ratio <= 0) { + throw new Error( + `Invalid limitPrice "${input.limitPrice}" for market comparison. ` + + `Expected a positive ${buyAsset.symbol}/${sellAsset.symbol} price.` + ) + } + const logRatio = Math.abs(Math.log10(ratio)) + const isNearUsdPrice = (usdPrice: number) => usdPrice > 0 && Math.abs(limitPriceNum - usdPrice) / usdPrice <= 0.25 + + // Guard likely "USD price leaked into pair price" mistakes. + // Example: ARB->EUL should be ~0.086 EUL/ARB, but passing 1.39 (EUL USD) is >10x off. + if (logRatio > 1 && (isNearUsdPrice(sellUsdPrice) || isNearUsdPrice(buyUsdPrice))) { + throw new Error( + `limitPrice ${input.limitPrice} appears to be a USD token price, not the pair price. ` + + `Expected approximately ${marketLimitPrice.toFixed(6)} ${buyAsset.symbol}/${sellAsset.symbol} ` + + `(1 ${sellAsset.symbol} = X ${buyAsset.symbol}).` + ) + } + + if (logRatio > 3) { + throw new Error( + `limitPrice ${input.limitPrice} is more than 1000× from the market rate (~${marketLimitPrice.toFixed(6)} ${buyAsset.symbol}/${sellAsset.symbol}). ` + + `Did you invert the price or pass a base-unit value? For ${sellAsset.symbol} at $${sellUsdPrice} selling for ${buyAsset.symbol}, limitPrice should be ~${marketLimitPrice.toFixed(6)}.` + ) + } + if (logRatio > 1) { + console.warn('[createLimitOrder] limitPrice sanity check: suspicious deviation', { + inputLimitPrice: input.limitPrice, + marketLimitPrice, + ratio, + sellAsset: sellAsset.symbol, + buyAsset: buyAsset.symbol, + }) + } + } + // Get numeric chain ID directly from network (Zod schema guarantees valid network) const evmChainId = NETWORK_TO_CHAIN_ID[input.network]! @@ -198,4 +251,20 @@ IMPORTANT: - For percentage-based requests ("sell when up X%"), compute limitPrice = currentPricePerToken × (1 + X/100) using getAssetPrices and the maths tool`, inputSchema: createLimitOrderSchema, execute: executeCreateLimitOrder, + experimental_toToolResultContent: (result: CreateLimitOrderOutput) => { + const llmSigningData: Pick = { + domain: result.signingData.domain, + types: result.signingData.types, + primaryType: result.signingData.primaryType, + } + const llmVisible = { + summary: result.summary, + signingData: llmSigningData, + needsApproval: result.needsApproval, + approvalTx: result.approvalTx, + approvalTarget: result.approvalTarget, + trackingUrl: result.trackingUrl, + } + return [{ type: 'text' as const, text: JSON.stringify(llmVisible) }] + }, } diff --git a/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts b/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts index d66ec251..261da06a 100644 --- a/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts +++ b/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts @@ -43,8 +43,12 @@ export const createStopLossSchema = z.object({ network: cowSupportedNetworkSchema.describe('Network for the stop-loss order'), sellAmount: z .string() + .refine(val => !/^\d{15,}/.test(val.trim()), { + message: + 'sellAmount looks like a base-unit value (15+ digits). Use human-readable token amounts (e.g. "1" for 1 WETH, not "1000000000000000000").', + }) .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.' + '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() @@ -318,4 +322,22 @@ IMPORTANT: - Use the maths tool if you need to calculate trigger prices from percentages`, inputSchema: createStopLossSchema, execute: executeCreateStopLoss, + experimental_toToolResultContent: (result: CreateStopLossOutput) => { + const llmVisible = { + summary: result.summary, + safeTransaction: result.safeTransaction, + needsApproval: result.needsApproval, + approvalTx: result.approvalTx, + approvalTarget: result.approvalTarget, + safeAddress: result.safeAddress, + orderHash: result.orderHash, + conditionalOrderParams: result.conditionalOrderParams, + needsDeposit: result.needsDeposit, + depositTx: result.depositTx, + sellTokenAddress: result.sellTokenAddress, + buyTokenAddress: result.buyTokenAddress, + validTo: result.validTo, + } + return [{ type: 'text' as const, text: JSON.stringify(llmVisible) }] + }, } diff --git a/apps/agentic-server/src/tools/twap/createTwap.ts b/apps/agentic-server/src/tools/twap/createTwap.ts index 381431f8..c7368dcd 100644 --- a/apps/agentic-server/src/tools/twap/createTwap.ts +++ b/apps/agentic-server/src/tools/twap/createTwap.ts @@ -38,8 +38,12 @@ export const createTwapSchema = z.object({ network: cowSupportedNetworkSchema.describe('Network for the TWAP/DCA order'), totalAmount: z .string() + .refine(val => !/^\d{15,}/.test(val.trim()), { + message: + 'totalAmount looks like a base-unit value (15+ digits). Use human-readable token amounts (e.g. "1000" for 1000 USDC, not "1000000000").', + }) .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.' + '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() @@ -271,4 +275,23 @@ IMPORTANT: - Native tokens (ETH) must be wrapped (WETH) to sell`, inputSchema: createTwapSchema, execute: executeCreateTwap, + experimental_toToolResultContent: (result: CreateTwapOutput) => { + const llmVisible = { + summary: result.summary, + safeTransaction: result.safeTransaction, + needsApproval: result.needsApproval, + approvalTx: result.approvalTx, + approvalTarget: result.approvalTarget, + safeAddress: result.safeAddress, + orderHash: result.orderHash, + conditionalOrderParams: result.conditionalOrderParams, + needsDeposit: result.needsDeposit, + depositTx: result.depositTx, + sellTokenAddress: result.sellTokenAddress, + buyTokenAddress: result.buyTokenAddress, + durationSeconds: result.durationSeconds, + warnings: result.warnings, + } + return [{ type: 'text' as const, text: JSON.stringify(llmVisible) }] + }, }