From 7dd71436da5c844c5e04a18d6bfa79decd59a324 Mon Sep 17 00:00:00 2001 From: ali Date: Wed, 4 Feb 2026 06:43:42 +0300 Subject: [PATCH] Enhance EVM transaction handling for account abstraction (AA) detection. Added early detection for AA transactions, refined nonce checks to accommodate contract wallets and high nonces, and improved logging for transaction tracing. This ensures more accurate transaction validation and reduces false negatives. --- src/services/chains/evm/transactionService.ts | 180 ++++++++++++++---- 1 file changed, 138 insertions(+), 42 deletions(-) diff --git a/src/services/chains/evm/transactionService.ts b/src/services/chains/evm/transactionService.ts index 1a10f6b9a..dadda1653 100644 --- a/src/services/chains/evm/transactionService.ts +++ b/src/services/chains/evm/transactionService.ts @@ -103,13 +103,38 @@ export async function getEvmTransactionInfoFromNetwork( } txTrace(input, 'getEvmTransactionInfoFromNetwork:notFoundByHash'); + // For AA transactions, try to detect early by checking if tx goes to EntryPoint + // This allows us to skip nonce checks entirely without relying on getCode() + let isLikelyAATx = false; + try { + logger.debug( + 'NODE RPC request count - getEvmTransactionInfoFromNetwork provider.getTransaction for AA detection:', + input.txHash, + ); + const tx = await provider.getTransaction(input.txHash); + if (tx && isErc4337EntryPointTx(tx.to)) { + isLikelyAATx = true; + txTrace(input, 'getEvmTransactionInfoFromNetwork:aa_tx_detected_early', { + txTo: tx.to, + }); + } + } catch (e) { + // If we can't fetch the tx, that's okay - we'll continue with normal flow + logger.debug('Could not fetch transaction for early AA detection', { + error: e?.message, + txHash: input.txHash, + }); + } + // NOTE: For account-abstraction / smart contract wallets, `getTransactionCount(wallet)` // is not meaningful (it stays 0 forever for most contracts). Using it causes donations // to remain "pending" indefinitely when a (EOA) nonce is passed from the client. // We only apply nonce-based "mined yet?" checks for EOAs. - let shouldUseNonceChecks = typeof nonce === 'number'; + let shouldUseNonceChecks = typeof nonce === 'number' && !isLikelyAATx; + let isContractWallet = false; txTrace(input, 'getEvmTransactionInfoFromNetwork:nonceChecks:init', { shouldUseNonceChecks, + isLikelyAATx, }); if (shouldUseNonceChecks) { try { @@ -118,11 +143,17 @@ export async function getEvmTransactionInfoFromNetwork( input.fromAddress, ); const code = await provider.getCode(input.fromAddress); - shouldUseNonceChecks = code === '0x'; - txTrace(input, 'getEvmTransactionInfoFromNetwork:nonceChecks:getCode_ok', { - code, - shouldUseNonceChecks, - }); + isContractWallet = code !== '0x'; + shouldUseNonceChecks = !isContractWallet; + txTrace( + input, + 'getEvmTransactionInfoFromNetwork:nonceChecks:getCode_ok', + { + code, + isContractWallet, + shouldUseNonceChecks, + }, + ); } catch (e) { // If `getCode` fails, fall back to legacy behavior for safety logger.warn('getTransactionInfoFromNetwork() getCode failed', { @@ -142,6 +173,26 @@ export async function getEvmTransactionInfoFromNetwork( } } + // If nonce is suspiciously high (> 100000), it's likely an AA wallet's internal nonce + // rather than an EOA nonce. Skip the check to avoid false negatives. + const isSuspiciouslyHighNonce = nonce && nonce > 100000; + + if (isSuspiciouslyHighNonce) { + logger.info( + 'Detected suspiciously high nonce (likely AA wallet), skipping nonce validation', + { + nonce, + txHash: input.txHash, + fromAddress: input.fromAddress, + networkId, + }, + ); + txTrace(input, 'getEvmTransactionInfoFromNetwork:high_nonce_skip', { + nonce, + }); + shouldUseNonceChecks = false; + } + let userTransactionsCount: number | undefined; if (shouldUseNonceChecks) { logger.debug( @@ -159,6 +210,7 @@ export async function getEvmTransactionInfoFromNetwork( nonce, }, ); + if (typeof nonce === 'number' && userTransactionsCount <= nonce) { logger.debug('getTransactionDetail check nonce', { input, @@ -190,7 +242,7 @@ export async function getEvmTransactionInfoFromNetwork( if ( !transaction && (!nonce || - // If we skipped nonce checks (contract wallet), treat it as "nonce used" to avoid false negatives + // If we skipped nonce checks (contract wallet, high nonce, or AA tx), treat it as "nonce used" to avoid false negatives (typeof userTransactionsCount === 'number' && userTransactionsCount > nonce) || !shouldUseNonceChecks) @@ -198,22 +250,32 @@ export async function getEvmTransactionInfoFromNetwork( // in this case we understand that the transaction will not happen anytime, because nonce is used // so this is not speedup for sure const timeNow = new Date().getTime() / 1000; // in seconds - txTrace(input, 'getEvmTransactionInfoFromNetwork:notFound:nonce_used_or_skipped', { - nonce, - shouldUseNonceChecks, - userTransactionsCount, - inputTimestamp: input.timestamp, - timeNow, - ageSeconds: timeNow - input.timestamp, - }); + txTrace( + input, + 'getEvmTransactionInfoFromNetwork:notFound:nonce_used_or_skipped', + { + nonce, + shouldUseNonceChecks, + userTransactionsCount, + inputTimestamp: input.timestamp, + timeNow, + ageSeconds: timeNow - input.timestamp, + }, + ); if (input.timestamp - timeNow < ONE_HOUR) { - txTrace(input, 'getEvmTransactionInfoFromNetwork:notFound:recent_under_1h'); + txTrace( + input, + 'getEvmTransactionInfoFromNetwork:notFound:recent_under_1h', + ); throw new Error( i18n.__(translationErrorMessagesKeys.TRANSACTION_NOT_FOUND), ); } - txTrace(input, 'getEvmTransactionInfoFromNetwork:notFound:nonce_used_and_old'); + txTrace( + input, + 'getEvmTransactionInfoFromNetwork:notFound:nonce_used_and_old', + ); throw new Error( i18n.__( translationErrorMessagesKeys.TRANSACTION_NOT_FOUND_AND_NONCE_IS_USED, @@ -287,7 +349,9 @@ async function getInternalTransactionsByTxHash(params: { status: result?.data?.status, message: result?.data?.message, resultIsArray: Array.isArray(result?.data?.result), - resultLength: Array.isArray(result?.data?.result) ? result.data.result.length : undefined, + resultLength: Array.isArray(result?.data?.result) + ? result.data.result.length + : undefined, }); if (result?.data?.status === '0') { @@ -296,7 +360,10 @@ async function getInternalTransactionsByTxHash(params: { typeof result?.data?.message === 'string' && result.data.message.toLowerCase().includes('no transactions found') ) { - txTrace(params, 'getInternalTransactionsByTxHash:no_transactions_found'); + txTrace( + params, + 'getInternalTransactionsByTxHash:no_transactions_found', + ); return []; } txTrace(params, 'getInternalTransactionsByTxHash:error_status_0', { @@ -308,7 +375,9 @@ async function getInternalTransactionsByTxHash(params: { ); } - const internalTxs = Array.isArray(result?.data?.result) ? result.data.result : []; + const internalTxs = Array.isArray(result?.data?.result) + ? result.data.result + : []; txTrace(params, 'getInternalTransactionsByTxHash:success', { internalTxsCount: internalTxs.length, }); @@ -498,7 +567,8 @@ async function findEvmTransactionByNonce(data: { lastPage, userRecentTransactionsCount: userRecentTransactions.length, newestNonce: userRecentTransactions[0]?.nonce, - oldestNonce: userRecentTransactions[userRecentTransactions.length - 1]?.nonce, + oldestNonce: + userRecentTransactions[userRecentTransactions.length - 1]?.nonce, }); const foundTransaction = userRecentTransactions.find( tx => +tx.nonce === input.nonce, @@ -694,7 +764,10 @@ async function getTransactionDetailForNormalTransfer( blockNumber: receipt.blockNumber, }); if (!receipt.status) { - txTrace(input, 'getTransactionDetailForNormalTransfer:receipt_status_failed'); + txTrace( + input, + 'getTransactionDetailForNormalTransfer:receipt_status_failed', + ); throw new Error( i18n.__( translationErrorMessagesKeys.TRANSACTION_STATUS_IS_FAILED_IN_NETWORK, @@ -723,9 +796,13 @@ async function getTransactionDetailForNormalTransfer( }); if (input.safeTxHash && receipt) { - txTrace(input, 'getTransactionDetailForNormalTransfer:multisig_path_enter', { - safeTxHash: input.safeTxHash, - }); + txTrace( + input, + 'getTransactionDetailForNormalTransfer:multisig_path_enter', + { + safeTxHash: input.safeTxHash, + }, + ); const decodedLogs = abiDecoder.decodeLogs(receipt.logs); const token = await findTokenByNetworkAndSymbol(networkId, symbol); const events = decodedLogs[0].events; @@ -742,7 +819,10 @@ async function getTransactionDetailForNormalTransfer( }); if (!transactionTo || !transactionFrom) { - txTrace(input, 'getTransactionDetailForNormalTransfer:multisig_decode_missing_fields'); + txTrace( + input, + 'getTransactionDetailForNormalTransfer:multisig_decode_missing_fields', + ); throw new Error( i18n.__( translationErrorMessagesKeys.TRANSACTION_STATUS_IS_FAILED_IN_NETWORK, @@ -754,16 +834,24 @@ async function getTransactionDetailForNormalTransfer( // Account abstraction: native token transfer happens as an internal call from the smart account, // while the outer tx is to the EntryPoint and typically has `value = 0`. if (!input.safeTxHash && isErc4337EntryPointTx(transaction.to)) { - txTrace(input, 'getTransactionDetailForNormalTransfer:aa_entrypoint_detected', { - entryPoint: transaction.to, - }); + txTrace( + input, + 'getTransactionDetailForNormalTransfer:aa_entrypoint_detected', + { + entryPoint: transaction.to, + }, + ); const internalTxs = await getInternalTransactionsByTxHash({ networkId, txHash, }); - txTrace(input, 'getTransactionDetailForNormalTransfer:aa_internal_txs_fetched', { - internalTxsCount: internalTxs.length, - }); + txTrace( + input, + 'getTransactionDetailForNormalTransfer:aa_internal_txs_fetched', + { + internalTxsCount: internalTxs.length, + }, + ); const internalTransfer = extractNativeTransferFromInternalTxs({ internalTxs, expectedTo: input.toAddress, @@ -771,11 +859,15 @@ async function getTransactionDetailForNormalTransfer( expectedAmount: input.amount, }); if (!internalTransfer) { - txTrace(input, 'getTransactionDetailForNormalTransfer:aa_no_matching_internal_transfer', { - expectedTo: input.toAddress, - expectedFrom: input.fromAddress, - expectedAmount: input.amount, - }); + txTrace( + input, + 'getTransactionDetailForNormalTransfer:aa_no_matching_internal_transfer', + { + expectedTo: input.toAddress, + expectedFrom: input.fromAddress, + expectedAmount: input.amount, + }, + ); throw new Error( i18n.__( translationErrorMessagesKeys.TRANSACTION_TO_ADDRESS_IS_DIFFERENT_FROM_SENT_TO_ADDRESS, @@ -786,11 +878,15 @@ async function getTransactionDetailForNormalTransfer( transactionTo = internalTransfer.to; transactionFrom = internalTransfer.from; amount = internalTransfer.amount.toString(); - txTrace(input, 'getTransactionDetailForNormalTransfer:aa_internal_transfer_selected', { - transactionTo, - transactionFrom, - amount, - }); + txTrace( + input, + 'getTransactionDetailForNormalTransfer:aa_internal_transfer_selected', + { + transactionTo, + transactionFrom, + amount, + }, + ); } txTrace(input, 'getTransactionDetailForNormalTransfer:return', {