Skip to content

Commit 87e3b62

Browse files
authored
Merge pull request #7 from szhygulin/fix/compound-decimals-and-tx-simulation
Fix Compound dust rendering and add tx simulation
2 parents 689b448 + cc19a28 commit 87e3b62

8 files changed

Lines changed: 455 additions & 5 deletions

File tree

src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ import {
117117

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

120+
import { simulateTransaction } from "./modules/simulation/index.js";
121+
import { simulateTransactionInput } from "./modules/simulation/schemas.js";
122+
120123
import { requestCapability, requestCapabilityInput } from "./modules/feedback/index.js";
121124

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

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

src/modules/compound/index.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,19 @@ async function readMarketPosition(
8686
})),
8787
];
8888
const metaResults = await client.multicall({ contracts: metaCalls, allowFailure: true });
89+
const baseSuppliedWei = supplied as bigint;
90+
const baseBorrowedWei = borrowed as bigint;
91+
// If either base balance is nonzero we MUST know the base token's decimals to
92+
// format correctly. Previously this silently fell back to 18, which rendered a
93+
// 184k USDC (6-decimal) supply as ~0.0000002 USDC — showed up as dust in the
94+
// portfolio summary while the direct get_compound_positions call succeeded.
95+
// Skip the market rather than emit a wrong-scale amount.
96+
if (
97+
metaResults[0].status !== "success" &&
98+
(baseSuppliedWei > 0n || baseBorrowedWei > 0n)
99+
) {
100+
return null;
101+
}
89102
const baseDecimals =
90103
metaResults[0].status === "success" ? Number(metaResults[0].result) : 18;
91104
const baseSymbol =
@@ -117,9 +130,6 @@ async function readMarketPosition(
117130
allowFailure: true,
118131
});
119132

