Skip to content
Open
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
49 changes: 42 additions & 7 deletions apps/cowswap-frontend/src/common/hooks/useGetReceipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { Command } from '@cowprotocol/types'

import { useConfig } from 'wagmi'
import { waitForTransactionReceipt } from 'wagmi/actions'
import { getTransaction, getTransactionReceipt } from 'wagmi/actions'

import type { TransactionReceipt, Hex } from 'viem'

Expand All @@ -19,21 +19,56 @@ interface RetryResult<T> {

export type GetReceipt = (hash: string) => RetryResult<TransactionReceipt>

/**
* Thrown when a transaction hash is not found on-chain or in the mempool.
* This typically occurs with MetaMask Smart Transactions (STX) where a synthetic hash
* is returned to the dapp but the transaction is cancelled before being broadcast.
*/
export class TransactionNotBroadcastError extends Error {
constructor(hash: string) {
super(`Transaction ${hash} not found on-chain or in mempool`)
this.name = 'TransactionNotBroadcastError'
}
}

export function useGetReceipt(chainId: SupportedChainId): GetReceipt {
const config = useConfig()

const getReceipt = useCallback<GetReceipt>(
(hash) => {
const retryOptions = RETRY_OPTIONS_BY_CHAIN_ID[chainId] || DEFAULT_RETRY_OPTIONS

return retry(() => {
return waitForTransactionReceipt(config, { hash: hash as Hex }).then((receipt) => {
if (receipt === null) {
console.debug('[useGetReceipt] Retrying for hash', hash)
return retry(async () => {
// Check if the receipt is already available (non-blocking, returns immediately).
// Swallow all errors — any failure just means "no receipt yet" and we proceed to
// check whether the transaction exists at all.
const receipt = await getTransactionReceipt(config, { hash: hash as Hex }).catch(() => null)
if (receipt) return receipt

// No receipt. Check whether the transaction exists anywhere (mempool or chain).
// If it doesn't exist, the hash was never broadcast — e.g. MetaMask Smart Transactions
// returns a synthetic hash that is cancelled before any real tx is submitted.
let txExists: boolean
try {
await getTransaction(config, { hash: hash as Hex })
txExists = true
} catch (e: unknown) {
const name = (e as { name?: string })?.name
if (name === 'TransactionNotFoundError') {
txExists = false
} else {
// Network / RPC error (e.g. 402, timeout): we can't determine if the tx exists.
// Retry the check on the next attempt rather than making an assumption.
throw new RetryableError()
}
return receipt
})
}

if (!txExists) {
throw new TransactionNotBroadcastError(hash)
}

// Transaction is in the mempool but not yet mined — retry
throw new RetryableError()
}, retryOptions)
},
[config, chainId],
Expand Down
12 changes: 10 additions & 2 deletions apps/cowswap-frontend/src/legacy/hooks/useWrapCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
calculateGasMargin,
formatTokenAmount,
getIsNativeToken,
getProviderErrorMessage,
getRawCurrentChainIdFromUrl,
isRejectRequestProviderError,
} from '@cowprotocol/common-utils'
Expand Down Expand Up @@ -46,6 +47,7 @@ export interface WrapUnwrapContext {
addTransaction: TransactionAdder
closeModals: Command
openTransactionConfirmationModal: Command
openErrorModal: (message: string) => void
analytics: ReturnType<typeof useCowAnalytics>
}

Expand Down Expand Up @@ -73,6 +75,7 @@ export async function wrapUnwrapCallback(
addTransaction,
openTransactionConfirmationModal,
closeModals,
openErrorModal,
analytics,
} = context
const isNativeIn = getIsNativeToken(amount.currency)
Expand All @@ -98,8 +101,6 @@ export async function wrapUnwrapCallback(

return { hash }
} catch (error: unknown) {
useModals && closeModals()

const isRejected = isRejectRequestProviderError(error)
const action = isRejected ? t`Reject` : t`Error`
sendWrapEvent(analytics, action as WrapAction, operationMessage, amount)
Expand All @@ -108,6 +109,13 @@ export async function wrapUnwrapCallback(
console.error(errorMessage, error)

if (isRejected) {
useModals && closeModals()
return null
}

if (useModals) {
// Show the error inside the modal (transitions from pending → error screen)
openErrorModal(getProviderErrorMessage(error) || t`Transaction failed`)
return null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ export default createReducer(initialState, (builder) =>
tx.receipt = receipt
tx.confirmedTime = now()

if (receipt.status === 'reverted') {
tx.errorMessage = 'Transaction failed'
}

if (tx.linkedTransactionHash) {
delete transactions[chainId]?.[tx.linkedTransactionHash]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,21 @@ interface TxReplacedPayload {
transaction: EnhancedTransactionDetails
}

interface TxCancelledNotBroadcastPayload {
transaction: EnhancedTransactionDetails
}

export enum OnchainTxEvents {
BEFORE_TX_FINALIZE = 'BEFORE_TX_FINALIZE',
TX_REPLACED = 'TX_REPLACED',
TX_CANCELLED_NOT_BROADCAST = 'TX_CANCELLED_NOT_BROADCAST',
}

// Define types for event payloads
export interface OnchainTxEventPayloadMap {
[OnchainTxEvents.BEFORE_TX_FINALIZE]: FinalizeTxPayload
[OnchainTxEvents.TX_REPLACED]: TxReplacedPayload
[OnchainTxEvents.TX_CANCELLED_NOT_BROADCAST]: TxCancelledNotBroadcastPayload
}

export const ONCHAIN_TRANSACTIONS_EVENTS = Object.freeze(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export function usePendingTransactionsContext(): CheckEthereumTransactions | nul
async () => {
if (!lastBlockNumber || !account) return null

const transactionsCount = await getTransactionCount(config, { address: account })
// Fallback to 0 on failure so receipt checking can still run even when the nonce fetch fails
// (e.g. temporary RPC errors). The nonce-based replacement check will simply be skipped.
const transactionsCount = await getTransactionCount(config, { address: account }).catch(() => 0)
Comment on lines +37 to +39
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t map nonce-fetch failures to 0

Line 39 turns RPC failure into a valid nonce value, which makes the replacement check indistinguishable from a true fresh-account state. That can suppress replacement detection while the RPC is degraded.

Suggested direction
- const transactionsCount = await getTransactionCount(config, { address: account }).catch(() => 0)
+ const transactionsCount = await getTransactionCount(config, { address: account }).catch(() => undefined)

Then type transactionsCount as number | undefined in CheckEthereumTransactions and guard downstream replacement logic with transactionsCount !== undefined.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/hooks/usePendingTransactionsContext.ts`
around lines 37 - 39, Don't turn RPC nonce-fetch failures into a valid 0 nonce:
remove the .catch(() => 0) on getTransactionCount so failures produce undefined
(or propagate), type transactionsCount as number | undefined (used by
CheckEthereumTransactions), and update any replacement-detection logic (in
CheckEthereumTransactions and downstream code) to explicitly guard with
transactionsCount !== undefined before treating the nonce as a real count.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with the bot, it's better to have it as null or something

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the CheckEthereumTransactions type definition
rg -n "transactionsCount" --type ts -C 3

# Find the types file
fd "types.ts" apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/

Length of output: 5669


@shoom3301 Agreed — null is the cleanest signal here since it clearly means "unknown" rather than "zero". Here are all three spots that need to move together:

1. types.ts — widen the type

-  transactionsCount: number
+  transactionsCount: number | null

2. usePendingTransactionsContext.ts — use null as the fallback

-      const transactionsCount = await getTransactionCount(config, { address: account }).catch(() => 0)
+      const transactionsCount = await getTransactionCount(config, { address: account }).catch(() => null)

3. checkOnChainTransaction.ts line 77 — guard the replacement check

-      if (transaction.nonce !== undefined && transaction.nonce < transactionsCount) {
+      if (transaction.nonce !== undefined && transactionsCount !== null && transaction.nonce < transactionsCount) {

The existing transaction.nonce !== undefined guard already prevents a false positive when the nonce is missing; adding transactionsCount !== null makes the symmetric case explicit — when the RPC count is unknown, the replacement check is simply skipped rather than silently evaluated against 0.


const params: CheckEthereumTransactions = {
chainId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { SupportedChainId } from '@cowprotocol/cow-sdk'

import { checkedTransaction } from 'legacy/state/enhancedTransactions/actions'
import { EnhancedTransactionDetails } from 'legacy/state/enhancedTransactions/reducer'

import { TransactionNotBroadcastError } from 'common/hooks/useGetReceipt'

import { finalizeEthereumTransaction } from './finalizeEthereumTransaction'
import { handleTransactionReplacement } from './handleTransactionReplacement'

import { ONCHAIN_TRANSACTIONS_EVENTS, OnchainTxEvents } from '../../../onchainTransactionsEvents'
import { CheckEthereumTransactions } from '../types'

// Grace period before treating a "not found" hash as definitely not broadcast.
// Allows time for freshly submitted transactions to propagate to the node.
// Fast chains (Arbitrum, Base) mine transactions in <5 seconds, so a shorter
// grace period is safe and avoids long "stuck pending" UX after STX cancellations.
const NOT_BROADCAST_GRACE_PERIOD_MS: Record<number, number> = {
[SupportedChainId.MAINNET]: 60_000,
[SupportedChainId.GNOSIS_CHAIN]: 30_000,
[SupportedChainId.ARBITRUM_ONE]: 15_000,
[SupportedChainId.BASE]: 15_000,
[SupportedChainId.SEPOLIA]: 30_000,
}
const DEFAULT_NOT_BROADCAST_GRACE_PERIOD_MS = 30_000

// TODO: Add proper return type annotation
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function checkOnChainTransaction(transaction: EnhancedTransactionDetails, params: CheckEthereumTransactions) {
Expand Down Expand Up @@ -34,7 +52,23 @@ export function checkOnChainTransaction(transaction: EnhancedTransactionDetails,
.catch((error) => {
setTxLastBlockNumber()

if (!error.isCancelledError) {
if (error instanceof TransactionNotBroadcastError) {
// The hash doesn't exist on-chain or in the mempool — it was likely a MetaMask Smart
// Transaction synthetic hash that was cancelled before the tx was ever broadcast.
// Wait for the grace period before acting to allow for node propagation delays.
const gracePeriodMs = NOT_BROADCAST_GRACE_PERIOD_MS[chainId] ?? DEFAULT_NOT_BROADCAST_GRACE_PERIOD_MS
const pendingMs = Date.now() - transaction.addedTime
if (pendingMs >= gracePeriodMs) {
console.log('[FinalizeTxUpdater] Transaction not found on-chain after grace period, marking as replaced.', {
hash,
})
ONCHAIN_TRANSACTIONS_EVENTS.emit(OnchainTxEvents.TX_CANCELLED_NOT_BROADCAST, { transaction })
handleTransactionReplacement(transaction, params)
}
return
}

if (!error.isCancelledError && !error.isRetryableError) {
console.error(`[FinalizeTxUpdater] Failed to get transaction receipt for tx: ${hash}`, error)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {
} from '@cowprotocol/events'
import { useAddSnackbar } from '@cowprotocol/snackbars'

import { Trans } from '@lingui/react/macro'
import { WIDGET_EVENT_EMITTER } from 'widgetEventEmitter'

import { ONCHAIN_TRANSACTIONS_EVENTS, OnchainTxEvents } from 'modules/onchainTransactions/onchainTransactionsEvents'
import { TransactionContentWithLink } from 'modules/orders'
import { getCowSoundError } from 'modules/sounds'

Expand Down Expand Up @@ -62,5 +64,27 @@ export function OnchainTransactionEventsUpdater() {
}
}, [addSnackbar])

useEffect(() => {
const handler = ONCHAIN_TRANSACTIONS_EVENTS.on({
event: OnchainTxEvents.TX_CANCELLED_NOT_BROADCAST,
handler({ transaction }) {
getCowSoundError().play()
addSnackbar({
id: `cancelled-not-broadcast-${transaction.hash}`,
icon: 'alert',
content: (
<TransactionContentWithLink transactionHash={transaction.hash}>
<Trans>Transaction cancelled: it was not submitted to the blockchain.</Trans>
</TransactionContentWithLink>
),
})
},
})

return () => {
ONCHAIN_TRANSACTIONS_EVENTS.off(handler)
}
}, [addSnackbar])

return null
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export function TradeWidgetModals({

const { isOpen: isTradeReviewOpen, error: confirmError, pendingTrade } = useTradeConfirmState()
const { field } = useSelectTokenWidgetState()
const [{ isOpen: isWrapNativeOpen }, setWrapNativeScreenState] = useWrapNativeScreenState()
const [{ isOpen: isWrapNativeOpen, errorMessage: wrapNativeError }, setWrapNativeScreenState] =
useWrapNativeScreenState()
const {
approveInProgress,
isPendingInProgress,
Expand Down Expand Up @@ -163,6 +164,14 @@ export function TradeWidgetModals({
}

if (isWrapNativeOpen) {
if (wrapNativeError) {
return (
<TransactionErrorContent
message={wrapNativeError}
onDismiss={() => setWrapNativeScreenState({ isOpen: false })}
/>
)
}
return <WrapNativeModal />
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ function useWrapNativeContext(amount: Nullish<CurrencyAmount<Currency>>): WrapUn
openTransactionConfirmationModal() {
setWrapNativeState({ isOpen: true })
},
openErrorModal(errorMessage: string) {
setWrapNativeState({ isOpen: true, errorMessage })
},
}
}, [
wethChainId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { atom } from 'jotai'

interface WrapNativeState {
isOpen: boolean
errorMessage?: string
}

export const wrapNativeStateAtom = atom<WrapNativeState>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export interface TradeFormButtonContext {

connectWallet: Command

wrapNativeFlow(): void
wrapNativeFlow(): Promise<unknown>
}

export interface TradeFormValidationCommonContext {
Expand Down
12 changes: 10 additions & 2 deletions libs/common-utils/src/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ interface Market<T = string> {
quoteToken: T
}

const PROVIDER_REJECT_REQUEST_CODES = [4001, -32000] // See https://eips.ethereum.org/EIPS/eip-1193
// 4001 is the standard EIP-1193 user rejection code.
// -32000 is a generic server error used by nodes for things like "intrinsic gas too low";
// it is NOT included here because relying on it alone causes node errors to be silently swallowed.
const PROVIDER_REJECT_REQUEST_CODES = [4001] // See https://eips.ethereum.org/EIPS/eip-1193
const PROVIDER_REJECT_REQUEST_ERROR_MESSAGES = [
'User denied message signature',
'User rejected',
Expand Down Expand Up @@ -123,7 +126,12 @@ export function getChainIdValues(): ChainId[] {
*/
export function getProviderErrorMessage(error: unknown): string | undefined {
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) return error.message as string
if (error && typeof error === 'object') {
// Prefer viem's shortMessage (concise, human-readable) over the full message
// which includes verbose request arguments and hex data.
if ('shortMessage' in error && typeof error.shortMessage === 'string') return error.shortMessage
if ('message' in error) return error.message as string
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard message type before returning to avoid downstream crashes

At Line 130, error.message as string can return non-string values at runtime. That can break consumers that call string methods (e.g., toLowerCase() in isRejectRequestProviderError). Please add a runtime typeof check.

Suggested fix
-    if ('message' in error) return error.message as string
+    if ('message' in error && typeof error.message === 'string') return error.message
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ('message' in error) return error.message as string
if ('message' in error && typeof error.message === 'string') return error.message
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/common-utils/src/misc.ts` at line 130, The current return uses
error.message as string without verifying its runtime type; update the guard to
check typeof error.message === 'string' before returning it and otherwise fall
back to a safe string (e.g., String(error) or ''), so callers like
isRejectRequestProviderError won't crash when calling string methods; locate the
check around the variable error in libs/common-utils/src/misc.ts and replace the
unconditional cast with a typeof check and safe fallback.

}
return error?.toString()
}

Expand Down
Loading