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
18 changes: 18 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ import {

import { getTokenPriceInput, getTokenPriceTool } from "./modules/prices/index.js";

import { simulateTransaction } from "./modules/simulation/index.js";
import { simulateTransactionInput } from "./modules/simulation/schemas.js";

import { requestCapability, requestCapabilityInput } from "./modules/feedback/index.js";

import { issueHandles } from "./signing/tx-store.js";
Expand Down Expand Up @@ -500,6 +503,21 @@ async function main() {
handler(getTransactionStatus)
);

server.registerTool(
"simulate_transaction",
{
description:
"Run an eth_call against the chain's RPC to simulate a transaction without signing or broadcasting it. " +
"Returns `{ ok, returnData?, revertReason? }`. Use this BEFORE prepare_*/send_transaction to verify " +
"a contract call does what you expect — e.g. does wrapping ETH by sending to WETH9's fallback succeed, " +
"does a custom calldata revert, what selector gets hit. For state-dependent calls (WETH deposit credits " +
"msg.sender, ERC-20 transfer debits msg.sender), pass the user's wallet as `from`. Prepared transactions " +
"are also re-simulated automatically at send_transaction time — this tool lets the agent check ahead.",
inputSchema: simulateTransactionInput.shape,
},
handler(simulateTransaction)
);

// ---- Module 7: Balances & ENS ----
server.registerTool(
"get_token_balance",
Expand Down
16 changes: 13 additions & 3 deletions src/modules/compound/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,19 @@ async function readMarketPosition(
})),
];
const metaResults = await client.multicall({ contracts: metaCalls, allowFailure: true });
const baseSuppliedWei = supplied as bigint;
const baseBorrowedWei = borrowed as bigint;
// If either base balance is nonzero we MUST know the base token's decimals to
// format correctly. Previously this silently fell back to 18, which rendered a
// 184k USDC (6-decimal) supply as ~0.0000002 USDC — showed up as dust in the
// portfolio summary while the direct get_compound_positions call succeeded.
// Skip the market rather than emit a wrong-scale amount.
if (
metaResults[0].status !== "success" &&
(baseSuppliedWei > 0n || baseBorrowedWei > 0n)
) {
return null;
}
const baseDecimals =
metaResults[0].status === "success" ? Number(metaResults[0].result) : 18;
const baseSymbol =
Expand Down Expand Up @@ -117,9 +130,6 @@ async function readMarketPosition(
allowFailure: true,
});

const baseSuppliedWei = supplied as bigint;
const baseBorrowedWei = borrowed as bigint;

