From 297eab9f15fbf55261c7348d5eaa52683e3d382e Mon Sep 17 00:00:00 2001 From: Antonio Ventilii Date: Wed, 9 Sep 2026 13:48:08 +0200 Subject: [PATCH] fix(frontend): reload the native balance after every mined EVM transaction A mined transaction reloaded the balance of the token it moved, and only that one. The gas, however, is paid in the network's native coin whatever was sent, so an ERC-20 transfer leaves that balance overstated too, and nothing corrected it until the next 30 second balance poll. In that window "Max" prices a native send against a balance that still holds gas the account has already spent. The amount plus the gas it reserves then exceeds what the account has, and the node refuses the transaction with "gas required exceeds allowance" instead of trimming it. --- .../eth/services/eth-transaction.services.ts | 21 ++++- .../services/eth-transaction.services.spec.ts | 81 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/tests/eth/services/eth-transaction.services.spec.ts diff --git a/src/frontend/src/eth/services/eth-transaction.services.ts b/src/frontend/src/eth/services/eth-transaction.services.ts index df16caf3cc7..491ae757ac8 100644 --- a/src/frontend/src/eth/services/eth-transaction.services.ts +++ b/src/frontend/src/eth/services/eth-transaction.services.ts @@ -1,3 +1,4 @@ +import { enabledEthEvmNativeTokens } from '$eth/derived/native-tokens.derived'; import { alchemyProviders } from '$eth/providers/alchemy.providers'; import { reloadEthereumBalance } from '$eth/services/eth-balance.services'; import { reloadEthereumTransactions } from '$eth/services/eth-transactions.services'; @@ -142,6 +143,22 @@ const processMinedTransaction = async ({ await reloadEthereumTransactions({ identity, tokenId, networkId, chainId, standard }); - // Reload balance as a transaction has been mined - await reloadEthereumBalance(token); + // Reload balance as a transaction has been mined. + // + // The gas is paid in the network's native coin whatever the transaction moved, so an ERC-20 + // transfer leaves that balance overstated as well, not only the balance of the token it sent. + // Nothing else corrects it until the next balance poll, and in that window "Max" prices a native + // send against a balance that still holds the gas already spent: the amount plus the gas it + // reserves exceeds what the account has, and the chain rejects the transaction outright rather + // than trimming it. + const nativeToken = get(enabledEthEvmNativeTokens).find( + ({ network: { id } }) => id === networkId + ); + + const tokensToReload: Token[] = [ + token, + ...(nonNullish(nativeToken) && nativeToken.id !== tokenId ? [nativeToken] : []) + ]; + + await Promise.all(tokensToReload.map((tokenToReload) => reloadEthereumBalance(tokenToReload))); }; diff --git a/src/frontend/src/tests/eth/services/eth-transaction.services.spec.ts b/src/frontend/src/tests/eth/services/eth-transaction.services.spec.ts new file mode 100644 index 00000000000..8509fbfd738 --- /dev/null +++ b/src/frontend/src/tests/eth/services/eth-transaction.services.spec.ts @@ -0,0 +1,81 @@ +import { ETHEREUM_NETWORK } from '$env/networks/networks.eth.env'; +import { ETHEREUM_TOKEN, SEPOLIA_TOKEN } from '$env/tokens/tokens.eth.env'; +import * as nativeTokensDerived from '$eth/derived/native-tokens.derived'; +import * as ethBalanceServices from '$eth/services/eth-balance.services'; +import { processErc20Transaction } from '$eth/services/eth-transaction.services'; +import * as ethTransactionsServices from '$eth/services/eth-transactions.services'; +import type { RequiredToken } from '$lib/types/token'; +import { mockValidErc20Token } from '$tests/mocks/erc20-tokens.mock'; +import { mockIdentity } from '$tests/mocks/identity.mock'; +import { readable } from 'svelte/store'; + +describe('eth-transaction.services', () => { + describe('processErc20Transaction', () => { + const hash = '0x5f97b634e4173d1b167c23386b677976a8d807b91eefe9e407b0025cff4fe441'; + + const mockNativeTokens = (tokens: RequiredToken[]) => + vi + .spyOn(nativeTokensDerived, 'enabledEthEvmNativeTokens', 'get') + .mockReturnValue(readable(tokens)); + + const processMined = (token: Parameters[0]['token']) => + processErc20Transaction({ + identity: mockIdentity, + hash, + value: 1n, + token, + type: 'mined' + }); + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(ethTransactionsServices, 'reloadEthereumTransactions').mockResolvedValue({ + success: true + }); + vi.spyOn(ethBalanceServices, 'reloadEthereumBalance').mockResolvedValue({ success: true }); + + mockNativeTokens([ETHEREUM_TOKEN as RequiredToken]); + }); + + it('reloads the native balance too, since the mined transfer paid its gas out of it', async () => { + await processMined(mockValidErc20Token); + + expect(ethBalanceServices.reloadEthereumBalance).toHaveBeenCalledTimes(2); + + expect(ethBalanceServices.reloadEthereumBalance).toHaveBeenCalledWith(mockValidErc20Token); + + expect(ethBalanceServices.reloadEthereumBalance).toHaveBeenCalledWith(ETHEREUM_TOKEN); + }); + + it('reloads the balance once when the token that was sent is the native one', async () => { + await processMined(ETHEREUM_TOKEN); + + expect(ethBalanceServices.reloadEthereumBalance).toHaveBeenCalledExactlyOnceWith( + ETHEREUM_TOKEN + ); + }); + + it('reloads only the token that was sent when no native token matches its network', async () => { + mockNativeTokens([SEPOLIA_TOKEN as RequiredToken]); + + await processMined(mockValidErc20Token); + + expect(ethBalanceServices.reloadEthereumBalance).toHaveBeenCalledExactlyOnceWith( + mockValidErc20Token + ); + }); + + it('reloads the transactions of the token that was sent', async () => { + await processMined(mockValidErc20Token); + + expect(ethTransactionsServices.reloadEthereumTransactions).toHaveBeenCalledExactlyOnceWith({ + identity: mockIdentity, + tokenId: mockValidErc20Token.id, + networkId: ETHEREUM_NETWORK.id, + chainId: ETHEREUM_NETWORK.chainId, + standard: mockValidErc20Token.standard + }); + }); + }); +});