From c0a0f203fde668e543a0b07c0267ef91b0cb6bc9 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 12:32:39 +0200 Subject: [PATCH 01/16] fix: oracle sync --- src/llm/client.ts | 60 +++++++++++++++++++---- src/llm/tools.ts | 24 ++++++--- src/routes/oracle.ts | 23 ++++----- src/services/oraclePool.ts | 99 +++++++++++++++++++++++++++++++++----- 4 files changed, 166 insertions(+), 40 deletions(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index c9ab647..833986e 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -78,7 +78,7 @@ import { MAX_SLIPPAGE_BPS, DEFAULT_MAX_DIVERGENCE_PCT, } from "../services/swapShield.js"; -import { buildOraclePoolSwapTx } from "../services/oraclePool.js"; +import { buildOraclePoolSwapTx, getNativeTokenSymbol } from "../services/oraclePool.js"; // Known on-chain error selectors for Rigoblock pool / Across bridge contracts. // These appear as 4-byte hex prefixes in "execution reverted" messages. @@ -2477,13 +2477,7 @@ export async function executeToolCall( throw new Error("'token' is required. Specify the token symbol whose oracle feed is stale (e.g., 'GRG', 'USDC')."); } - const amountEth = args.amountEth as string; - if (!amountEth) { - throw new Error( - "Please specify how much ETH you want to swap on the oracle pool (e.g., '0.001' or '0.01'). " + - "Larger amounts move the price more aggressively and converge the TWAP faster.", - ); - } + const nativeSymbol = getNativeTokenSymbol(ctx.chainId); // Auto-switch chain if provided let oracleChainSwitched: number | undefined; @@ -2495,16 +2489,62 @@ export async function executeToolCall( } } + let amountIn = (args.amountEth as string) || ""; + const amountOut = (args.amountOut as string) || ""; + + // If amountOut is provided instead of amountIn, estimate the required native input + // using the vault's on-chain BackgeoOracle (convertTokenAmount). + if (!amountIn && amountOut && ctx.vaultAddress && env.ALCHEMY_API_KEY) { + try { + const tokenAddr = await resolveTokenAddress(ctx.chainId, tokenArg); + const decimalsOut = await getTokenDecimals(ctx.chainId, tokenAddr, env.ALCHEMY_API_KEY); + const desiredOutRaw = parseUnits(amountOut, decimalsOut); + const publicClient = getClient(ctx.chainId, env.ALCHEMY_API_KEY); + const NATIVE_ZERO = "0x0000000000000000000000000000000000000000" as Address; + const normalizeForOracle = (addr: string) => + addr.toLowerCase() === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ? NATIVE_ZERO + : (addr as Address); + const estimatedIn = await publicClient.readContract({ + address: ctx.vaultAddress as Address, + abi: RIGOBLOCK_VAULT_ABI, + functionName: "convertTokenAmount", + args: [normalizeForOracle(tokenAddr), desiredOutRaw, NATIVE_ZERO], + }) as bigint; + if (estimatedIn > 0n) { + // Add a 5% buffer to ensure the swap produces at least the desired output + // (the oracle TWAP may be slightly stale). + const buffered = (estimatedIn * 105n) / 100n; + amountIn = formatUnits(buffered, 18); + console.log( + `[oracle] Estimated ${amountOut} ${tokenArg} → ${amountIn} ${nativeSymbol} ` + + `(via vault oracle, +5% buffer)` + ); + } + } catch (err) { + console.warn( + `[oracle] convertTokenAmount estimate failed for ${tokenArg} output=${amountOut}:`, + err instanceof Error ? err.message : err + ); + // Fall through to default + } + } + + // Default if still not set + if (!amountIn) { + amountIn = "0.001"; + } + const result = await buildOraclePoolSwapTx( tokenArg, - amountEth, + amountIn, ctx.chainId, env.ALCHEMY_API_KEY, ); return { message: result.message, - transaction: result.transaction, + transaction: { ...result.transaction, operatorOnly: true }, chainSwitch: oracleChainSwitched, }; } diff --git a/src/llm/tools.ts b/src/llm/tools.ts index e36d080..efdfa79 100644 --- a/src/llm/tools.ts +++ b/src/llm/tools.ts @@ -1127,16 +1127,19 @@ export const TOOL_DEFINITIONS = [ function: { name: "refresh_oracle_feed", description: - "Swap ETH for an ERC-20 token directly on the BackgeoOracle's dedicated Uniswap V4 oracle pool. " + + "Swap the chain's native token (ETH on most chains, POL on Polygon, BNB on BSC) for an ERC-20 token " + + "directly on the BackgeoOracle's dedicated Uniswap V4 oracle pool. " + "Use this tool in TWO situations: " + "(1) User explicitly asks to swap on/via/through/using the BackgeoOracle or oracle pool " + - "(e.g., 'swap 0.001 ETH for USDC using BackgeoOracle', 'swap on oracle pool', " + + "(e.g., 'swap 0.001 POL for GRG using BackgeoOracle', 'swap on oracle pool', " + "'swap ETH for GRG via oracle'); " + "(2) Swap Shield is blocking a vault swap due to oracle price divergence and user wants to " + "fix the root cause rather than disabling the shield. " + "This is an OPERATOR EOA transaction (to: Universal Router, NOT the vault) — " + "the operator signs with their personal wallet and receives the output token. " + - "IMPORTANT: if amountEth is not provided in the user message, ask for it before calling.", + "If the user says 'buy N TOKEN' without specifying input amount, pass amountOut=N and " + + "the system will estimate the required native token input. If no amount is given at all, " + + "a default of 0.001 native token is used.", parameters: { type: "object", properties: { @@ -1144,14 +1147,21 @@ export const TOOL_DEFINITIONS = [ type: "string", description: "The ERC-20 token whose oracle feed is stale — symbol (e.g., 'GRG', 'USDC') or " + - "contract address. ETH/WETH cannot be specified (ETH is always currency0).", + "contract address. The native token (ETH/POL/BNB) cannot be specified (it is always currency0).", }, amountEth: { type: "string", description: - "Amount of ETH to swap, provided explicitly by the user (e.g., '0.001', '0.01'). " + + "Amount of native token to swap as input (e.g., '0.001', '0.01', '5'). " + "Larger amounts move the oracle pool price more aggressively and converge TWAP faster. " + - "MUST be provided by the user — do NOT guess this value.", + "Provide this OR amountOut, not both. If neither is provided, defaults to 0.001.", + }, + amountOut: { + type: "string", + description: + "Desired output amount of the token (e.g., '2' for 2 GRG). If provided instead of amountEth, " + + "the system estimates the required native token input using the vault's on-chain oracle. " + + "Requires an active vault session.", }, chain: { type: "string", @@ -1160,7 +1170,7 @@ export const TOOL_DEFINITIONS = [ "Must match the chain where the oracle feed is stale.", }, }, - required: ["token", "amountEth"], + required: ["token"], }, }, }, diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index a1d3bb5..9477be0 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -5,7 +5,7 @@ * * Body (JSON): * token string — ERC-20 symbol or address whose oracle feed is stale (e.g. "GRG", "USDC") - * amountEth string — Amount of ETH to swap (human-readable, e.g. "0.001") + * amountEth string — Amount of native token to swap (human-readable, e.g. "0.001"). Optional; defaults to 0.001. * chainId number — Chain where the oracle pool lives * * Returns an unsigned OPERATOR EOA transaction to be signed with the operator's @@ -18,7 +18,7 @@ import { Hono } from "hono"; import { parseUnits } from "viem"; import type { Env, AppVariables } from "../types.js"; -import { buildOraclePoolSwapTx } from "../services/oraclePool.js"; +import { buildOraclePoolSwapTx, getNativeTokenSymbol } from "../services/oraclePool.js"; import { sanitizeError, resolveChainId } from "../config.js"; export const oracle = new Hono<{ Bindings: Env; Variables: AppVariables }>(); @@ -37,7 +37,7 @@ oracle.post("/refresh", async (c) => { } const token = typeof body.token === "string" ? body.token.trim() : ""; - const amountEth = typeof body.amountEth === "string" ? body.amountEth.trim() : ""; + let amountEth = typeof body.amountEth === "string" ? body.amountEth.trim() : ""; const rawChain = body.chainId ?? body.chain; if (!token) { @@ -46,12 +46,6 @@ oracle.post("/refresh", async (c) => { 400, ); } - if (!amountEth) { - return c.json( - { error: "Missing required field: amountEth (ETH amount, e.g. '0.001')" }, - 400, - ); - } if (!rawChain) { return c.json( { error: "Missing required field: chainId (e.g. 42161 for Arbitrum, 8453 for Base)" }, @@ -72,6 +66,13 @@ oracle.post("/refresh", async (c) => { ); } + const nativeSymbol = getNativeTokenSymbol(chainId); + + // Default amount if not provided + if (!amountEth) { + amountEth = "0.001"; + } + // Validate amountEth: parseFloat accepts "0.01abc" → 0.01 and "1e-3" → 0.001, // both of which later fail inside buildOraclePoolSwapTx/parseUnits with a 500. // Validate strictly with parseUnits so those cases return a clear 400. @@ -79,7 +80,7 @@ oracle.post("/refresh", async (c) => { const parsed = parseUnits(amountEth, 18); if (parsed <= 0n) throw new Error("non-positive"); } catch { - return c.json({ error: "amountEth must be a positive decimal number (e.g. '0.001'). Scientific notation is not supported." }, 400); + return c.json({ error: `amountEth must be a positive decimal number (e.g. '0.001'). Scientific notation is not supported.` }, 400); } try { @@ -95,7 +96,7 @@ oracle.post("/refresh", async (c) => { const isClientError = msg.includes("not deployed on chain") || msg.includes("not available on chain") || - msg.includes("ETH/WETH does not need") || + msg.includes("does not need an oracle update") || msg.includes("cardinality = 0") || msg.includes("Invalid decimal") || msg.includes("Token") || diff --git a/src/services/oraclePool.ts b/src/services/oraclePool.ts index f7b7b99..5d6a410 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -40,6 +40,16 @@ import { import { getClient } from "./vault.js"; import { resolveTokenAddress } from "../config.js"; +/** Chain-native token symbol for user-facing strings. */ +const NATIVE_TOKEN: Record = { + 1: "ETH", 10: "ETH", 130: "ETH", 8453: "ETH", 42161: "ETH", + 56: "BNB", 137: "POL", +}; + +export function getNativeTokenSymbol(chainId: number): string { + return NATIVE_TOKEN[chainId] || "ETH"; +} + // ── BackgeoOracle contract addresses per chain ───────────────────────── // Source: https://github.com/RigoBlock/v3-contracts/blob/development/src/utils/constants.ts export const BACKGEO_ORACLE: Record = { @@ -165,20 +175,34 @@ export interface OraclePoolSwapResult { // ── Main Function ─────────────────────────────────────────────────────── /** - * Build an unsigned EOA transaction that swaps a small ETH amount on the + * Build an unsigned transaction that swaps a small native-token amount on the * BackgeoOracle V4 pool to refresh stale price observations. * + * Two paths are supported: + * 1. **Vault path** (`vaultAddress` provided): the calldata targets the vault's + * `execute()` adapter with `value = 0`. The Rigoblock adapter handles native + * token settlement internally using the vault's balance — no `msg.value` is + * required because this is an exact-in swap where `amountIn` is already + * encoded in the V4 `SWAP_EXACT_IN_SINGLE` action. This path supports + * delegation, NAV shield, and slippage protection. + * 2. **EOA path** (`vaultAddress` omitted): the calldata targets the Uniswap + * Universal Router directly. The operator sends `msg.value = amountIn` and + * signs with their personal wallet. + * * @param token - Token symbol or address whose oracle feed is stale (e.g., "GRG"). - * @param amountEth - Amount of ETH to swap (default "0.001"). Larger amounts + * @param amountIn - Amount of native token to swap (default "0.001"). Larger amounts * move the pool price more aggressively toward market, converging the TWAP faster. * @param chainId - Chain where the oracle is stale. * @param alchemyKey - Alchemy API key for RPC calls. + * @param vaultAddress - Optional vault address. If provided, the transaction targets + * the vault adapter instead of the Universal Router. */ export async function buildOraclePoolSwapTx( token: string, - amountEth: string = "0.001", + amountIn: string = "0.001", chainId: number, alchemyKey: string, + vaultAddress?: Address, ): Promise { const oracle = BACKGEO_ORACLE[chainId]; if (!oracle) { @@ -200,9 +224,11 @@ export async function buildOraclePoolSwapTx( ? ETH_ADDRESS : rawTokenAddr; + const nativeSymbol = getNativeTokenSymbol(chainId); + if (tokenAddr === ETH_ADDRESS) { throw new Error( - "ETH/WETH does not need an oracle update — it is always currency0. " + + `${nativeSymbol}/W${nativeSymbol} does not need an oracle update — it is always currency0. ` + "Please specify the token whose oracle feed is stale (e.g., 'GRG').", ); } @@ -257,8 +283,10 @@ export async function buildOraclePoolSwapTx( ); } - // Parse ETH input amount - const amountInWei = parseUnits(amountEth, 18); + // Parse native token input amount + const amountInWei = parseUnits(amountIn, 18); + + const viaVault = !!vaultAddress; // ── Build V4 SWAP_EXACT_IN_SINGLE calldata ──────────────────────────── // @@ -331,28 +359,75 @@ export async function buildOraclePoolSwapTx( // Derive a user-friendly token symbol for the description const tokenSymbol = token.toUpperCase(); + + if (viaVault) { + const description = + `Oracle pool refresh: swap ${amountIn} ${nativeSymbol} → ${tokenSymbol} on BackgeoOracle V4 pool ` + + `(chain ${chainId}) via vault. Creates a new price observation.`; + + const message = [ + `🔄 Oracle Refresh Transaction Ready (Vault)`, + ``, + `This swaps a small amount of ${nativeSymbol} from the vault on the BackgeoOracle's dedicated`, + `Uniswap V4 pool to create a fresh price observation. The 5-minute TWAP will start converging`, + `toward the current market price after this swap is confirmed.`, + ``, + `Token: ${tokenSymbol}`, + `Amount: ${amountIn} ${nativeSymbol} → ${tokenSymbol}`, + `Pool: fee=0, tickSpacing=32767, hooks=${oracle.slice(0, 10)}…`, + `Oracle: ${oracle}`, + `Pool ID: ${poolId.slice(0, 18)}…`, + `Cardinality: ${cardinality} observations stored`, + `Chain: ${chainId}`, + ``, + `This transaction goes through the vault adapter and can be executed via delegation.`, + `Gas limit: ${ORACLE_SWAP_GAS_LIMIT.toString()}`, + ].join("\n"); + + return { + transaction: { + to: vaultAddress!, + data: calldata, + value: "0x0", + chainId, + gas: "0x" + ORACLE_SWAP_GAS_LIMIT.toString(16), + description, + }, + poolInfo: { + oracle, + currency0: ETH_ADDRESS, + currency1: tokenAddr, + tokenSymbol, + poolId, + cardinality, + }, + message, + }; + } + + // EOA path const description = - `Oracle pool refresh: swap ${amountEth} ETH → ${tokenSymbol} on BackgeoOracle V4 pool ` + + `Oracle pool refresh: swap ${amountIn} ${nativeSymbol} → ${tokenSymbol} on BackgeoOracle V4 pool ` + `(chain ${chainId}) to create a new price observation. ` + - `Sign with your operator wallet (NOT the vault).`; + `Sign with your operator wallet (EOA).`; const message = [ `🔄 Oracle Refresh Transaction Ready`, ``, - `This swaps a small amount of ETH on the BackgeoOracle's dedicated Uniswap V4 pool`, + `This swaps a small amount of ${nativeSymbol} on the BackgeoOracle's dedicated Uniswap V4 pool`, `to create a fresh price observation. The 5-minute TWAP will start converging`, `toward the current market price after this swap is confirmed.`, ``, `Token: ${tokenSymbol}`, - `Amount: ${amountEth} ETH → ${tokenSymbol}`, + `Amount: ${amountIn} ${nativeSymbol} → ${tokenSymbol}`, `Pool: fee=0, tickSpacing=32767, hooks=${oracle.slice(0, 10)}…`, `Oracle: ${oracle}`, `Pool ID: ${poolId.slice(0, 18)}…`, `Cardinality: ${cardinality} observations stored`, `Chain: ${chainId}`, ``, - `⚠️ Sign this with your OPERATOR WALLET (EOA), NOT the vault.`, - `The transaction goes directly to the Universal Router — not the vault adapter.`, + `⚠️ Sign this with your OPERATOR WALLET (EOA).`, + `The transaction goes directly to the Universal Router.`, `Gas limit: ${ORACLE_SWAP_GAS_LIMIT.toString()}`, ].join("\n"); From 3c970533d858a8c33e4ee8e9e2e2f019d795745f Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 12:35:53 +0200 Subject: [PATCH 02/16] feat: add vault path to oracle sync - correctly encode oracle sync swaps to be sent to smart pool --- public/index.html | 12 ++++++++-- src/llm/client.ts | 53 +++++++++++++++++++++++++++++++++++++++++++- src/llm/tools.ts | 7 ++++++ src/routes/oracle.ts | 16 +++++++++---- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/public/index.html b/public/index.html index a18a515..942a5c7 100644 --- a/public/index.html +++ b/public/index.html @@ -2332,10 +2332,18 @@

🏊 Get Started

document.getElementById('tx-description').textContent = tx.description || 'Vault swap transaction'; const gasDisplay = tx.gas ? parseInt(tx.gas, 16).toLocaleString() : 'auto'; const dataLength = tx.data ? (tx.data.length - 2) / 2 : 0; + const isOperatorOnly = tx.operatorOnly; + const valueHex = tx.value || '0x0'; + const valueBigInt = valueHex === '0x0' ? 0n : BigInt(valueHex); + const valueDisplay = valueBigInt > 0n + ? `${Number(valueBigInt) / 1e18} (msg.value)` + : '0 (vault uses own balance)'; + const toLabel = isOperatorOnly ? 'To' : 'To (vault)'; + document.getElementById('tx-details').innerHTML = ` -
To (vault)${escapeHtml(tx.to)}
+
${toLabel}${escapeHtml(tx.to)}
Chain${CHAIN_NAMES[tx.chainId] || tx.chainId}
-
Value0 (vault uses own balance)
+
Value${valueDisplay}
Gas limit${gasDisplay}
Data${tx.data.slice(0, 10)}… (${dataLength} bytes)
`; diff --git a/src/llm/client.ts b/src/llm/client.ts index 833986e..0c90683 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -2535,16 +2535,67 @@ export async function executeToolCall( amountIn = "0.001"; } + const viaVault = args.viaVault === true; + const vaultAddr = viaVault ? (ctx.vaultAddress as Address | undefined) : undefined; + + if (viaVault && !vaultAddr) { + throw new Error( + `viaVault=true requires a vault address. Connect a vault first or omit viaVault to use the EOA path.` + ); + } + const result = await buildOraclePoolSwapTx( tokenArg, amountIn, ctx.chainId, env.ALCHEMY_API_KEY, + vaultAddr, ); + // EOA path: simulate the transaction to catch reverts early and provide accurate gas + if (!viaVault && env.ALCHEMY_API_KEY && ctx.operatorAddress) { + try { + const publicClient = getClient(ctx.chainId, env.ALCHEMY_API_KEY); + const tx = result.transaction; + + // eth_call simulation — catches pool reverts, insufficient cardinality, etc. + await publicClient.call({ + account: ctx.operatorAddress as Address, + to: tx.to as Address, + data: tx.data, + value: BigInt(tx.value), + chain: undefined, + }); + + // eth_estimateGas for accurate gas limit + const estimatedGas = await publicClient.estimateGas({ + account: ctx.operatorAddress as Address, + to: tx.to as Address, + data: tx.data, + value: BigInt(tx.value), + chain: undefined, + }); + + // Add 20% buffer for execution variance + const gasWithBuffer = (estimatedGas * 120n) / 100n; + result.transaction.gas = "0x" + gasWithBuffer.toString(16); + console.log( + `[oracle] EOA simulation passed. Gas estimate: ${estimatedGas} → buffered: ${gasWithBuffer}` + ); + } catch (simErr) { + const reason = simErr instanceof Error ? simErr.message : String(simErr); + console.warn(`[oracle] EOA simulation failed: ${reason}`); + throw new Error( + `Oracle refresh simulation failed: ${reason}. ` + + `This usually means the pool is not initialized, the operator has insufficient ${nativeSymbol} balance, ` + + `or the oracle hook rejected the swap. Verify the pool state and try again.` + ); + } + } + return { message: result.message, - transaction: { ...result.transaction, operatorOnly: true }, + transaction: viaVault ? result.transaction : { ...result.transaction, operatorOnly: true }, chainSwitch: oracleChainSwitched, }; } diff --git a/src/llm/tools.ts b/src/llm/tools.ts index efdfa79..e7efc5e 100644 --- a/src/llm/tools.ts +++ b/src/llm/tools.ts @@ -1163,6 +1163,13 @@ export const TOOL_DEFINITIONS = [ "the system estimates the required native token input using the vault's on-chain oracle. " + "Requires an active vault session.", }, + viaVault: { + type: "boolean", + description: + "If true, route the swap through the vault adapter instead of the operator's personal wallet. " + + "The vault must have enough native token balance. When delegation is active, this enables " + + "auto-execution with NAV shield protection. Default: false (EOA direct to Universal Router).", + }, chain: { type: "string", description: diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index 9477be0..f261086 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -4,9 +4,11 @@ * POST /api/oracle/refresh * * Body (JSON): - * token string — ERC-20 symbol or address whose oracle feed is stale (e.g. "GRG", "USDC") - * amountEth string — Amount of native token to swap (human-readable, e.g. "0.001"). Optional; defaults to 0.001. - * chainId number — Chain where the oracle pool lives + * token string — ERC-20 symbol or address whose oracle feed is stale (e.g. "GRG", "USDC") + * amountEth string — Amount of native token to swap (human-readable, e.g. "0.001"). Optional; defaults to 0.001. + * chainId number — Chain where the oracle pool lives + * vaultAddress string — Optional. If provided, routes through the vault adapter (value=0, supports delegation). + * Omit for EOA path (direct to Universal Router). * * Returns an unsigned OPERATOR EOA transaction to be signed with the operator's * personal wallet (not the vault). The transaction targets the Universal Router, @@ -38,6 +40,7 @@ oracle.post("/refresh", async (c) => { const token = typeof body.token === "string" ? body.token.trim() : ""; let amountEth = typeof body.amountEth === "string" ? body.amountEth.trim() : ""; + const rawVault = body.vaultAddress; const rawChain = body.chainId ?? body.chain; if (!token) { @@ -83,8 +86,13 @@ oracle.post("/refresh", async (c) => { return c.json({ error: `amountEth must be a positive decimal number (e.g. '0.001'). Scientific notation is not supported.` }, 400); } + const vaultAddress = + typeof rawVault === "string" && rawVault.startsWith("0x") && rawVault.length === 42 + ? (rawVault as Address) + : undefined; + try { - const result = await buildOraclePoolSwapTx(token, amountEth, chainId, c.env.ALCHEMY_API_KEY); + const result = await buildOraclePoolSwapTx(token, amountEth, chainId, c.env.ALCHEMY_API_KEY, vaultAddress); return c.json({ transaction: result.transaction, poolInfo: result.poolInfo, From f0116beab06fbad8fda982b77179ef02dbedafa4 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 12:52:57 +0200 Subject: [PATCH 03/16] fix: address copilot review findings - oracle.ts: import Address type from viem (was used but not imported) - oracle.ts: use nativeSymbol in amountEth validation error message - oracle.ts: update docstring to describe both EOA and vault paths - client.ts: move nativeSymbol derivation after chain-switch so it reflects the finalised chainId (fixes stale symbol in logs/errors) - client.ts: explicitly reject requests that set both amountEth and amountOut (previously amountOut was silently ignored) - tools.ts: update refresh_oracle_feed description to reflect both the EOA path (default) and the vault-adapter path (viaVault=true) - oraclePool.ts: set operatorOnly:true on EOA-path transaction so the UI can correctly label it without relying on msg.value heuristics - index.html: derive isOperatorOnly from tx.operatorOnly OR valueBigInt>0 (vault calls never carry msg.value, so >0 is a reliable EOA signal) - index.html: show '0 (vault uses own balance)' only for vault transactions; EOA transactions with value=0 show plain '0' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/index.html | 6 ++++-- src/llm/client.ts | 11 +++++++++-- src/llm/tools.ts | 4 +++- src/routes/oracle.ts | 10 +++++----- src/services/oraclePool.ts | 1 + 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/public/index.html b/public/index.html index 942a5c7..e8049a1 100644 --- a/public/index.html +++ b/public/index.html @@ -2332,12 +2332,14 @@

🏊 Get Started

document.getElementById('tx-description').textContent = tx.description || 'Vault swap transaction'; const gasDisplay = tx.gas ? parseInt(tx.gas, 16).toLocaleString() : 'auto'; const dataLength = tx.data ? (tx.data.length - 2) / 2 : 0; - const isOperatorOnly = tx.operatorOnly; const valueHex = tx.value || '0x0'; const valueBigInt = valueHex === '0x0' ? 0n : BigInt(valueHex); + // tx.operatorOnly is set by the API for EOA-signed transactions; also treat + // non-zero msg.value as a reliable indicator (vault calls never require msg.value). + const isOperatorOnly = tx.operatorOnly || valueBigInt > 0n; const valueDisplay = valueBigInt > 0n ? `${Number(valueBigInt) / 1e18} (msg.value)` - : '0 (vault uses own balance)'; + : isOperatorOnly ? '0' : '0 (vault uses own balance)'; const toLabel = isOperatorOnly ? 'To' : 'To (vault)'; document.getElementById('tx-details').innerHTML = ` diff --git a/src/llm/client.ts b/src/llm/client.ts index 0c90683..71447d9 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -2477,8 +2477,6 @@ export async function executeToolCall( throw new Error("'token' is required. Specify the token symbol whose oracle feed is stale (e.g., 'GRG', 'USDC')."); } - const nativeSymbol = getNativeTokenSymbol(ctx.chainId); - // Auto-switch chain if provided let oracleChainSwitched: number | undefined; if (args.chain) { @@ -2489,9 +2487,18 @@ export async function executeToolCall( } } + const nativeSymbol = getNativeTokenSymbol(ctx.chainId); + let amountIn = (args.amountEth as string) || ""; const amountOut = (args.amountOut as string) || ""; + // Reject ambiguous input: only one of amountEth or amountOut may be provided. + if (amountIn && amountOut) { + throw new Error( + "Provide amountEth (native token input) OR amountOut (token output to receive), not both." + ); + } + // If amountOut is provided instead of amountIn, estimate the required native input // using the vault's on-chain BackgeoOracle (convertTokenAmount). if (!amountIn && amountOut && ctx.vaultAddress && env.ALCHEMY_API_KEY) { diff --git a/src/llm/tools.ts b/src/llm/tools.ts index e7efc5e..a5a8b4a 100644 --- a/src/llm/tools.ts +++ b/src/llm/tools.ts @@ -1135,8 +1135,10 @@ export const TOOL_DEFINITIONS = [ "'swap ETH for GRG via oracle'); " + "(2) Swap Shield is blocking a vault swap due to oracle price divergence and user wants to " + "fix the root cause rather than disabling the shield. " + - "This is an OPERATOR EOA transaction (to: Universal Router, NOT the vault) — " + + "This is an oracle pool refresh that can execute via two paths: " + + "(1) EOA path (default): OPERATOR EOA transaction (to: Universal Router, NOT the vault) — " + "the operator signs with their personal wallet and receives the output token. " + + "(2) Vault path (viaVault=true): routes through the vault adapter (value=0, supports delegation). " + "If the user says 'buy N TOKEN' without specifying input amount, pass amountOut=N and " + "the system will estimate the required native token input. If no amount is given at all, " + "a default of 0.001 native token is used.", diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index f261086..27734e0 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -10,15 +10,15 @@ * vaultAddress string — Optional. If provided, routes through the vault adapter (value=0, supports delegation). * Omit for EOA path (direct to Universal Router). * - * Returns an unsigned OPERATOR EOA transaction to be signed with the operator's - * personal wallet (not the vault). The transaction targets the Universal Router, - * not the vault adapter. + * Returns an unsigned transaction in one of two forms: + * - EOA path (default, no vaultAddress): operator signs with personal wallet; targets Universal Router directly. + * - Vault path (vaultAddress provided): routes through the vault adapter (value=0, supports delegation). * * Auth: x402 payment OR authenticated browser session. */ import { Hono } from "hono"; -import { parseUnits } from "viem"; +import { parseUnits, type Address } from "viem"; import type { Env, AppVariables } from "../types.js"; import { buildOraclePoolSwapTx, getNativeTokenSymbol } from "../services/oraclePool.js"; import { sanitizeError, resolveChainId } from "../config.js"; @@ -83,7 +83,7 @@ oracle.post("/refresh", async (c) => { const parsed = parseUnits(amountEth, 18); if (parsed <= 0n) throw new Error("non-positive"); } catch { - return c.json({ error: `amountEth must be a positive decimal number (e.g. '0.001'). Scientific notation is not supported.` }, 400); + return c.json({ error: `amountEth must be a positive decimal number of ${nativeSymbol} (e.g. '0.001'). Scientific notation is not supported.` }, 400); } const vaultAddress = diff --git a/src/services/oraclePool.ts b/src/services/oraclePool.ts index 5d6a410..5609fd2 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -439,6 +439,7 @@ export async function buildOraclePoolSwapTx( chainId, gas: "0x" + ORACLE_SWAP_GAS_LIMIT.toString(16), description, + operatorOnly: true, }, poolInfo: { oracle, From 23f5607c50206cb2bd3fcb24f46da262720f487d Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 13:29:10 +0200 Subject: [PATCH 04/16] fix: address second round of copilot review findings - oraclePool.ts: add operatorOnly?: boolean to OraclePoolSwapResult transaction type (EOA path sets this flag; type must declare it) - oracle.ts: import isAddress from viem; use it for strict vaultAddress validation and return 400 instead of silently falling back to EOA path - client.ts: when amountOut is provided but vault/RPC missing, throw an explicit error instead of silently defaulting amountIn to 0.001; also throw when oracle estimation fails rather than falling through to default - index.html: replace Number(valueBigInt)/1e18 with BigInt-safe formatter to avoid precision loss for large msg.value amounts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/index.html | 8 +++++++- src/llm/client.ts | 15 +++++++++++++-- src/routes/oracle.ts | 10 +++++----- src/services/oraclePool.ts | 1 + 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/public/index.html b/public/index.html index e8049a1..976f2bf 100644 --- a/public/index.html +++ b/public/index.html @@ -2338,7 +2338,13 @@

🏊 Get Started

// non-zero msg.value as a reliable indicator (vault calls never require msg.value). const isOperatorOnly = tx.operatorOnly || valueBigInt > 0n; const valueDisplay = valueBigInt > 0n - ? `${Number(valueBigInt) / 1e18} (msg.value)` + ? (() => { + const divisor = 10n ** 18n; + const whole = valueBigInt / divisor; + const frac = valueBigInt % divisor; + const fracStr = frac > 0n ? '.' + frac.toString().padStart(18, '0').replace(/0+$/, '') : ''; + return `${whole}${fracStr} (msg.value)`; + })() : isOperatorOnly ? '0' : '0 (vault uses own balance)'; const toLabel = isOperatorOnly ? 'To' : 'To (vault)'; diff --git a/src/llm/client.ts b/src/llm/client.ts index 71447d9..3325d54 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -2501,7 +2501,13 @@ export async function executeToolCall( // If amountOut is provided instead of amountIn, estimate the required native input // using the vault's on-chain BackgeoOracle (convertTokenAmount). - if (!amountIn && amountOut && ctx.vaultAddress && env.ALCHEMY_API_KEY) { + if (!amountIn && amountOut) { + if (!ctx.vaultAddress || !env.ALCHEMY_API_KEY) { + throw new Error( + `amountOut requires a connected vault with an active RPC key for oracle estimation. ` + + `Connect a vault first, or provide amountEth directly.` + ); + } try { const tokenAddr = await resolveTokenAddress(ctx.chainId, tokenArg); const decimalsOut = await getTokenDecimals(ctx.chainId, tokenAddr, env.ALCHEMY_API_KEY); @@ -2527,13 +2533,18 @@ export async function executeToolCall( `[oracle] Estimated ${amountOut} ${tokenArg} → ${amountIn} ${nativeSymbol} ` + `(via vault oracle, +5% buffer)` ); + } else { + throw new Error(`Oracle returned a zero estimate for ${amountOut} ${tokenArg}.`); } } catch (err) { console.warn( `[oracle] convertTokenAmount estimate failed for ${tokenArg} output=${amountOut}:`, err instanceof Error ? err.message : err ); - // Fall through to default + throw new Error( + `Could not estimate native input for amountOut="${amountOut}" ${tokenArg}: oracle estimation failed. ` + + `Provide amountEth directly instead.` + ); } } diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index 27734e0..f5c3e72 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -18,7 +18,7 @@ */ import { Hono } from "hono"; -import { parseUnits, type Address } from "viem"; +import { parseUnits, isAddress, type Address } from "viem"; import type { Env, AppVariables } from "../types.js"; import { buildOraclePoolSwapTx, getNativeTokenSymbol } from "../services/oraclePool.js"; import { sanitizeError, resolveChainId } from "../config.js"; @@ -86,10 +86,10 @@ oracle.post("/refresh", async (c) => { return c.json({ error: `amountEth must be a positive decimal number of ${nativeSymbol} (e.g. '0.001'). Scientific notation is not supported.` }, 400); } - const vaultAddress = - typeof rawVault === "string" && rawVault.startsWith("0x") && rawVault.length === 42 - ? (rawVault as Address) - : undefined; + if (rawVault !== undefined && (typeof rawVault !== "string" || !isAddress(rawVault))) { + return c.json({ error: "vaultAddress must be a valid EVM address (0x-prefixed, 42 hex characters)." }, 400); + } + const vaultAddress = typeof rawVault === "string" ? (rawVault as Address) : undefined; try { const result = await buildOraclePoolSwapTx(token, amountEth, chainId, c.env.ALCHEMY_API_KEY, vaultAddress); diff --git a/src/services/oraclePool.ts b/src/services/oraclePool.ts index 5609fd2..8eeb370 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -159,6 +159,7 @@ export interface OraclePoolSwapResult { chainId: number; gas: string; // hex description: string; + operatorOnly?: boolean; }; /** Human-readable pool key summary for diagnostics. */ poolInfo: { From fe762d65b8a06e711dce33c571e0ebf05deab78b Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 13:39:16 +0200 Subject: [PATCH 05/16] fix: address third round of copilot review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client.ts: handle estimatedIn < 0n (negative oracle int256) separately from === 0n; negative result gets its own error message distinct from the zero-estimate error - index.html: tighten comment on the msg.value heuristic — delegated vault adapter calls never require msg.value, but direct vault-owner calls (e.g. fundPool) can; the comment was misleading Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/index.html | 4 +++- src/llm/client.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index 976f2bf..353ce12 100644 --- a/public/index.html +++ b/public/index.html @@ -2335,7 +2335,9 @@

🏊 Get Started

const valueHex = tx.value || '0x0'; const valueBigInt = valueHex === '0x0' ? 0n : BigInt(valueHex); // tx.operatorOnly is set by the API for EOA-signed transactions; also treat - // non-zero msg.value as a reliable indicator (vault calls never require msg.value). + // non-zero msg.value as a reliable indicator (delegated vault adapter calls + // never require msg.value; direct vault owner calls like fundPool may, but + // those are EOA transactions anyway). const isOperatorOnly = tx.operatorOnly || valueBigInt > 0n; const valueDisplay = valueBigInt > 0n ? (() => { diff --git a/src/llm/client.ts b/src/llm/client.ts index 3325d54..bed4d9b 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -2533,6 +2533,8 @@ export async function executeToolCall( `[oracle] Estimated ${amountOut} ${tokenArg} → ${amountIn} ${nativeSymbol} ` + `(via vault oracle, +5% buffer)` ); + } else if (estimatedIn < 0n) { + throw new Error(`Oracle returned a negative estimate for ${amountOut} ${tokenArg} — unexpected oracle condition.`); } else { throw new Error(`Oracle returned a zero estimate for ${amountOut} ${tokenArg}.`); } From c64d895478a0fd9851a47c0210e0263d4b344a01 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 13:46:41 +0200 Subject: [PATCH 06/16] fix: clarify OraclePoolSwapResult docs for dual EOA/vault paths - Update transaction field JSDoc to describe both paths: EOA (operatorOnly=true, targets Universal Router, msg.value=amountIn) and vault (operatorOnly absent, targets vault adapter, value=0, delegatable) - Clarify vault-path comment: vault.execute is non-payable so no msg.value is sent by caller; V4 SETTLE_ALL is satisfied from the vault's own native balance, not from msg.value (encoding alone does not eliminate settlement) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/services/oraclePool.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/services/oraclePool.ts b/src/services/oraclePool.ts index 8eeb370..3abd829 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -151,7 +151,13 @@ const ORACLE_SWAP_GAS_LIMIT = 400_000n; // ── Types ────────────────────────────────────────────────────────────── export interface OraclePoolSwapResult { - /** Transaction to be signed by the operator EOA (not vault). */ + /** + * Unsigned transaction for the oracle refresh. + * - When `operatorOnly` is `true` (EOA path): targets the Universal Router; + * the operator signs with their personal wallet and sends `msg.value = amountIn`. + * - When `operatorOnly` is absent/false (vault path): targets the vault adapter + * (`vault.execute`); `value = 0` and can be delegated or relayed. + */ transaction: { to: Address; data: Hex; @@ -181,11 +187,12 @@ export interface OraclePoolSwapResult { * * Two paths are supported: * 1. **Vault path** (`vaultAddress` provided): the calldata targets the vault's - * `execute()` adapter with `value = 0`. The Rigoblock adapter handles native - * token settlement internally using the vault's balance — no `msg.value` is - * required because this is an exact-in swap where `amountIn` is already - * encoded in the V4 `SWAP_EXACT_IN_SINGLE` action. This path supports - * delegation, NAV shield, and slippage protection. + * `execute()` adapter with `value = 0`. The external `vault.execute` call is + * non-payable, so no `msg.value` is sent by the caller. Internally, Uniswap + * V4's `SETTLE_ALL` action requires the native token to be present at execution + * time — but the vault adapter sources this payment from the vault's own native + * balance, not from `msg.value`. This path supports delegation, NAV shield, + * and slippage protection. * 2. **EOA path** (`vaultAddress` omitted): the calldata targets the Uniswap * Universal Router directly. The operator sends `msg.value = amountIn` and * signs with their personal wallet. From 758312638783fe47a7385a7124498c5e883dee17 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 13:56:02 +0200 Subject: [PATCH 07/16] fix: address fourth round of copilot review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oraclePool.ts: use 30-minute deadline for vault/delegated path (was 5 min) to accommodate queuing and user review before submission - oraclePool.ts: update file-level '## Transaction type' doc to describe both EOA path (Universal Router, msg.value=amountIn) and vault path (vault adapter, value=0, settlement from vault balance) - oracle.ts: reject zero address (0x000...000) in addition to malformed addresses — zero address passes isAddress() but is an invalid destination - client.ts: validate desiredOutRaw > 0n before calling convertTokenAmount; inputs like '0' or '-1' now fail fast with a clear error instead of propagating to confusing oracle-level errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/client.ts | 5 +++++ src/routes/oracle.ts | 4 ++-- src/services/oraclePool.ts | 15 +++++++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index bed4d9b..64fb216 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -2512,6 +2512,11 @@ export async function executeToolCall( const tokenAddr = await resolveTokenAddress(ctx.chainId, tokenArg); const decimalsOut = await getTokenDecimals(ctx.chainId, tokenAddr, env.ALCHEMY_API_KEY); const desiredOutRaw = parseUnits(amountOut, decimalsOut); + if (desiredOutRaw <= 0n) { + throw new Error( + `amountOut must be a positive value; got "${amountOut}". Provide a value greater than zero.` + ); + } const publicClient = getClient(ctx.chainId, env.ALCHEMY_API_KEY); const NATIVE_ZERO = "0x0000000000000000000000000000000000000000" as Address; const normalizeForOracle = (addr: string) => diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index f5c3e72..9f31de9 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -86,8 +86,8 @@ oracle.post("/refresh", async (c) => { return c.json({ error: `amountEth must be a positive decimal number of ${nativeSymbol} (e.g. '0.001'). Scientific notation is not supported.` }, 400); } - if (rawVault !== undefined && (typeof rawVault !== "string" || !isAddress(rawVault))) { - return c.json({ error: "vaultAddress must be a valid EVM address (0x-prefixed, 42 hex characters)." }, 400); + if (rawVault !== undefined && (typeof rawVault !== "string" || !isAddress(rawVault) || rawVault === "0x0000000000000000000000000000000000000000")) { + return c.json({ error: "vaultAddress must be a valid non-zero EVM address (0x-prefixed, 42 hex characters)." }, 400); } const vaultAddress = typeof rawVault === "string" ? (rawVault as Address) : undefined; diff --git a/src/services/oraclePool.ts b/src/services/oraclePool.ts index 3abd829..171fbe8 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -24,9 +24,13 @@ * * ## Transaction type * - * Returns an OPERATOR EOA transaction (to: Universal Router, not the vault). - * The operator signs with their personal wallet. No vault delegation required. - * Value = amountIn (ETH for ETH→token swaps). + * Two paths depending on whether `vaultAddress` is provided: + * - **EOA path** (no `vaultAddress`): targets the Universal Router. The operator + * signs with their personal wallet and sends `msg.value = amountIn`. No vault + * delegation required. + * - **Vault path** (`vaultAddress` provided): targets the vault's `execute()` + * adapter with `value = 0`. Settlement is sourced from the vault's own native + * balance. Supports delegation, NAV shield, and slippage protection. */ import { @@ -358,7 +362,10 @@ export async function buildOraclePoolSwapTx( ); // Encode Universal Router execute(commands, inputs, deadline) - const deadline = BigInt(Math.floor(Date.now() / 1000) + 300); // 5-minute deadline + // Vault/delegated path uses 30 minutes to accommodate queuing and user review; + // EOA path uses 5 minutes since the user signs and broadcasts immediately. + const deadlineSeconds = viaVault ? 1800 : 300; + const deadline = BigInt(Math.floor(Date.now() / 1000) + deadlineSeconds); const calldata = encodeFunctionData({ abi: UR_EXECUTE_ABI, functionName: "execute", From 540f8e3f6fb111826b9901f0f33939cca2c32a28 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 14:05:09 +0200 Subject: [PATCH 08/16] fix: coerce amountEth/amountOut args to string, normalize viaVault bool - client.ts: coerce args.amountEth and args.amountOut with String() so numeric inputs from function-calling don't bypass parseUnits - client.ts: accept viaVault as boolean or string 'true' to match pattern used by other tools in this file - oracle.ts: coerce numeric amountEth to string rather than silently treating it as missing and defaulting to 0.001 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/client.ts | 9 ++++++--- src/routes/oracle.ts | 8 +++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index 64fb216..4133663 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -2489,8 +2489,10 @@ export async function executeToolCall( const nativeSymbol = getNativeTokenSymbol(ctx.chainId); - let amountIn = (args.amountEth as string) || ""; - const amountOut = (args.amountOut as string) || ""; + // Coerce to string: the LLM (or function-calling) may deliver numbers instead + // of strings; String(...) ensures parseUnits/branching behaves deterministically. + let amountIn = args.amountEth != null ? String(args.amountEth).trim() : ""; + const amountOut = args.amountOut != null ? String(args.amountOut).trim() : ""; // Reject ambiguous input: only one of amountEth or amountOut may be provided. if (amountIn && amountOut) { @@ -2560,7 +2562,8 @@ export async function executeToolCall( amountIn = "0.001"; } - const viaVault = args.viaVault === true; + // Accept both boolean true and the string "true" (function-calling can send either). + const viaVault = args.viaVault === true || args.viaVault === "true"; const vaultAddr = viaVault ? (ctx.vaultAddress as Address | undefined) : undefined; if (viaVault && !vaultAddr) { diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index 9f31de9..92e4fa3 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -39,7 +39,13 @@ oracle.post("/refresh", async (c) => { } const token = typeof body.token === "string" ? body.token.trim() : ""; - let amountEth = typeof body.amountEth === "string" ? body.amountEth.trim() : ""; + // Accept numeric amountEth (e.g. 0.001 from JSON) by coercing to string. + let amountEth = + typeof body.amountEth === "string" + ? body.amountEth.trim() + : typeof body.amountEth === "number" + ? String(body.amountEth) + : ""; const rawVault = body.vaultAddress; const rawChain = body.chainId ?? body.chain; From 4ce87cfe60a0657f54cb88c57bbb1ac1c93f425c Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 14:16:10 +0200 Subject: [PATCH 09/16] fix: round 7 review comments - oracle.ts: update amountEth doc to string|number - client.ts: block chain auto-switch for vault-dependent paths (viaVault/amountOut) - client.ts: guard zero-address vault in viaVault path and amountOut estimation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/client.ts | 20 +++++++++++++++++--- src/routes/oracle.ts | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index 4133663..62792b7 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -2487,6 +2487,17 @@ export async function executeToolCall( } } + // Vault-dependent options (viaVault, amountOut) require ctx.vaultAddress to be on + // the active chain. Disallow chain auto-switch when either is requested. + const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; + const requestsVaultPath = args.viaVault === true || args.viaVault === "true" || !!args.amountOut; + if (oracleChainSwitched && requestsVaultPath) { + throw new Error( + `Cannot switch chains while using vault-dependent options (viaVault or amountOut). ` + + `Connect a vault on chain ${oracleChainSwitched} first, or omit viaVault/amountOut.` + ); + } + const nativeSymbol = getNativeTokenSymbol(ctx.chainId); // Coerce to string: the LLM (or function-calling) may deliver numbers instead @@ -2504,7 +2515,7 @@ export async function executeToolCall( // If amountOut is provided instead of amountIn, estimate the required native input // using the vault's on-chain BackgeoOracle (convertTokenAmount). if (!amountIn && amountOut) { - if (!ctx.vaultAddress || !env.ALCHEMY_API_KEY) { + if (!ctx.vaultAddress || ctx.vaultAddress === ZERO_ADDR || !env.ALCHEMY_API_KEY) { throw new Error( `amountOut requires a connected vault with an active RPC key for oracle estimation. ` + `Connect a vault first, or provide amountEth directly.` @@ -2564,11 +2575,14 @@ export async function executeToolCall( // Accept both boolean true and the string "true" (function-calling can send either). const viaVault = args.viaVault === true || args.viaVault === "true"; - const vaultAddr = viaVault ? (ctx.vaultAddress as Address | undefined) : undefined; + // Treat zero address as "no vault" (frontend uses it as a placeholder before connecting). + const vaultAddr = viaVault && ctx.vaultAddress && ctx.vaultAddress !== ZERO_ADDR + ? (ctx.vaultAddress as Address) + : undefined; if (viaVault && !vaultAddr) { throw new Error( - `viaVault=true requires a vault address. Connect a vault first or omit viaVault to use the EOA path.` + `viaVault=true requires a connected vault. Connect a vault first or omit viaVault to use the EOA path.` ); } diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index 92e4fa3..e0915a6 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -5,7 +5,7 @@ * * Body (JSON): * token string — ERC-20 symbol or address whose oracle feed is stale (e.g. "GRG", "USDC") - * amountEth string — Amount of native token to swap (human-readable, e.g. "0.001"). Optional; defaults to 0.001. + * amountEth string|number — Amount of native token to swap (human-readable, e.g. "0.001" or 0.001). Optional; defaults to 0.001. * chainId number — Chain where the oracle pool lives * vaultAddress string — Optional. If provided, routes through the vault adapter (value=0, supports delegation). * Omit for EOA path (direct to Universal Router). From 84ac10b2a47e8ecb9667c58b599e56791b0a9f9d Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 14:24:34 +0200 Subject: [PATCH 10/16] fix: round 8 review comments - client.ts: check vault-path guard BEFORE mutating ctx.chainId to prevent partial context mutation on throw - tools.ts: clarify vault path output destination (TAKE_ALL sends to vault) - oraclePool.ts: fix SETTLE_ALL/TAKE_ALL comments to distinguish EOA vs vault path settlement source and output recipient Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/client.ts | 25 +++++++++++++------------ src/llm/tools.ts | 1 + src/services/oraclePool.ts | 8 ++++++-- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index 62792b7..3a4868b 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -2477,27 +2477,28 @@ export async function executeToolCall( throw new Error("'token' is required. Specify the token symbol whose oracle feed is stale (e.g., 'GRG', 'USDC')."); } - // Auto-switch chain if provided + // Vault-dependent options (viaVault, amountOut) require ctx.vaultAddress to be on + // the active chain. Check this BEFORE mutating ctx.chainId to avoid leaving context + // in a partially-switched state if the guard throws. + const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; + const requestsVaultPath = args.viaVault === true || args.viaVault === "true" || !!args.amountOut; + + // Auto-switch chain if provided (only safe for non-vault-dependent calls) let oracleChainSwitched: number | undefined; if (args.chain) { const requestedChain = resolveChainId(args.chain as string); if (requestedChain !== ctx.chainId) { + if (requestsVaultPath) { + throw new Error( + `Cannot switch chains while using vault-dependent options (viaVault or amountOut). ` + + `Connect a vault on chain ${requestedChain} first, or omit viaVault/amountOut.` + ); + } ctx.chainId = requestedChain; oracleChainSwitched = requestedChain; } } - // Vault-dependent options (viaVault, amountOut) require ctx.vaultAddress to be on - // the active chain. Disallow chain auto-switch when either is requested. - const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; - const requestsVaultPath = args.viaVault === true || args.viaVault === "true" || !!args.amountOut; - if (oracleChainSwitched && requestsVaultPath) { - throw new Error( - `Cannot switch chains while using vault-dependent options (viaVault or amountOut). ` + - `Connect a vault on chain ${oracleChainSwitched} first, or omit viaVault/amountOut.` - ); - } - const nativeSymbol = getNativeTokenSymbol(ctx.chainId); // Coerce to string: the LLM (or function-calling) may deliver numbers instead diff --git a/src/llm/tools.ts b/src/llm/tools.ts index a5a8b4a..f0f4a83 100644 --- a/src/llm/tools.ts +++ b/src/llm/tools.ts @@ -1139,6 +1139,7 @@ export const TOOL_DEFINITIONS = [ "(1) EOA path (default): OPERATOR EOA transaction (to: Universal Router, NOT the vault) — " + "the operator signs with their personal wallet and receives the output token. " + "(2) Vault path (viaVault=true): routes through the vault adapter (value=0, supports delegation). " + + "The output token is sent to msg.sender of the Universal Router, which is the vault adapter — so the output stays in the vault. " + "If the user says 'buy N TOKEN' without specifying input amount, pass amountOut=N and " + "the system will estimate the required native token input. If no amount is given at all, " + "a default of 0.001 native token is used.", diff --git a/src/services/oraclePool.ts b/src/services/oraclePool.ts index 171fbe8..ca39186 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -308,10 +308,14 @@ export async function buildOraclePoolSwapTx( // PoolKey, zeroForOne=true, amountIn, amountOutMin=0, hookData=0x // // param[1] — SETTLE_ALL: (currencyIn, maxAmount) - // Settles ETH from msg.value. maxAmount = amountIn ensures no over-settling. + // EOA path: settles ETH from msg.value. Vault path: settles from the vault's own + // native balance (vault.execute is non-payable, so value=0; the vault adapter + // sources the native token internally). maxAmount = amountIn ensures no over-settling. // // param[2] — TAKE_ALL: (currencyOut, minAmount=0) - // Takes all token output and sends to msg.sender (the operator). + // Takes all token output and sends to msg.sender. + // EOA path: msg.sender is the operator, so the operator receives the output token. + // Vault path: msg.sender is the vault adapter, so the output token stays in the vault. const swapParam = encodeAbiParameters( [ From 875211f246e65dbf3be9b15b0b550ef9995162e3 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 14:32:29 +0200 Subject: [PATCH 11/16] fix: round 9 - normalize amountOut before requestsVaultPath guard Use String(args.amountOut).trim() consistently so that falsy-but-valid values like 0 and whitespace strings don't bypass or spuriously trigger the vault-path chain-switch guard. Reuse normalizedAmountOut for the amountOut const below to avoid double-coercion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/client.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index 3a4868b..4044602 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -2480,8 +2480,11 @@ export async function executeToolCall( // Vault-dependent options (viaVault, amountOut) require ctx.vaultAddress to be on // the active chain. Check this BEFORE mutating ctx.chainId to avoid leaving context // in a partially-switched state if the guard throws. + // Use the same normalization as the amountOut coercion below so that numeric 0, + // whitespace strings, and null/undefined are all handled consistently. const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; - const requestsVaultPath = args.viaVault === true || args.viaVault === "true" || !!args.amountOut; + const normalizedAmountOut = args.amountOut != null ? String(args.amountOut).trim() : ""; + const requestsVaultPath = args.viaVault === true || args.viaVault === "true" || normalizedAmountOut !== ""; // Auto-switch chain if provided (only safe for non-vault-dependent calls) let oracleChainSwitched: number | undefined; @@ -2504,7 +2507,7 @@ export async function executeToolCall( // Coerce to string: the LLM (or function-calling) may deliver numbers instead // of strings; String(...) ensures parseUnits/branching behaves deterministically. let amountIn = args.amountEth != null ? String(args.amountEth).trim() : ""; - const amountOut = args.amountOut != null ? String(args.amountOut).trim() : ""; + const amountOut = normalizedAmountOut; // already coerced above for the vault-path guard // Reject ambiguous input: only one of amountEth or amountOut may be provided. if (amountIn && amountOut) { From a4003d92fa6dd4b2acce540f7439abf8544354eb Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 14:41:40 +0200 Subject: [PATCH 12/16] fix: round 10 - guard amountInWei > 0 and avoid scientific notation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oraclePool.ts: validate amountInWei > 0n after parseUnits to produce a clear error instead of silently building a zero-value swap calldata - oracle.ts: use Number.toFixed(18) instead of String() for numeric amountEth to avoid scientific notation (e.g. 0.0000001 → '1e-7') that parseUnits rejects; the existing parseUnits validation still catches any remaining invalid values with a 400 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/routes/oracle.ts | 4 +++- src/services/oraclePool.ts | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index e0915a6..d49e657 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -40,11 +40,13 @@ oracle.post("/refresh", async (c) => { const token = typeof body.token === "string" ? body.token.trim() : ""; // Accept numeric amountEth (e.g. 0.001 from JSON) by coercing to string. + // Use toFixed(18) for numbers instead of String() to avoid scientific-notation + // output (e.g. String(0.0000001) → "1e-7") which parseUnits rejects. let amountEth = typeof body.amountEth === "string" ? body.amountEth.trim() : typeof body.amountEth === "number" - ? String(body.amountEth) + ? body.amountEth.toFixed(18) : ""; const rawVault = body.vaultAddress; const rawChain = body.chainId ?? body.chain; diff --git a/src/services/oraclePool.ts b/src/services/oraclePool.ts index ca39186..33039fd 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -297,6 +297,9 @@ export async function buildOraclePoolSwapTx( // Parse native token input amount const amountInWei = parseUnits(amountIn, 18); + if (amountInWei <= 0n) { + throw new Error(`amountIn must be a positive amount of native token; received "${amountIn}".`); + } const viaVault = !!vaultAddress; From 94ff80dd1dd33b5f56d6907b6765c5ca138a64bd Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 14:48:58 +0200 Subject: [PATCH 13/16] fix: round 11 - guard zero vaultAddress in oraclePool.ts Add explicit zero-address check at the entry point of buildOraclePoolSwapTx so future call sites don't silently generate an unusable transaction to the zero address. Both the HTTP route and LLM tool path already validate this, but defense-in-depth in the service layer ensures all callers are safe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/services/oraclePool.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/services/oraclePool.ts b/src/services/oraclePool.ts index 33039fd..f85af01 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -228,6 +228,10 @@ export async function buildOraclePoolSwapTx( throw new Error(`Universal Router not available on chain ${chainId}.`); } + if (vaultAddress !== undefined && vaultAddress === "0x0000000000000000000000000000000000000000") { + throw new Error("vaultAddress must be a valid non-zero vault address."); + } + // Resolve token address — normalize WETH/ETH to address(0) const rawTokenAddr = await resolveTokenAddress(chainId, token); const tokenAddr: Address = From 0c470baed86309d4c15ed20751b5135d3f32bc9c Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 14:55:36 +0200 Subject: [PATCH 14/16] fix: round 12 - avoid scientific notation in LLM tool amountEth/amountOut Introduce a toDecimalString() helper that uses Number.toFixed(18) for numeric inputs and String().trim() for other values. Apply it to both normalizedAmountOut and amountIn in the refresh_oracle_feed tool handler, mirroring the toFixed(18) fix already applied to the HTTP route in oracle.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/client.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index 4044602..7d8962f 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -2483,7 +2483,14 @@ export async function executeToolCall( // Use the same normalization as the amountOut coercion below so that numeric 0, // whitespace strings, and null/undefined are all handled consistently. const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; - const normalizedAmountOut = args.amountOut != null ? String(args.amountOut).trim() : ""; + // Use toFixed(18) for numeric inputs to avoid scientific notation (e.g. + // 0.0000001 → "1e-7" via String()) that parseUnits rejects. + const toDecimalString = (v: unknown): string => { + if (v == null) return ""; + if (typeof v === "number") return v.toFixed(18); + return String(v).trim(); + }; + const normalizedAmountOut = toDecimalString(args.amountOut); const requestsVaultPath = args.viaVault === true || args.viaVault === "true" || normalizedAmountOut !== ""; // Auto-switch chain if provided (only safe for non-vault-dependent calls) @@ -2504,9 +2511,9 @@ export async function executeToolCall( const nativeSymbol = getNativeTokenSymbol(ctx.chainId); - // Coerce to string: the LLM (or function-calling) may deliver numbers instead - // of strings; String(...) ensures parseUnits/branching behaves deterministically. - let amountIn = args.amountEth != null ? String(args.amountEth).trim() : ""; + // Coerce to decimal string; toDecimalString() uses toFixed(18) for numbers + // to avoid scientific notation (e.g. 0.0000001 → "1e-7") that parseUnits rejects. + let amountIn = toDecimalString(args.amountEth); const amountOut = normalizedAmountOut; // already coerced above for the vault-path guard // Reject ambiguous input: only one of amountEth or amountOut may be provided. From 087793bdb55d225c3dd2143d16b588d1dc88b943 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 15:01:20 +0200 Subject: [PATCH 15/16] fix: round 13 - correct slippage docs and amountOut description - oraclePool.ts: remove misleading 'slippage protection' claims from module header and buildOraclePoolSwapTx JSDoc; the swap uses amountOutMinimum=0 (exact-input); the NAV shield enforces a value-level check, not per-token slippage - tools.ts: reword refresh_oracle_feed amountOut description from 'Desired output amount' (implies guarantee) to 'Sizing hint' with a note that actual received amount may differ and there is no on-chain min-out bound Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/tools.ts | 3 ++- src/services/oraclePool.ts | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/llm/tools.ts b/src/llm/tools.ts index f0f4a83..e082f18 100644 --- a/src/llm/tools.ts +++ b/src/llm/tools.ts @@ -1162,8 +1162,9 @@ export const TOOL_DEFINITIONS = [ amountOut: { type: "string", description: - "Desired output amount of the token (e.g., '2' for 2 GRG). If provided instead of amountEth, " + + "Sizing hint for the desired output amount (e.g., '2' for ~2 GRG). If provided instead of amountEth, " + "the system estimates the required native token input using the vault's on-chain oracle. " + + "The actual received amount may differ — the swap is exact-input with no on-chain min-out bound. " + "Requires an active vault session.", }, viaVault: { diff --git a/src/services/oraclePool.ts b/src/services/oraclePool.ts index f85af01..b1cff77 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -30,7 +30,8 @@ * delegation required. * - **Vault path** (`vaultAddress` provided): targets the vault's `execute()` * adapter with `value = 0`. Settlement is sourced from the vault's own native - * balance. Supports delegation, NAV shield, and slippage protection. + * balance. Supports delegation and the NAV shield. The swap is exact-input + * with amountOutMinimum=0 — output is not bounded on-chain. */ import { @@ -195,8 +196,9 @@ export interface OraclePoolSwapResult { * non-payable, so no `msg.value` is sent by the caller. Internally, Uniswap * V4's `SETTLE_ALL` action requires the native token to be present at execution * time — but the vault adapter sources this payment from the vault's own native - * balance, not from `msg.value`. This path supports delegation, NAV shield, - * and slippage protection. + * balance, not from `msg.value`. This path supports delegation and the NAV + * shield. The swap is exact-input with amountOutMinimum=0 — output amount + * is not bounded on-chain (NAV shield enforces a value-level check instead). * 2. **EOA path** (`vaultAddress` omitted): the calldata targets the Uniswap * Universal Router directly. The operator sends `msg.value = amountIn` and * signs with their personal wallet. From 4766cdec9f36a959aa1604767afadaa57a35ffe8 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 15:09:01 +0200 Subject: [PATCH 16/16] fix: normalize wrapped-native addresses (WETH/WBNB/WPOL) to ETH_ADDRESS in oracle pool resolveTokenAddress('WETH', chainId) returns the real WETH contract address (e.g. 0x4200...0006 on Base), not 0x0 or 0xeeee... So the existing guard for 'ETH/WETH does not need an oracle update' was silently bypassed when the caller passed 'WETH' (or 'WBNB', 'WPOL' on other chains). Fix: look up the chain's wrapped-native address from TOKEN_MAP using the W${nativeSymbol} key and add it to the normalization block so WETH/WBNB/WPOL are mapped to ETH_ADDRESS before the guard is evaluated. Also move nativeSymbol declaration before tokenAddr so it can be used in the normalization condition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/services/oraclePool.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/services/oraclePool.ts b/src/services/oraclePool.ts index b1cff77..200fce7 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -43,7 +43,7 @@ import { type Hex, } from "viem"; import { getClient } from "./vault.js"; -import { resolveTokenAddress } from "../config.js"; +import { resolveTokenAddress, TOKEN_MAP } from "../config.js"; /** Chain-native token symbol for user-facing strings. */ const NATIVE_TOKEN: Record = { @@ -234,16 +234,17 @@ export async function buildOraclePoolSwapTx( throw new Error("vaultAddress must be a valid non-zero vault address."); } - // Resolve token address — normalize WETH/ETH to address(0) + // Resolve token address — normalize ETH aliases and wrapped-native to address(0) const rawTokenAddr = await resolveTokenAddress(chainId, token); + const nativeSymbol = getNativeTokenSymbol(chainId); + const wrappedNativeAddr = (TOKEN_MAP[chainId]?.[`W${nativeSymbol}`] as string | undefined)?.toLowerCase(); const tokenAddr: Address = rawTokenAddr.toLowerCase() === ETH_ADDRESS || - rawTokenAddr.toLowerCase() === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + rawTokenAddr.toLowerCase() === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" || + (wrappedNativeAddr !== undefined && rawTokenAddr.toLowerCase() === wrappedNativeAddr) ? ETH_ADDRESS : rawTokenAddr; - const nativeSymbol = getNativeTokenSymbol(chainId); - if (tokenAddr === ETH_ADDRESS) { throw new Error( `${nativeSymbol}/W${nativeSymbol} does not need an oracle update — it is always currency0. ` +