diff --git a/public/index.html b/public/index.html index a18a515..353ce12 100644 --- a/public/index.html +++ b/public/index.html @@ -2332,10 +2332,28 @@

🏊 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 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 (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 + ? (() => { + 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)'; + 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 c9ab647..7d8962f 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,34 +2477,178 @@ 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.", - ); - } + // 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"; + // 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 + // 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; } } + const nativeSymbol = getNativeTokenSymbol(ctx.chainId); + + // 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. + 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) { + 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.` + ); + } + try { + 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) => + 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)` + ); + } 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}.`); + } + } catch (err) { + console.warn( + `[oracle] convertTokenAmount estimate failed for ${tokenArg} output=${amountOut}:`, + err instanceof Error ? err.message : err + ); + throw new Error( + `Could not estimate native input for amountOut="${amountOut}" ${tokenArg}: oracle estimation failed. ` + + `Provide amountEth directly instead.` + ); + } + } + + // Default if still not set + if (!amountIn) { + amountIn = "0.001"; + } + + // Accept both boolean true and the string "true" (function-calling can send either). + const viaVault = args.viaVault === true || args.viaVault === "true"; + // 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 connected vault. Connect a vault first or omit viaVault to use the EOA path.` + ); + } + const result = await buildOraclePoolSwapTx( tokenArg, - amountEth, + 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, + transaction: viaVault ? result.transaction : { ...result.transaction, operatorOnly: true }, chainSwitch: oracleChainSwitched, }; } diff --git a/src/llm/tools.ts b/src/llm/tools.ts index e36d080..e082f18 100644 --- a/src/llm/tools.ts +++ b/src/llm/tools.ts @@ -1127,16 +1127,22 @@ 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) β€” " + + "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. " + - "IMPORTANT: if amountEth is not provided in the user message, ask for it before calling.", + "(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.", parameters: { type: "object", properties: { @@ -1144,14 +1150,29 @@ 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: + "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: { + 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", @@ -1160,7 +1181,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..d49e657 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -4,21 +4,23 @@ * 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 ETH to swap (human-readable, e.g. "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|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). * - * 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, isAddress, type Address } 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 +39,16 @@ oracle.post("/refresh", async (c) => { } const token = typeof body.token === "string" ? body.token.trim() : ""; - const amountEth = typeof body.amountEth === "string" ? body.amountEth.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" + ? body.amountEth.toFixed(18) + : ""; + const rawVault = body.vaultAddress; const rawChain = body.chainId ?? body.chain; if (!token) { @@ -46,12 +57,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 +77,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,11 +91,16 @@ 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); + } + + 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; 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, @@ -95,7 +112,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..200fce7 100644 --- a/src/services/oraclePool.ts +++ b/src/services/oraclePool.ts @@ -24,9 +24,14 @@ * * ## 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 and the NAV shield. The swap is exact-input + * with amountOutMinimum=0 β€” output is not bounded on-chain. */ import { @@ -38,7 +43,17 @@ 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 = { + 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 @@ -141,7 +156,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; @@ -149,6 +170,7 @@ export interface OraclePoolSwapResult { chainId: number; gas: string; // hex description: string; + operatorOnly?: boolean; }; /** Human-readable pool key summary for diagnostics. */ poolInfo: { @@ -165,20 +187,36 @@ 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 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 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. + * * @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) { @@ -192,17 +230,24 @@ export async function buildOraclePoolSwapTx( throw new Error(`Universal Router not available on chain ${chainId}.`); } - // Resolve token address β€” normalize WETH/ETH to address(0) + if (vaultAddress !== undefined && vaultAddress === "0x0000000000000000000000000000000000000000") { + throw new Error("vaultAddress must be a valid non-zero vault address."); + } + + // 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; 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 +302,13 @@ export async function buildOraclePoolSwapTx( ); } - // Parse ETH input amount - const amountInWei = parseUnits(amountEth, 18); + // 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; // ── Build V4 SWAP_EXACT_IN_SINGLE calldata ──────────────────────────── // @@ -268,10 +318,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( [ @@ -322,7 +376,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", @@ -331,28 +388,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"); @@ -364,6 +468,7 @@ export async function buildOraclePoolSwapTx( chainId, gas: "0x" + ORACLE_SWAP_GAS_LIMIT.toString(16), description, + operatorOnly: true, }, poolInfo: { oracle,