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,