Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions packages/interwovenkit-react/src/pages/bridge/data/evm.test.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
102 changes: 101 additions & 1 deletion packages/interwovenkit-react/src/pages/bridge/data/evm.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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<JsonRpcSigner, "sendUncheckedTransaction">,
provider: EvmReceiptProvider,
tx: TransactionRequest,
) {
const startBlock = await provider.getBlockNumber()
const txHash = await signer.sendUncheckedTransaction(tx)

const wait = waitForEvmReceipt({ provider, txHash, startBlock })

return { txHash, wait }
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
21 changes: 8 additions & 13 deletions packages/interwovenkit-react/src/pages/bridge/data/tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
Loading