diff --git a/packages/interwovenkit-react/src/pages/bridge/FooterWithErc20Approval.tsx b/packages/interwovenkit-react/src/pages/bridge/FooterWithErc20Approval.tsx index 82594ce6..8b27454b 100644 --- a/packages/interwovenkit-react/src/pages/bridge/FooterWithErc20Approval.tsx +++ b/packages/interwovenkit-react/src/pages/bridge/FooterWithErc20Approval.tsx @@ -7,9 +7,9 @@ import Footer from "@/components/Footer" import FormHelp from "@/components/form/FormHelp" import { normalizeError } from "@/data/http" import { useGetProvider } from "@/data/signer" -import { TimeoutError, withTimeout } from "@/lib/promise" +import { TimeoutError } from "@/lib/promise" import { useFindSkipChain } from "./data/chains" -import { switchEthereumChain } from "./data/evm" +import { createErc20ApproveTx, sendUncheckedEvmTransaction, switchEthereumChain } from "./data/evm" import { getErc20ApprovalStateKey, shouldResetErc20ApprovalMutationState, @@ -110,18 +110,16 @@ const FooterWithErc20Approval = ({ tx, children }: Props) => { for (const approval of approvalsNeeded) { const { token_contract, spender, amount } = approval - const erc20Abi = [ - "function approve(address spender, uint256 amount) external returns (bool)", - ] - const tokenContract = new ethers.Contract(token_contract, erc20Abi, signer) - const response = await tokenContract.approve(spender, amount) - // Same timeout as bridge tx — ethers' wait() has no built-in - // timeout. If the tx is dropped without replacement, it hangs. - await withTimeout( - response.wait(), - 30_000, - "Approval was not confirmed in time. It may still be processing.", + const { wait } = await sendUncheckedEvmTransaction( + signer, + provider, + createErc20ApproveTx({ + tokenContract: token_contract, + spender, + amount, + }), ) + await wait } return true diff --git a/packages/interwovenkit-react/src/pages/bridge/data/evm.test.ts b/packages/interwovenkit-react/src/pages/bridge/data/evm.test.ts new file mode 100644 index 00000000..c7e86ae9 --- /dev/null +++ b/packages/interwovenkit-react/src/pages/bridge/data/evm.test.ts @@ -0,0 +1,148 @@ +import { ethers } from "ethers" +import { createErc20ApproveTx, sendUncheckedEvmTransaction } from "./evm" + +describe("createErc20ApproveTx", () => { + it("encodes ERC-20 approve calldata", () => { + const tx = createErc20ApproveTx({ + tokenContract: "0x0000000000000000000000000000000000000001", + spender: "0x0000000000000000000000000000000000000002", + amount: "123", + }) + + expect(tx.to).toBe("0x0000000000000000000000000000000000000001") + + const parsed = new ethers.Interface([ + "function approve(address spender, uint256 amount) external returns (bool)", + ]).parseTransaction({ data: tx.data as string }) + + expect(parsed?.name).toBe("approve") + expect(parsed?.args[0]).toBe("0x0000000000000000000000000000000000000002") + expect(parsed?.args[1]).toBe(123n) + }) +}) + +describe("sendUncheckedEvmTransaction", () => { + it("falls back to waiting by hash with a confirmation timeout", async () => { + const receipt = { status: 1 } + const signer = { + sendUncheckedTransaction: vi.fn().mockResolvedValue("0xabc"), + } + const provider = { + getBlockNumber: vi.fn().mockResolvedValue(123), + getTransaction: vi.fn().mockResolvedValue(null), + waitForTransaction: vi.fn().mockResolvedValue(receipt), + } + const tx = { to: "0x0000000000000000000000000000000000000001", data: "0x1234" } + + const result = await sendUncheckedEvmTransaction(signer as never, provider as never, tx) + + expect(signer.sendUncheckedTransaction).toHaveBeenCalledWith(tx) + expect(provider.getBlockNumber).toHaveBeenCalledTimes(1) + expect(provider.getTransaction).toHaveBeenCalledWith("0xabc") + await expect(result.wait).resolves.toBe(receipt) + expect(provider.waitForTransaction).toHaveBeenCalledWith("0xabc", 1, 30000) + expect(result.txHash).toBe("0xabc") + }) + + it("falls back to waiting by hash when getTransaction rejects", async () => { + const receipt = { status: 1 } + const signer = { + sendUncheckedTransaction: vi.fn().mockResolvedValue("0xabc"), + } + const provider = { + getBlockNumber: vi.fn().mockResolvedValue(123), + getTransaction: vi.fn().mockRejectedValue(new Error("invalid value for value.nonce")), + waitForTransaction: vi.fn().mockResolvedValue(receipt), + } + const tx = { to: "0x0000000000000000000000000000000000000001", data: "0x1234" } + + const result = await sendUncheckedEvmTransaction(signer as never, provider as never, tx) + + expect(signer.sendUncheckedTransaction).toHaveBeenCalledWith(tx) + expect(provider.getTransaction).toHaveBeenCalledWith("0xabc") + await expect(result.wait).resolves.toBe(receipt) + expect(provider.waitForTransaction).toHaveBeenCalledWith("0xabc", 1, 30000) + expect(result.txHash).toBe("0xabc") + }) + + it("rethrows unrelated getTransaction errors", async () => { + const signer = { + sendUncheckedTransaction: vi.fn().mockResolvedValue("0xabc"), + } + const provider = { + getBlockNumber: vi.fn().mockResolvedValue(123), + getTransaction: vi.fn().mockRejectedValue(new Error("rpc unavailable")), + waitForTransaction: vi.fn(), + } + const tx = { to: "0x0000000000000000000000000000000000000001", data: "0x1234" } + + const result = await sendUncheckedEvmTransaction(signer as never, provider as never, tx) + + await expect(result.wait).rejects.toThrow("rpc unavailable") + expect(provider.waitForTransaction).not.toHaveBeenCalled() + }) + + it("uses a replaceable transaction wait when the transaction response is available", async () => { + const receipt = { status: 1 } + const wait = vi.fn().mockResolvedValue(receipt) + const replaceableTransaction = vi.fn().mockReturnValue({ wait }) + const signer = { + sendUncheckedTransaction: vi.fn().mockResolvedValue("0xabc"), + } + const provider = { + getBlockNumber: vi.fn().mockResolvedValue(123), + getTransaction: vi.fn().mockResolvedValue({ replaceableTransaction }), + waitForTransaction: vi.fn(), + } + const tx = { to: "0x0000000000000000000000000000000000000001", data: "0x1234" } + + const result = await sendUncheckedEvmTransaction(signer as never, provider as never, tx) + + await expect(result.wait).resolves.toBe(receipt) + expect(replaceableTransaction).toHaveBeenCalledWith(123) + expect(wait).toHaveBeenCalledWith(1, 30000) + expect(provider.waitForTransaction).not.toHaveBeenCalled() + }) + + it("throws CALL_EXCEPTION when transaction reverts", async () => { + const receipt = { status: 0 } + const signer = { + sendUncheckedTransaction: vi.fn().mockResolvedValue("0xabc"), + } + const provider = { + getBlockNumber: vi.fn().mockResolvedValue(123), + getTransaction: vi.fn().mockResolvedValue(null), + waitForTransaction: vi.fn().mockResolvedValue(receipt), + } + const tx = { to: "0x0000000000000000000000000000000000000001", data: "0x1234" } + + const result = await sendUncheckedEvmTransaction(signer as never, provider as never, tx) + + expect(result.txHash).toBe("0xabc") + await expect(result.wait).rejects.toMatchObject({ + message: "transaction execution reverted", + code: "CALL_EXCEPTION", + receipt, + }) + }) + + it("throws a timeout error when confirmation does not arrive in time", async () => { + const signer = { + sendUncheckedTransaction: vi.fn().mockResolvedValue("0xabc"), + } + const provider = { + getBlockNumber: vi.fn().mockResolvedValue(123), + getTransaction: vi.fn().mockResolvedValue(null), + waitForTransaction: vi.fn().mockResolvedValue(null), + } + const tx = { to: "0x0000000000000000000000000000000000000001", data: "0x1234" } + + const result = await sendUncheckedEvmTransaction(signer as never, provider as never, tx) + + await expect(result.wait).rejects.toMatchObject({ + message: "Transaction confirmation timed out", + code: "TIMEOUT", + transactionHash: "0xabc", + }) + }) +}) diff --git a/packages/interwovenkit-react/src/pages/bridge/data/evm.ts b/packages/interwovenkit-react/src/pages/bridge/data/evm.ts index 3b1fa491..1eeeb2b8 100644 --- a/packages/interwovenkit-react/src/pages/bridge/data/evm.ts +++ b/packages/interwovenkit-react/src/pages/bridge/data/evm.ts @@ -1,7 +1,75 @@ -import type { BrowserProvider } from "ethers" +import type { BrowserProvider, JsonRpcSigner, TransactionReceipt, TransactionRequest } from "ethers" +import { ethers } from "ethers" import { path } from "ramda" +import { TimeoutError } from "@/lib/promise" import type { RouterChainJson } from "./chains" +const EVM_TX_CONFIRMATION_TIMEOUT_MS = 30000 + +function createTimeoutError(txHash: string) { + const error = new TimeoutError("Transaction confirmation timed out") as TimeoutError & { + code: string + transactionHash: string + } + + error.code = "TIMEOUT" + error.transactionHash = txHash + + return error +} + +function createCallException(receipt: TransactionReceipt) { + const error = new Error("transaction execution reverted") as Error & { + code: string + receipt: TransactionReceipt + } + + error.code = "CALL_EXCEPTION" + error.receipt = receipt + + return error +} + +function isRecoverableGetTransactionError(error: unknown) { + return error instanceof Error && /invalid value for value\.nonce/i.test(error.message) +} + +type EvmReceiptProvider = Pick< + BrowserProvider, + "getBlockNumber" | "getTransaction" | "waitForTransaction" +> + +async function waitForEvmReceipt({ + provider, + txHash, + startBlock, +}: { + provider: EvmReceiptProvider + txHash: string + startBlock: number +}) { + const transaction = await provider.getTransaction(txHash).catch((error) => { + if (isRecoverableGetTransactionError(error)) { + return null + } + + throw error + }) + const receipt = transaction + ? await transaction.replaceableTransaction(startBlock).wait(1, EVM_TX_CONFIRMATION_TIMEOUT_MS) + : await provider.waitForTransaction(txHash, 1, EVM_TX_CONFIRMATION_TIMEOUT_MS) + + if (!receipt) { + throw createTimeoutError(txHash) + } + + if (receipt.status === 0) { + throw createCallException(receipt) + } + + return receipt +} + export async function switchEthereumChain(provider: BrowserProvider, chain: RouterChainJson) { const { chain_type, chain_id, chain_name, evm_fee_asset, rpc } = chain @@ -36,3 +104,35 @@ export async function switchEthereumChain(provider: BrowserProvider, chain: Rout ]) } } + +const erc20Interface = new ethers.Interface([ + "function approve(address spender, uint256 amount) external returns (bool)", +]) + +export function createErc20ApproveTx({ + tokenContract, + spender, + amount, +}: { + tokenContract: string + spender: string + amount: string +}): TransactionRequest { + return { + to: tokenContract, + data: erc20Interface.encodeFunctionData("approve", [spender, amount]), + } +} + +export async function sendUncheckedEvmTransaction( + signer: Pick, + provider: EvmReceiptProvider, + tx: TransactionRequest, +) { + const startBlock = await provider.getBlockNumber() + const txHash = await signer.sendUncheckedTransaction(tx) + + const wait = waitForEvmReceipt({ provider, txHash, startBlock }) + + return { txHash, wait } +} diff --git a/packages/interwovenkit-react/src/pages/bridge/data/tx.ts b/packages/interwovenkit-react/src/pages/bridge/data/tx.ts index d6f50987..14ee3d7c 100644 --- a/packages/interwovenkit-react/src/pages/bridge/data/tx.ts +++ b/packages/interwovenkit-react/src/pages/bridge/data/tx.ts @@ -21,7 +21,7 @@ import { useSignWithEthSecp256k1, } from "@/data/signer" import { waitForTxConfirmationWithClient } from "@/data/tx" -import { TimeoutError, withTimeout } from "@/lib/promise" +import { TimeoutError } from "@/lib/promise" import { Link, useLocationState, useNavigate } from "@/lib/router" import { useNotification } from "@/public/app/NotificationContext" import { DEFAULT_GAS_ADJUSTMENT } from "@/public/data/constants" @@ -33,7 +33,7 @@ import { useFindSkipAsset } from "./assets" import { decodeCosmosAminoMessages } from "./bridgeTxUtils" import { useChainType, useFindSkipChain, useSkipChain } from "./chains" import { useCosmosWallets } from "./cosmos" -import { switchEthereumChain } from "./evm" +import { sendUncheckedEvmTransaction, switchEthereumChain } from "./evm" import type { FormValues } from "./form" import type { HistoryDetails } from "./history" import { useBridgeHistoryList } from "./history" @@ -176,17 +176,12 @@ export function useBridgeTx(tx: TxJson, options?: UseBridgeTxOptions) { const provider = await getProvider() const signer = await provider.getSigner() await switchEthereumChain(provider, srcChain) - const response = await signer.sendTransaction({ chainId, to, value, data: `0x${data}` }) - // ethers' wait() has no built-in timeout (timeout parameter - // defaults to 0/disabled). If the tx is dropped without an - // on-chain replacement, the promise hangs indefinitely. - // 30s matches the Cosmos waitForTxConfirmationWithClient timeout. - const wait = withTimeout( - response.wait(), - 30_000, - "Transaction was not confirmed in time. It may still be processing.", - ) - return { txHash: response.hash, wait } + return sendUncheckedEvmTransaction(signer, provider, { + chainId, + to, + value, + data: `0x${data}`, + }) } throw new Error("Unlisted chain type")