const collateral: TokenAmount[] = [];
for (let i = 0; i < collateralAddrs.length; i++) {
const balRes = collatResults[i * 3];
Expand Down
38 changes: 36 additions & 2 deletions src/modules/execution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { consumeHandle, retireHandle } from "../../signing/tx-store.js";
import { assertTransactionSafe } from "../../signing/pre-sign-check.js";
import { getClient, verifyChainId } from "../../data/rpc.js";
import { erc20Abi } from "../../abis/erc20.js";
import { simulateTx } from "../simulation/index.js";
import {
buildAaveSupply,
buildAaveWithdraw,
Expand Down Expand Up @@ -86,10 +87,21 @@ async function resolveAssetMeta(
return { decimals: Number(decimals), symbol: symbol as string };
}

/** Attach gas estimate + USD cost + eth_call simulation result. */
/** Attach eth_call simulation result, gas estimate, and USD cost. */
async function enrichTx(tx: UnsignedTx): Promise<UnsignedTx> {
const client = getClient(tx.chain);
const from = tx.from;
// Always simulate — even when gas estimation would succeed — so the caller
// can see the decoded revert reason alongside the preview. A failed sim on
// a standalone tx is a red flag; a failed sim on `tx.next` of an
// approve→action pair is expected until the approve mines.
tx.simulation = await simulateTx({
chain: tx.chain,
from,
to: tx.to,
data: tx.data,
value: tx.value,
});
try {
const gas = await client.estimateGas({
account: from ?? "0x0000000000000000000000000000000000000001",
Expand All @@ -107,7 +119,9 @@ async function enrichTx(tx: UnsignedTx): Promise<UnsignedTx> {
tx.gasCostUsd = round(gasEth * ethPrice, 2);
}
} catch {
// Simulation fails for many legitimate reasons (insufficient allowance, etc.) — we surface the tx anyway.
// Gas estimation fails for many legitimate reasons (insufficient allowance on
// a follow-up step, etc.) — we surface the tx anyway. The simulation field
// above has already captured any revert reason.
}
if (tx.next) tx.next = await enrichTx(tx.next);
return tx;
Expand Down Expand Up @@ -277,6 +291,26 @@ export async function sendTransaction(args: SendTransactionArgs): Promise<{
// (for approve) spender allowlist. A compromised agent can't slip an
// "approve(attacker, MAX)" past this, even if the handle system were bypassed.
await assertTransactionSafe(tx);
// Re-simulate against current chain state before asking the user to sign.
// At prepare time, step 2 of an approve→action pair legitimately reverts
// because the approve isn't mined yet. By send time, the approve is on-chain
// and the simulation should pass. A revert here means signing would waste gas
// on a guaranteed failure — refuse rather than forward.
const sim = await simulateTx({
chain: tx.chain,
from: tx.from,
to: tx.to,
data: tx.data,
value: tx.value,
});
if (!sim.ok) {
throw new Error(
`Pre-sign simulation failed: ${sim.revertReason ?? "execution reverted"}. ` +
`Refusing to forward to Ledger — signing this tx would burn gas on a revert. ` +
`If a prerequisite step (e.g. an ERC-20 approve) must be mined first, send it ` +
`and wait for confirmation before retrying. Use simulate_transaction to debug.`
);
}
// Assert that tx.from is actually an account the paired wallet holds keys
// for. Without this check, a prepare_* call with a user-supplied `wallet`
// arg referencing an address the wallet doesn't control would be forwarded
Expand Down
79 changes: 79 additions & 0 deletions src/modules/simulation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { BaseError } from "viem";
import { getClient } from "../../data/rpc.js";
import type { SupportedChain } from "../../types/index.js";
import type { SimulateTransactionArgs } from "./schemas.js";

export interface SimulationResult {
chain: SupportedChain;
ok: boolean;
returnData?: `0x${string}`;
revertReason?: string;
}

/**
* Best-effort decoding of a revert. viem wraps the RPC error in a BaseError
* chain; `shortMessage` usually contains the decoded "Execution reverted with
* reason: ..." line. We fall back to the first line of `.message` for non-viem
* errors (e.g. raw RPC HTTP failures).
*/
function decodeRevertError(err: unknown): string {
if (err instanceof BaseError) {
return err.shortMessage || err.message.split("\n")[0] || "execution reverted";
}
if (err instanceof Error) {
return err.message.split("\n")[0] || "execution reverted";
}
return "execution reverted";
}

/**
* Run an eth_call against the chain's RPC. Does NOT change state — this is the
* same primitive wallets use for contract reads, extended here to catch reverts
* that a blind signature would otherwise waste gas on. Used in two places:
* 1. The standalone `simulate_transaction` MCP tool (agent-facing).
* 2. `sendTransaction` just before forwarding to Ledger — a second-line
* safety net that refuses to sign a tx that will definitely revert.
*/
export async function simulateTx(args: {
chain: SupportedChain;
from?: `0x${string}`;
to: `0x${string}`;
data?: `0x${string}`;
value?: string;
}): Promise<SimulationResult> {
const client = getClient(args.chain);
try {
const result = await client.call({
// viem's `call` requires an `account` when we want to reflect msg.sender
// state (balance, nonce). Falling back to a placeholder still catches most
// reverts; the sign-time caller always passes `from`.
account: args.from,
to: args.to,
data: args.data ?? "0x",
value: args.value ? BigInt(args.value) : 0n,
});
return {
chain: args.chain,
ok: true,
returnData: (result.data ?? "0x") as `0x${string}`,
};
} catch (err) {
return {
chain: args.chain,
ok: false,
revertReason: decodeRevertError(err),
};
}
}

export async function simulateTransaction(
args: SimulateTransactionArgs
): Promise<SimulationResult> {
return simulateTx({
chain: args.chain as SupportedChain,
from: args.from as `0x${string}` | undefined,
to: args.to as `0x${string}`,
data: args.data as `0x${string}` | undefined,
value: args.value,
});
}
27 changes: 27 additions & 0 deletions src/modules/simulation/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { z } from "zod";
import { SUPPORTED_CHAINS } from "../../types/index.js";

const chainEnum = z.enum(SUPPORTED_CHAINS as unknown as [string, ...string[]]);
const addressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/);
const dataSchema = z.string().regex(/^0x[a-fA-F0-9]*$/);

export const simulateTransactionInput = z.object({
chain: chainEnum.default("ethereum"),
from: addressSchema.optional().describe(
"msg.sender to simulate from. Omit for a state-independent call; include the " +
"user's wallet when the target contract's behavior depends on the caller " +
"(e.g. WETH9.deposit credits msg.sender, ERC-20 transfer debits msg.sender)."
),
to: addressSchema,
data: dataSchema.optional().describe("Hex-encoded calldata. Omit for a plain value transfer."),
value: z
.string()
.regex(/^\d+$/)
.optional()
.describe(
"Value to send with the call, in wei as a decimal string. Omit for 0. " +
'Example: "500000000000000000" for 0.5 ETH.'
),
});

export type SimulateTransactionArgs = z.infer<typeof simulateTransactionInput>;
10 changes: 10 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,16 @@ export interface UnsignedTx {
gasEstimate?: string;
/** Estimated gas cost in USD. */
gasCostUsd?: number;
/**
* Result of an eth_call simulation against the current chain state. `ok:false`
* with a revertReason is expected on the follow-up tx of an approve→action
* pair at prepare time (the approve hasn't been mined yet). At sign time, the
* same simulation is re-run and a revert aborts the signing path.
*/
simulation?: {
ok: boolean;
revertReason?: string;
};
/** If this tx is a prerequisite (e.g. ERC-20 approve), the follow-up tx is in `next`. */
next?: UnsignedTx;
/**
Expand Down
58 changes: 58 additions & 0 deletions test/session-regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,64 @@ describe("Bug 8: Compound V3 reader surfaces base balance even when a getAssetIn
expect(ethMarket!.baseSupplied?.symbol).toBe("USDC");
expect(ethMarket!.baseSupplied?.formatted).toBe("184874.39434");
});

it("skips a market with a nonzero base balance when the base token's decimals read fails", async () => {
// Live-session bug: wallet C0f5...4075 held 184377 USDC in cUSDCv3, but
// get_portfolio_summary rendered it as ~0.0000002 USDC because the base
// token's decimals() multicall entry transiently failed and the code fell
// back to decimals=18. A 6-decimal USDC supply formatted as 18 decimals
// looks like dust. Fix: skip the market rather than emit wrong-scale
// numbers; the direct get_compound_positions retry will typically succeed.
let callIdx = 0;
const mockClient = {
multicall: vi.fn(async ({ contracts }: { contracts: unknown[] }) => {
callIdx++;
if (contracts.length === 4) {
if (callIdx === 1) {
return [
{ status: "success", result: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
{ status: "success", result: 0 },
{ status: "success", result: 184_377_830_000n },
{ status: "success", result: 0n },
];
}
return [
{ status: "success", result: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
{ status: "success", result: 0 },
{ status: "success", result: 0n },
{ status: "success", result: 0n },
];
}
if ((contracts[0] as { functionName: string }).functionName === "decimals") {
// decimals() fails; symbol() also flaky. This is the transient condition.
return [
{ status: "failure", error: new Error('returned no data ("0x")') },
{ status: "failure", error: new Error('returned no data ("0x")') },
];
}
return [];
}),
};

vi.doMock("../src/data/rpc.js", () => ({
getClient: () => mockClient,
resetClients: () => {},
}));
vi.doMock("../src/data/format.js", async () => {
const actual = await vi.importActual<typeof import("../src/data/format.js")>(
"../src/data/format.js"
);
return { ...actual, priceTokenAmounts: async () => {} };
});

const { getCompoundPositions } = await import("../src/modules/compound/index.js");
const { positions } = await getCompoundPositions({
wallet: "0xC0f5b7f7703BA95dC7C09D4eF50A830622234075",
chains: ["ethereum"],
});
const ethMarket = positions.find((p) => p.chain === "ethereum");
expect(ethMarket).toBeUndefined();
});
});

describe("Bug 9: Portfolio summary aggregates Compound alongside Aave", () => {
Expand Down
Loading
Loading