120-
const baseSuppliedWei = supplied as bigint;
121-
const baseBorrowedWei = borrowed as bigint;
122-
123133
const collateral: TokenAmount[] = [];
124134
for (let i = 0; i < collateralAddrs.length; i++) {
125135
const balRes = collatResults[i * 3];

src/modules/execution/index.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { consumeHandle, retireHandle } from "../../signing/tx-store.js";
1010
import { assertTransactionSafe } from "../../signing/pre-sign-check.js";
1111
import { getClient, verifyChainId } from "../../data/rpc.js";
1212
import { erc20Abi } from "../../abis/erc20.js";
13+
import { simulateTx } from "../simulation/index.js";
1314
import {
1415
buildAaveSupply,
1516
buildAaveWithdraw,
@@ -86,10 +87,21 @@ async function resolveAssetMeta(
8687
return { decimals: Number(decimals), symbol: symbol as string };
8788
}
8889

89-
/** Attach gas estimate + USD cost + eth_call simulation result. */
90+
/** Attach eth_call simulation result, gas estimate, and USD cost. */
9091
async function enrichTx(tx: UnsignedTx): Promise<UnsignedTx> {
9192
const client = getClient(tx.chain);
9293
const from = tx.from;
94+
// Always simulate — even when gas estimation would succeed — so the caller
95+
// can see the decoded revert reason alongside the preview. A failed sim on
96+
// a standalone tx is a red flag; a failed sim on `tx.next` of an
97+
// approve→action pair is expected until the approve mines.
98+
tx.simulation = await simulateTx({
99+
chain: tx.chain,
100+
from,
101+
to: tx.to,
102+
data: tx.data,
103+
value: tx.value,
104+
});
93105
try {
94106
const gas = await client.estimateGas({
95107
account: from ?? "0x0000000000000000000000000000000000000001",
@@ -107,7 +119,9 @@ async function enrichTx(tx: UnsignedTx): Promise<UnsignedTx> {
107119
tx.gasCostUsd = round(gasEth * ethPrice, 2);
108120
}
109121
} catch {
110-
// Simulation fails for many legitimate reasons (insufficient allowance, etc.) — we surface the tx anyway.
122+
// Gas estimation fails for many legitimate reasons (insufficient allowance on
123+
// a follow-up step, etc.) — we surface the tx anyway. The simulation field
124+
// above has already captured any revert reason.
111125
}
112126
if (tx.next) tx.next = await enrichTx(tx.next);
113127
return tx;
@@ -277,6 +291,26 @@ export async function sendTransaction(args: SendTransactionArgs): Promise<{
277291
// (for approve) spender allowlist. A compromised agent can't slip an
278292
// "approve(attacker, MAX)" past this, even if the handle system were bypassed.
279293
await assertTransactionSafe(tx);
294+
// Re-simulate against current chain state before asking the user to sign.
295+
// At prepare time, step 2 of an approve→action pair legitimately reverts
296+
// because the approve isn't mined yet. By send time, the approve is on-chain
297+
// and the simulation should pass. A revert here means signing would waste gas
298+
// on a guaranteed failure — refuse rather than forward.
299+
const sim = await simulateTx({
300+
chain: tx.chain,
301+
from: tx.from,
302+
to: tx.to,
303+
data: tx.data,
304+
value: tx.value,
305+
});
306+
if (!sim.ok) {
307+
throw new Error(
308+
`Pre-sign simulation failed: ${sim.revertReason ?? "execution reverted"}. ` +
309+
`Refusing to forward to Ledger — signing this tx would burn gas on a revert. ` +
310+
`If a prerequisite step (e.g. an ERC-20 approve) must be mined first, send it ` +
311+
`and wait for confirmation before retrying. Use simulate_transaction to debug.`
312+
);
313+
}
280314
// Assert that tx.from is actually an account the paired wallet holds keys
281315
// for. Without this check, a prepare_* call with a user-supplied `wallet`
282316
// arg referencing an address the wallet doesn't control would be forwarded

src/modules/simulation/index.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { BaseError } from "viem";
2+
import { getClient } from "../../data/rpc.js";
3+
import type { SupportedChain } from "../../types/index.js";
4+
import type { SimulateTransactionArgs } from "./schemas.js";
5+
6+
export interface SimulationResult {
7+
chain: SupportedChain;
8+
ok: boolean;
9+
returnData?: `0x${string}`;
10+
revertReason?: string;
11+
}
12+
13+
/**
14+
* Best-effort decoding of a revert. viem wraps the RPC error in a BaseError
15+
* chain; `shortMessage` usually contains the decoded "Execution reverted with
16+
* reason: ..." line. We fall back to the first line of `.message` for non-viem
17+
* errors (e.g. raw RPC HTTP failures).
18+
*/
19+
function decodeRevertError(err: unknown): string {
20+
if (err instanceof BaseError) {
21+
return err.shortMessage || err.message.split("\n")[0] || "execution reverted";
22+
}
23+
if (err instanceof Error) {
24+
return err.message.split("\n")[0] || "execution reverted";
25+
}
26+
return "execution reverted";
27+
}
28+
29+
/**
30+
* Run an eth_call against the chain's RPC. Does NOT change state — this is the
31+
* same primitive wallets use for contract reads, extended here to catch reverts
32+
* that a blind signature would otherwise waste gas on. Used in two places:
33+
* 1. The standalone `simulate_transaction` MCP tool (agent-facing).
34+
* 2. `sendTransaction` just before forwarding to Ledger — a second-line
35+
* safety net that refuses to sign a tx that will definitely revert.
36+
*/
37+
export async function simulateTx(args: {
38+
chain: SupportedChain;
39+
from?: `0x${string}`;
40+
to: `0x${string}`;
41+
data?: `0x${string}`;
42+
value?: string;
43+
}): Promise<SimulationResult> {
44+
const client = getClient(args.chain);
45+
try {
46+
const result = await client.call({
47+
// viem's `call` requires an `account` when we want to reflect msg.sender
48+
// state (balance, nonce). Falling back to a placeholder still catches most
49+
// reverts; the sign-time caller always passes `from`.
50+
account: args.from,
51+
to: args.to,
52+
data: args.data ?? "0x",
53+
value: args.value ? BigInt(args.value) : 0n,
54+
});
55+
return {
56+
chain: args.chain,
57+
ok: true,
58+
returnData: (result.data ?? "0x") as `0x${string}`,
59+
};
60+
} catch (err) {
61+
return {
62+
chain: args.chain,
63+
ok: false,
64+
revertReason: decodeRevertError(err),
65+
};
66+
}
67+
}
68+
69+
export async function simulateTransaction(
70+
args: SimulateTransactionArgs
71+
): Promise<SimulationResult> {
72+
return simulateTx({
73+
chain: args.chain as SupportedChain,
74+
from: args.from as `0x${string}` | undefined,
75+
to: args.to as `0x${string}`,
76+
data: args.data as `0x${string}` | undefined,
77+
value: args.value,
78+
});
79+
}

src/modules/simulation/schemas.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { z } from "zod";
2+
import { SUPPORTED_CHAINS } from "../../types/index.js";
3+
4+
const chainEnum = z.enum(SUPPORTED_CHAINS as unknown as [string, ...string[]]);
5+
const addressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/);
6+
const dataSchema = z.string().regex(/^0x[a-fA-F0-9]*$/);
7+
8+
export const simulateTransactionInput = z.object({
9+
chain: chainEnum.default("ethereum"),
10+
from: addressSchema.optional().describe(
11+
"msg.sender to simulate from. Omit for a state-independent call; include the " +
12+
"user's wallet when the target contract's behavior depends on the caller " +
13+
"(e.g. WETH9.deposit credits msg.sender, ERC-20 transfer debits msg.sender)."
14+
),
15+
to: addressSchema,
16+
data: dataSchema.optional().describe("Hex-encoded calldata. Omit for a plain value transfer."),
17+
value: z
18+
.string()
19+
.regex(/^\d+$/)
20+
.optional()
21+
.describe(
22+
"Value to send with the call, in wei as a decimal string. Omit for 0. " +
23+
'Example: "500000000000000000" for 0.5 ETH.'
24+
),
25+
});
26+
27+
export type SimulateTransactionArgs = z.infer<typeof simulateTransactionInput>;

src/types/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,16 @@ export interface UnsignedTx {
222222
gasEstimate?: string;
223223
/** Estimated gas cost in USD. */
224224
gasCostUsd?: number;
225+
/**
226+
* Result of an eth_call simulation against the current chain state. `ok:false`
227+
* with a revertReason is expected on the follow-up tx of an approve→action
228+
* pair at prepare time (the approve hasn't been mined yet). At sign time, the
229+
* same simulation is re-run and a revert aborts the signing path.
230+
*/
231+
simulation?: {
232+
ok: boolean;
233+
revertReason?: string;
234+
};
225235
/** If this tx is a prerequisite (e.g. ERC-20 approve), the follow-up tx is in `next`. */
226236
next?: UnsignedTx;
227237
/**

test/session-regression.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,64 @@ describe("Bug 8: Compound V3 reader surfaces base balance even when a getAssetIn
490490
expect(ethMarket!.baseSupplied?.symbol).toBe("USDC");
491491
expect(ethMarket!.baseSupplied?.formatted).toBe("184874.39434");
492492
});
493+
494+
it("skips a market with a nonzero base balance when the base token's decimals read fails", async () => {
495+
// Live-session bug: wallet C0f5...4075 held 184377 USDC in cUSDCv3, but
496+
// get_portfolio_summary rendered it as ~0.0000002 USDC because the base
497+
// token's decimals() multicall entry transiently failed and the code fell
498+
// back to decimals=18. A 6-decimal USDC supply formatted as 18 decimals
499+
// looks like dust. Fix: skip the market rather than emit wrong-scale
500+
// numbers; the direct get_compound_positions retry will typically succeed.
501+
let callIdx = 0;
502+
const mockClient = {
503+
multicall: vi.fn(async ({ contracts }: { contracts: unknown[] }) => {
504+
callIdx++;
505+
if (contracts.length === 4) {
506+
if (callIdx === 1) {
507+
return [
508+
{ status: "success", result: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
509+
{ status: "success", result: 0 },
510+
{ status: "success", result: 184_377_830_000n },
511+
{ status: "success", result: 0n },
512+
];
513+
}
514+
return [
515+
{ status: "success", result: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
516+
{ status: "success", result: 0 },
517+
{ status: "success", result: 0n },
518+
{ status: "success", result: 0n },
519+
];
520+
}
521+
if ((contracts[0] as { functionName: string }).functionName === "decimals") {
522+
// decimals() fails; symbol() also flaky. This is the transient condition.
523+
return [
524+
{ status: "failure", error: new Error('returned no data ("0x")') },
525+
{ status: "failure", error: new Error('returned no data ("0x")') },
526+
];
527+
}
528+
return [];
529+
}),
530+
};
531+
532+
vi.doMock("../src/data/rpc.js", () => ({
533+
getClient: () => mockClient,
534+
resetClients: () => {},
535+
}));
536+
vi.doMock("../src/data/format.js", async () => {
537+
const actual = await vi.importActual<typeof import("../src/data/format.js")>(
538+
"../src/data/format.js"
539+
);
540+
return { ...actual, priceTokenAmounts: async () => {} };
541+
});
542+
543+
const { getCompoundPositions } = await import("../src/modules/compound/index.js");
544+
const { positions } = await getCompoundPositions({
545+
wallet: "0xC0f5b7f7703BA95dC7C09D4eF50A830622234075",
546+
chains: ["ethereum"],
547+
});
548+
const ethMarket = positions.find((p) => p.chain === "ethereum");
549+
expect(ethMarket).toBeUndefined();
550+
});
493551
});
494552

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

0 commit comments

Comments
 (0)