@@ -10,6 +10,7 @@ import { consumeHandle, retireHandle } from "../../signing/tx-store.js";
1010import { assertTransactionSafe } from "../../signing/pre-sign-check.js" ;
1111import { getClient , verifyChainId } from "../../data/rpc.js" ;
1212import { erc20Abi } from "../../abis/erc20.js" ;
13+ import { simulateTx } from "../simulation/index.js" ;
1314import {
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. */
9091async 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
0 commit comments