Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2332,10 +2332,28 @@ <h3>🏊 Get Started</h3>
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
Comment thread
gabririgo marked this conversation as resolved.
? (() => {
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)';
Comment thread
gabririgo marked this conversation as resolved.
Comment thread
gabririgo marked this conversation as resolved.
const toLabel = isOperatorOnly ? 'To' : 'To (vault)';

document.getElementById('tx-details').innerHTML = `
<div class="row"><span class="label">To (vault)</span><span class="value">${escapeHtml(tx.to)}</span></div>
<div class="row"><span class="label">${toLabel}</span><span class="value">${escapeHtml(tx.to)}</span></div>
<div class="row"><span class="label">Chain</span><span class="value">${CHAIN_NAMES[tx.chainId] || tx.chainId}</span></div>
<div class="row"><span class="label">Value</span><span class="value">0 (vault uses own balance)</span></div>
<div class="row"><span class="label">Value</span><span class="value">${valueDisplay}</span></div>
<div class="row"><span class="label">Gas limit</span><span class="value">${gasDisplay}</span></div>
<div class="row"><span class="label">Data</span><span class="value">${tx.data.slice(0, 10)}… (${dataLength} bytes)</span></div>
`;
Expand Down
166 changes: 155 additions & 11 deletions src/llm/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Comment thread
gabririgo marked this conversation as resolved.
}

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."
);
}

Comment thread
gabririgo marked this conversation as resolved.
// 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;
Comment thread
gabririgo marked this conversation as resolved.
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)`
);
Comment thread
gabririgo marked this conversation as resolved.
} 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";
}
Comment thread
gabririgo marked this conversation as resolved.
Comment thread
gabririgo marked this conversation as resolved.

// 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.`
);
}
Comment thread
gabririgo marked this conversation as resolved.

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,
};
}
Expand Down
37 changes: 29 additions & 8 deletions src/llm/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1127,31 +1127,52 @@ 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). " +
Comment thread
gabririgo marked this conversation as resolved.
"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.",
Comment thread
gabririgo marked this conversation as resolved.
parameters: {
type: "object",
properties: {
token: {
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.",
},
Comment thread
gabririgo marked this conversation as resolved.
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",
Expand All @@ -1160,7 +1181,7 @@ export const TOOL_DEFINITIONS = [
"Must match the chain where the oracle feed is stale.",
},
},
required: ["token", "amountEth"],
required: ["token"],
},
},
},
Expand Down
Loading