diff --git a/src/fiatExchanges/saga.test.ts b/src/fiatExchanges/saga.test.ts index 403b4ecf3ee..93b585c5c76 100644 --- a/src/fiatExchanges/saga.test.ts +++ b/src/fiatExchanges/saga.test.ts @@ -25,6 +25,7 @@ import { } from 'src/transactions/types' import Logger from 'src/utils/Logger' import { Currency } from 'src/utils/currencies' +import { SerializableTransactionRequest } from 'src/viem/preparedTransactionSerialization' import { mockAccount, mockCeurAddress, @@ -37,6 +38,11 @@ const now = Date.now() Date.now = jest.fn(() => now) const loggerErrorSpy = jest.spyOn(Logger, 'error') +const mockPreparedTransaction: SerializableTransactionRequest = { + from: '0xfrom', + to: '0xto', + data: '0xdata', +} describe(watchBidaliPaymentRequests, () => { const amount = new BigNumber(20) @@ -97,12 +103,7 @@ describe(watchBidaliPaymentRequests, () => { 'Some description (TEST_CHARGE_ID)', recipient, true, - { - fee: new BigNumber('0.01'), - gas: new BigNumber('0.01'), - gasPrice: new BigNumber('0.01'), - feeCurrency: expectedCurrency, - } + mockPreparedTransaction ) ) .dispatch(sendPaymentSuccess({ amount, tokenId: expectedTokenId })) @@ -157,12 +158,7 @@ describe(watchBidaliPaymentRequests, () => { 'Some description (TEST_CHARGE_ID)', recipient, true, - { - fee: new BigNumber('0.01'), - gas: new BigNumber('0.01'), - gasPrice: new BigNumber('0.01'), - feeCurrency: Currency.Dollar, - } + mockPreparedTransaction ) ) .dispatch(sendPaymentFailure()) diff --git a/src/send/SendConfirmation.test.tsx b/src/send/SendConfirmation.test.tsx index 548fbc10948..417d91ef941 100644 --- a/src/send/SendConfirmation.test.tsx +++ b/src/send/SendConfirmation.test.tsx @@ -264,7 +264,6 @@ describe('SendConfirmation', () => { '', recipient, false, - undefined, getSerializablePreparedTransaction(mockPrepareTransactionsResultPossible.transactions[0]) ) ) @@ -293,7 +292,6 @@ describe('SendConfirmation', () => { trimmedComment, recipient, false, - undefined, getSerializablePreparedTransaction(mockPrepareTransactionsResultPossible.transactions[0]) ), ]) diff --git a/src/send/SendConfirmation.tsx b/src/send/SendConfirmation.tsx index fef2806f800..48361012b5a 100644 --- a/src/send/SendConfirmation.tsx +++ b/src/send/SendConfirmation.tsx @@ -233,7 +233,6 @@ function SendConfirmation(props: Props) { comment.trim(), recipient, fromModal, - undefined, getSerializablePreparedTransaction(preparedTransaction) ) ) diff --git a/src/send/actions.ts b/src/send/actions.ts index 09cebb1f647..77b6b537fc1 100644 --- a/src/send/actions.ts +++ b/src/send/actions.ts @@ -1,5 +1,4 @@ import BigNumber from 'bignumber.js' -import { FeeInfo } from 'src/fees/saga' import { Recipient } from 'src/recipients/recipient' import { QrCode } from 'src/send/types' import { Currency } from 'src/utils/currencies' @@ -47,8 +46,7 @@ export interface SendPaymentAction { comment: string recipient: Recipient fromModal: boolean - feeInfo?: FeeInfo - preparedTransaction?: SerializableTransactionRequest + preparedTransaction: SerializableTransactionRequest } export interface SendPaymentSuccessAction { @@ -121,8 +119,7 @@ export const sendPayment = ( comment: string, recipient: Recipient, fromModal: boolean, - feeInfo?: FeeInfo, - preparedTransaction?: SerializableTransactionRequest + preparedTransaction: SerializableTransactionRequest ): SendPaymentAction => ({ type: Actions.SEND_PAYMENT, amount, @@ -131,7 +128,6 @@ export const sendPayment = ( comment, recipient, fromModal, - feeInfo, preparedTransaction, }) diff --git a/src/send/saga.test.ts b/src/send/saga.test.ts index 2ec00413fbb..a17ccca08cf 100644 --- a/src/send/saga.test.ts +++ b/src/send/saga.test.ts @@ -1,37 +1,47 @@ -import { toTransactionObject } from '@celo/connect' import BigNumber from 'bignumber.js' import { expectSaga } from 'redux-saga-test-plan' import * as matchers from 'redux-saga-test-plan/matchers' -import { call, select } from 'redux-saga/effects' +import { throwError } from 'redux-saga-test-plan/providers' +import { call } from 'redux-saga/effects' import { showError } from 'src/alert/actions' +import { CeloExchangeEvents, SendEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import { ErrorMessages } from 'src/app/ErrorMessages' import { encryptComment } from 'src/identity/commentEncryption' +import { navigateBack, navigateHome } from 'src/navigator/NavigationService' import { Actions, SendPaymentAction, encryptComment as encryptCommentAction, encryptCommentComplete, + sendPaymentFailure, + sendPaymentSuccess, } from 'src/send/actions' import { encryptCommentSaga, sendPaymentSaga } from 'src/send/saga' -import { getERC20TokenContract, getStableTokenContract } from 'src/tokens/saga' -import { sendAndMonitorTransaction } from 'src/transactions/saga' -import { sendPayment as viemSendPayment } from 'src/viem/saga' +import { + Actions as TransactionActions, + addStandbyTransaction, + transactionConfirmed, +} from 'src/transactions/actions' +import { NetworkId, TokenTransactionTypeV2, TransactionStatus } from 'src/transactions/types' +import { publicClient } from 'src/viem' +import { ViemWallet } from 'src/viem/getLockableWallet' +import { getViemWallet } from 'src/web3/contracts' +import networkConfig from 'src/web3/networkConfig' import { UnlockResult, getConnectedAccount, getConnectedUnlockedAccount, unlockAccount, } from 'src/web3/saga' -import { currentAccountSelector } from 'src/web3/selectors' import { createMockStore } from 'test/utils' import { mockAccount, mockAccount2, - mockContract, + mockCeloAddress, + mockCeloTokenId, mockCusdAddress, mockCusdTokenId, - mockFeeInfo, mockQRCodeRecipient, } from 'test/values' @@ -78,57 +88,180 @@ describe(sendPaymentSaga, () => { comment: '', recipient: mockQRCodeRecipient, fromModal: false, - feeInfo: mockFeeInfo, - } - - const sendActionWithPreparedTx: SendPaymentAction = { - ...sendAction, preparedTransaction: { from: '0xfrom', to: '0xto', data: '0xdata', }, } - - beforeAll(() => { - ;(toTransactionObject as jest.Mock).mockImplementation(() => jest.fn()) - }) + const mockViemWallet = { + account: { address: mockAccount }, + writeContract: jest.fn(), + sendTransaction: jest.fn(), + } as any as ViemWallet + const mockTxHash: `0x${string}` = '0x12345678901234' + const mockTxReceipt = { + status: 'success', + transactionHash: mockTxHash, + blockNumber: 123, + gasUsed: BigInt(1e6), + effectiveGasPrice: BigInt(1e9), + } beforeEach(() => { jest.clearAllMocks() }) - it('sends a payment successfully with viem', async () => { - await expectSaga(sendPaymentSaga, sendActionWithPreparedTx) + it.each([ + { + testSuffix: 'navigates home when not initiated from modal', + fromModal: false, + navigateFn: navigateHome, + }, + { + testSuffix: 'navigates back when initiated from modal', + fromModal: true, + navigateFn: navigateBack, + }, + ])( + 'sends a payment successfully with viem and $testSuffix', + async ({ fromModal, navigateFn }) => { + await expectSaga(sendPaymentSaga, { ...sendAction, fromModal }) + .withState(createMockStore({}).getState()) + .provide([ + [call(getConnectedUnlockedAccount), mockAccount], + [matchers.call.fn(getViemWallet), mockViemWallet], + [matchers.call.fn(mockViemWallet.sendTransaction), mockTxHash], + [matchers.call.fn(publicClient.celo.waitForTransactionReceipt), mockTxReceipt], + [matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }], + ]) + .call(getViemWallet, networkConfig.viemChain.celo) + .put( + addStandbyTransaction({ + __typename: 'TokenTransferV3', + context: { id: 'mock' }, + type: TokenTransactionTypeV2.Sent, + networkId: NetworkId['celo-alfajores'], + amount: { + value: BigNumber(10).negated().toString(), + tokenAddress: mockCusdAddress, + tokenId: mockCusdTokenId, + }, + address: mockQRCodeRecipient.address, + metadata: { + comment: '', + }, + feeCurrencyId: mockCeloTokenId, + transactionHash: mockTxHash, + }) + ) + .put( + transactionConfirmed( + 'mock', + { + transactionHash: mockTxHash, + block: '123', + status: TransactionStatus.Complete, + fees: [ + { + type: 'SECURITY_FEE', + amount: { + value: '0.001', + tokenId: mockCeloTokenId, + }, + }, + ], + }, + 1701102971000 + ) + ) + .put(sendPaymentSuccess({ amount, tokenId: mockCusdTokenId })) + .run() + + expect(navigateFn).toHaveBeenCalledTimes(1) + expect(ValoraAnalytics.track).toHaveBeenCalledTimes(2) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.send_tx_start) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.send_tx_complete, { + txId: mockContext.id, + recipientAddress: mockQRCodeRecipient.address, + amount: '10', + usdAmount: '10', + tokenAddress: mockCusdAddress, + tokenId: mockCusdTokenId, + networkId: 'celo-alfajores', + isTokenManuallyImported: false, + }) + } + ) + + it('sends a payment successfully for celo and logs a withdrawal event', async () => { + await expectSaga(sendPaymentSaga, { ...sendAction, tokenId: mockCeloTokenId }) .withState(createMockStore({}).getState()) .provide([ [call(getConnectedUnlockedAccount), mockAccount], - [matchers.call.fn(viemSendPayment), undefined], + [matchers.call.fn(getViemWallet), mockViemWallet], + [matchers.call.fn(mockViemWallet.sendTransaction), mockTxHash], + [matchers.call.fn(publicClient.celo.waitForTransactionReceipt), mockTxReceipt], + [matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }], ]) - .call(viemSendPayment, { - context: { id: 'mock' }, - recipientAddress: sendActionWithPreparedTx.recipient.address, - amount: sendActionWithPreparedTx.amount, - tokenId: sendActionWithPreparedTx.tokenId, - comment: sendActionWithPreparedTx.comment, - feeInfo: sendActionWithPreparedTx.feeInfo, - preparedTransaction: sendActionWithPreparedTx.preparedTransaction, - }) - .not.call.fn(sendAndMonitorTransaction) + .call(getViemWallet, networkConfig.viemChain.celo) + .put( + addStandbyTransaction({ + __typename: 'TokenTransferV3', + context: { id: 'mock' }, + type: TokenTransactionTypeV2.Sent, + networkId: NetworkId['celo-alfajores'], + amount: { + value: BigNumber(10).negated().toString(), + tokenAddress: mockCeloAddress, + tokenId: mockCeloTokenId, + }, + address: mockQRCodeRecipient.address, + metadata: { + comment: '', + }, + feeCurrencyId: mockCeloTokenId, + transactionHash: mockTxHash, + }) + ) + .put( + transactionConfirmed( + 'mock', + { + transactionHash: mockTxHash, + block: '123', + status: TransactionStatus.Complete, + fees: [ + { + type: 'SECURITY_FEE', + amount: { + value: '0.001', + tokenId: mockCeloTokenId, + }, + }, + ], + }, + 1701102971000 + ) + ) + .put(sendPaymentSuccess({ amount, tokenId: mockCeloTokenId })) .run() - expect(mockContract.methods.transferWithComment).not.toHaveBeenCalled() - expect(mockContract.methods.transfer).not.toHaveBeenCalled() - expect(ValoraAnalytics.track).toHaveBeenCalledWith('send_tx_complete', { + expect(ValoraAnalytics.track).toHaveBeenCalledTimes(3) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.send_tx_start) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.send_tx_complete, { txId: mockContext.id, recipientAddress: mockQRCodeRecipient.address, amount: '10', usdAmount: '10', - tokenAddress: '0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1'.toLowerCase(), - tokenId: mockCusdTokenId, + tokenAddress: mockCeloAddress, + tokenId: mockCeloTokenId, networkId: 'celo-alfajores', isTokenManuallyImported: false, }) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(CeloExchangeEvents.celo_withdraw_completed, { + amount: '10', + }) }) it('fails if user cancels PIN input', async () => { @@ -139,20 +272,31 @@ describe(sendPaymentSaga, () => { [matchers.call.fn(unlockAccount), UnlockResult.CANCELED], ]) .put(showError(ErrorMessages.PIN_INPUT_CANCELED)) + .put(sendPaymentFailure()) .run() + // 2 calls from showError, one with the error handler and one with the + // assertion above, there shouldn't be any other calls + expect(ValoraAnalytics.track).toHaveBeenCalledTimes(2) }) - it('uploads symmetric keys if transaction sent successfully', async () => { - const account = '0x000123' + it('fails if sendTransaction throws', async () => { await expectSaga(sendPaymentSaga, sendAction) + .withState(createMockStore({}).getState()) .provide([ [call(getConnectedUnlockedAccount), mockAccount], - [select(currentAccountSelector), account], - [call(encryptComment, 'asdf', 'asdf', 'asdf', true), 'Asdf'], - [call(getERC20TokenContract, mockCusdAddress), mockContract], - [call(getStableTokenContract, mockCusdAddress), mockContract], + [matchers.call.fn(getViemWallet), mockViemWallet], + [matchers.call.fn(mockViemWallet.sendTransaction), throwError(new Error('tx failed'))], ]) + .call(getViemWallet, networkConfig.viemChain.celo) + .put(sendPaymentFailure()) + .put(showError(ErrorMessages.SEND_PAYMENT_FAILED)) + .not.put.actionType(TransactionActions.ADD_STANDBY_TRANSACTION) + .not.put.actionType(TransactionActions.TRANSACTION_CONFIRMED) .run() + expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.send_tx_start) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.send_tx_error, { + error: 'tx failed', + }) }) }) diff --git a/src/send/saga.ts b/src/send/saga.ts index 570053a4896..c9cac49bd7b 100644 --- a/src/send/saga.ts +++ b/src/send/saga.ts @@ -27,10 +27,11 @@ import { getTokenInfoByAddress, tokenAmountInSmallestUnit, } from 'src/tokens/saga' -import { TokenBalance } from 'src/tokens/slice' +import { tokensByIdSelector } from 'src/tokens/selectors' +import { TokenBalance, fetchTokenBalances } from 'src/tokens/slice' import { getTokenId } from 'src/tokens/utils' import { addStandbyTransaction } from 'src/transactions/actions' -import { sendAndMonitorTransaction } from 'src/transactions/saga' +import { handleTransactionReceiptReceived, sendAndMonitorTransaction } from 'src/transactions/saga' import { TokenTransactionTypeV2, TransactionContext, @@ -39,13 +40,16 @@ import { import Logger from 'src/utils/Logger' import { ensureError } from 'src/utils/ensureError' import { safely } from 'src/utils/safely' -import { SerializableTransactionRequest } from 'src/viem/preparedTransactionSerialization' -import { sendPayment as viemSendPayment } from 'src/viem/saga' -import { getContractKit } from 'src/web3/contracts' +import { publicClient } from 'src/viem' +import { getFeeCurrencyToken } from 'src/viem/prepareTransactions' +import { getPreparedTransaction } from 'src/viem/preparedTransactionSerialization' +import { getContractKit, getViemWallet } from 'src/web3/contracts' import networkConfig from 'src/web3/networkConfig' import { getConnectedUnlockedAccount } from 'src/web3/saga' -import { call, put, spawn, take, takeEvery, takeLeading } from 'typed-redux-saga' +import { getNetworkFromNetworkId } from 'src/web3/utils' +import { call, put, select, spawn, take, takeEvery, takeLeading } from 'typed-redux-saga' import * as utf8 from 'utf8' +import { TransactionReceipt } from 'viem' const TAG = 'send/saga' @@ -163,44 +167,99 @@ export function* buildAndSendPayment( return { receipt, error } } -/** - * Sends a payment to an address with an encrypted comment and gives profile - * access to the recipient - * - * @param recipientAddress the address to send the payment to - * @param amount the crypto amount to send - * @param usdAmount the amount in usd (nullable, used only for analytics) - * @param tokenId the id of the token being sent - * @param comment the comment on the transaction - * @param feeInfo an object containing the fee information - * @param preparedTransaction a serialized viem tx request - */ -function* sendPayment( - recipientAddress: string, - amount: BigNumber, - usdAmount: BigNumber | null, - tokenId: string, - comment: string, - feeInfo?: FeeInfo, - preparedTransaction?: SerializableTransactionRequest -) { - const context = newTransactionContext(TAG, 'Send payment') - const tokenInfo = yield* call(getTokenInfo, tokenId) - if (!tokenInfo) { - throw new Error('token info not found') - } - +export function* sendPaymentSaga({ + amount, + tokenId, + usdAmount, + comment, + recipient, + fromModal, + preparedTransaction: serializablePreparedTransaction, +}: SendPaymentAction) { try { + yield* call(getConnectedUnlockedAccount) + SentryTransactionHub.startTransaction(SentryTransaction.send_payment) + const context = newTransactionContext(TAG, 'Send payment') + const recipientAddress = recipient.address + if (!recipientAddress) { + // should never happen. TODO(ACT-1046): ensure recipient type here + // includes address + throw new Error('No address found on recipient') + } + + const tokenInfo = yield* call(getTokenInfo, tokenId) + const network = getNetworkFromNetworkId(tokenInfo?.networkId) + if (!tokenInfo || !network) { + throw new Error('Unknown token network') + } + const networkId = tokenInfo.networkId + + const wallet = yield* call(getViemWallet, networkConfig.viemChain[network]) + + if (!wallet.account) { + // this should never happen + throw new Error('no account found in the wallet') + } + ValoraAnalytics.track(SendEvents.send_tx_start) - yield* call(viemSendPayment, { - context, - recipientAddress, - amount, + + const preparedTransaction = getPreparedTransaction(serializablePreparedTransaction) + const tokensById = yield* select((state) => tokensByIdSelector(state, [networkId])) + const feeCurrencyId = getFeeCurrencyToken([preparedTransaction], networkId, tokensById)?.tokenId + + Logger.debug( + `${TAG}/sendPaymentSaga`, + 'Executing send transaction', + context.description ?? 'No description', + context.id, tokenId, - comment, - feeInfo, - preparedTransaction, - }) + amount + ) + + const hash = yield* call([wallet, 'sendTransaction'], preparedTransaction as any) + + Logger.debug(`${TAG}/sendPaymentSaga`, 'Successfully sent transaction to the network', hash) + + yield* put( + addStandbyTransaction({ + __typename: 'TokenTransferV3', + type: TokenTransactionTypeV2.Sent, + context, + networkId, + amount: { + value: amount.negated().toString(), + tokenAddress: tokenInfo.address ?? undefined, + tokenId, + }, + address: recipientAddress, + metadata: { + comment, + }, + transactionHash: hash, + feeCurrencyId, + }) + ) + + const receipt: TransactionReceipt = yield* call( + [publicClient[network], 'waitForTransactionReceipt'], + { hash } + ) + + Logger.debug(`${TAG}/sendPaymentSaga`, 'Got send transaction receipt', receipt) + + yield* call( + handleTransactionReceiptReceived, + context.id, + receipt, + networkConfig.networkToNetworkId[network], + feeCurrencyId + ) + + if (receipt.status === 'reverted') { + throw new Error(`Send transaction reverted: ${hash}`) + } + + yield* put(fetchTokenBalances({ showLoading: true })) ValoraAnalytics.track(SendEvents.send_tx_complete, { txId: context.id, @@ -212,48 +271,11 @@ function* sendPayment( networkId: tokenInfo.networkId, isTokenManuallyImported: !!tokenInfo?.isManuallyImported, }) - } catch (err) { - const error = ensureError(err) - Logger.error(`${TAG}/sendPayment`, 'Could not make token transfer', error.message) - ValoraAnalytics.track(SendEvents.send_tx_error, { error: error.message }) - yield* put(showErrorOrFallback(error, ErrorMessages.TRANSACTION_FAILED)) - // TODO: Uncomment this when the transaction feed supports multiple tokens. - // yield put(removeStandbyTransaction(context.id)) - } -} -export function* sendPaymentSaga({ - amount, - tokenId, - usdAmount, - comment, - recipient, - fromModal, - feeInfo, - preparedTransaction, -}: SendPaymentAction) { - try { - yield* call(getConnectedUnlockedAccount) - SentryTransactionHub.startTransaction(SentryTransaction.send_payment) - const tokenInfo: TokenBalance | undefined = yield* call(getTokenInfo, tokenId) - if (recipient.address) { - yield* call( - sendPayment, - recipient.address, - amount, - usdAmount, - tokenId, - comment, - feeInfo, - preparedTransaction - ) - if (tokenInfo?.symbol === 'CELO') { - ValoraAnalytics.track(CeloExchangeEvents.celo_withdraw_completed, { - amount: amount.toString(), - }) - } - } else { - throw new Error('No address found on recipient') + if (tokenInfo?.symbol === 'CELO') { + ValoraAnalytics.track(CeloExchangeEvents.celo_withdraw_completed, { + amount: amount.toString(), + }) } if (fromModal) { @@ -264,9 +286,20 @@ export function* sendPaymentSaga({ yield* put(sendPaymentSuccess({ amount, tokenId })) SentryTransactionHub.finishTransaction(SentryTransaction.send_payment) - } catch (e) { - yield* put(showErrorOrFallback(e, ErrorMessages.SEND_PAYMENT_FAILED)) - yield* put(sendPaymentFailure()) + } catch (err) { + // for pin cancelled, this will show the pin input canceled message, for any + // other error, will fallback to payment failed + yield* put(showErrorOrFallback(err, ErrorMessages.SEND_PAYMENT_FAILED)) + yield* put(sendPaymentFailure()) // resets isSending state + const error = ensureError(err) + if (error.message === ErrorMessages.PIN_INPUT_CANCELED) { + Logger.info(`${TAG}/sendPaymentSaga`, 'Send cancelled by user') + return + } + Logger.error(`${TAG}/sendPaymentSaga`, 'Send payment failed', error) + ValoraAnalytics.track(SendEvents.send_tx_error, { error: error.message }) + } finally { + SentryTransactionHub.finishTransaction(SentryTransaction.send_payment) } } diff --git a/src/viem/saga.test.ts b/src/viem/saga.test.ts deleted file mode 100644 index 835945c1d29..00000000000 --- a/src/viem/saga.test.ts +++ /dev/null @@ -1,901 +0,0 @@ -import { CeloTransactionObject } from '@celo/connect' -import BigNumber from 'bignumber.js' -import { expectSaga } from 'redux-saga-test-plan' -import * as matchers from 'redux-saga-test-plan/matchers' -import { throwError } from 'redux-saga-test-plan/providers' -import erc20 from 'src/abis/IERC20' -import stableToken from 'src/abis/StableToken' -import { encryptComment } from 'src/identity/commentEncryption' -import { buildSendTx } from 'src/send/saga' -import { fetchTokenBalances } from 'src/tokens/slice' -import { Actions, addStandbyTransaction, transactionConfirmed } from 'src/transactions/actions' -import { chooseTxFeeDetails } from 'src/transactions/send' -import { publicClient } from 'src/viem' -import { ViemWallet } from 'src/viem/getLockableWallet' -import { getSendTxFeeDetails, sendAndMonitorTransaction, sendPayment } from 'src/viem/saga' -import { getViemWallet } from 'src/web3/contracts' -import networkConfig from 'src/web3/networkConfig' - -import { showError } from 'src/alert/actions' -import { ErrorMessages } from 'src/app/ErrorMessages' -import { - Network, - NetworkId, - PendingStandbyTransfer, - TokenTransactionTypeV2, - TransactionStatus, -} from 'src/transactions/types' -import { SerializableTransactionRequest } from 'src/viem/preparedTransactionSerialization' -import { UnlockResult, unlockAccount } from 'src/web3/saga' -import { createMockStore } from 'test/utils' -import { - mockAccount, - mockAccount2, - mockCeloAddress, - mockCeloTokenId, - mockCusdAddress, - mockCusdTokenId, - mockEthTokenId, - mockFeeInfo, - mockTokenBalances, - mockUSDCAddress, - mockUSDCTokenId, -} from 'test/values' -import { Address, getAddress } from 'viem' - -jest.mock('src/transactions/send', () => ({ - chooseTxFeeDetails: jest.fn(), - wrapSendTransactionWithRetry: jest - .fn() - .mockImplementation((sendTxMethod, _context) => sendTxMethod()), -})) - -const mockViemFeeInfo = { - feeCurrency: getAddress(mockCusdAddress), - gas: BigInt(mockFeeInfo.gas.toNumber()), - maxFeePerGas: BigInt(mockFeeInfo.gasPrice.toNumber()), -} - -const mockViemWallet = { - account: { address: mockAccount2 }, - writeContract: jest.fn(), - sendTransaction: jest.fn(), -} as any as ViemWallet - -const storeStateWithTokens = createMockStore({ - tokens: { - tokenBalances: mockTokenBalances, - }, -}) - -describe('sendPayment', () => { - const mockTxHash: `0x${string}` = '0x12345678901234' - const mockTxReceipt = { - status: 'success', - transactionHash: mockTxHash, - blockNumber: 123, - gasUsed: BigInt(1e6), - effectiveGasPrice: BigInt(1e9), - } - - const simulateContractCeloSpy = jest.spyOn(publicClient.celo, 'simulateContract') - // We need to mock this outright for Ethereum, since for some reason, the viem simulation on Ethereum - // complains that "transfer" does not exist on the contract, when it actually does - const mockSimulateContractEthereum = jest - .spyOn(publicClient.ethereum, 'simulateContract') - // @ts-ignore - .mockResolvedValue('some request') - const callSpy = jest.spyOn(publicClient.ethereum, 'call') - - const mockSendPaymentArgs = { - context: { id: 'txId' }, - recipientAddress: mockAccount2, - amount: BigNumber(2), - tokenId: mockCusdTokenId, - comment: 'comment', - feeInfo: mockFeeInfo, - } - - const expectedStandbyTransaction: Omit = { - __typename: 'TokenTransferV3', - context: { id: 'txId' }, - type: TokenTransactionTypeV2.Sent, - networkId: NetworkId['celo-alfajores'], - amount: { - value: BigNumber(2).negated().toString(), - tokenAddress: mockCusdAddress, - tokenId: mockCusdTokenId, - }, - address: mockAccount2, - metadata: { - comment: 'comment', - }, - feeCurrencyId: mockCeloTokenId, - } - - const mockSendEthPaymentArgs = { - context: { id: 'txId' }, - recipientAddress: mockAccount2, - amount: BigNumber(2), - tokenId: mockEthTokenId, - comment: '', - } - const mockEthTokenBalance = { - name: 'Ethereum', - networkId: NetworkId['ethereum-sepolia'], - tokenId: mockEthTokenId, - symbol: 'ETH', - decimals: 18, - imageUrl: '', - balance: '10', - priceUsd: '1', - isNative: true, - } - const mockEthPreparedTransaction: SerializableTransactionRequest = { - type: 'eip1559', - from: '0xfrom', - to: '0xto', - data: '0xdata', - gas: '2000', - maxFeePerGas: '1000000', - } - const mockCip42PreparedTransaction: SerializableTransactionRequest = { - type: 'cip42', - from: '0xfrom', - to: '0xto', - data: '0xdata', - gas: '2000', - maxFeePerGas: '1000000', - feeCurrency: mockCusdAddress as Address, - } - - beforeEach(() => { - jest.clearAllMocks() - simulateContractCeloSpy.mockResolvedValue({ request: 'req' as any } as any) - }) - - it('sends a payment successfully for stable token', async () => { - await expectSaga(sendPayment, mockSendPaymentArgs) - .withState(storeStateWithTokens.getState()) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(encryptComment), 'encryptedComment'], - [matchers.call.fn(getSendTxFeeDetails), mockViemFeeInfo], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(mockViemWallet.writeContract), mockTxHash], - [matchers.call.fn(publicClient.celo.waitForTransactionReceipt), mockTxReceipt], - [matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }], - ]) - .call(getViemWallet, networkConfig.viemChain.celo) - .call(encryptComment, 'comment', mockSendPaymentArgs.recipientAddress, mockAccount2, true) - .call(getSendTxFeeDetails, { - recipientAddress: mockSendPaymentArgs.recipientAddress, - amount: BigNumber(2), - tokenAddress: mockCusdAddress, - feeInfo: mockFeeInfo, - encryptedComment: 'encryptedComment', - }) - .put( - addStandbyTransaction({ - ...expectedStandbyTransaction, - transactionHash: mockTxHash, - }) - ) - .put( - transactionConfirmed( - 'txId', - { - transactionHash: mockTxHash, - block: '123', - status: TransactionStatus.Complete, - fees: [ - { - type: 'SECURITY_FEE', - amount: { - value: '0.001', - tokenId: mockCeloTokenId, - }, - }, - ], - }, - 1701102971000 - ) - ) - .returns(mockTxReceipt) - .run() - - expect(simulateContractCeloSpy).toHaveBeenCalledWith({ - address: getAddress(mockCusdAddress), - abi: stableToken.abi, - functionName: 'transferWithComment', - account: mockViemWallet.account, - args: [getAddress(mockSendPaymentArgs.recipientAddress), BigInt(2e18), 'encryptedComment'], - ...mockViemFeeInfo, - }) - }) - - it('sends a payment successfully for stable token with prepared transaction', async () => { - await expectSaga(sendPayment, { - ...mockSendPaymentArgs, - preparedTransaction: mockCip42PreparedTransaction, - }) - .withState(createMockStore().getState()) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(encryptComment), 'encryptedComment'], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(mockViemWallet.writeContract), mockTxHash], - [matchers.call.fn(publicClient.celo.waitForTransactionReceipt), mockTxReceipt], - [matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }], - ]) - .call(getViemWallet, networkConfig.viemChain.celo) - .call(encryptComment, 'comment', mockSendPaymentArgs.recipientAddress, mockAccount2, true) - .not.call.fn(getSendTxFeeDetails) - .put( - addStandbyTransaction({ - ...expectedStandbyTransaction, - transactionHash: mockTxHash, - feeCurrencyId: mockCusdTokenId, - }) - ) - .returns(mockTxReceipt) - .run() - - expect(simulateContractCeloSpy).toHaveBeenCalledWith({ - address: getAddress(mockCusdAddress), - abi: stableToken.abi, - functionName: 'transferWithComment', - account: mockViemWallet.account, - args: [getAddress(mockSendPaymentArgs.recipientAddress), BigInt(2e18), 'encryptedComment'], - gas: BigInt(2000), - maxFeePerGas: BigInt(1000000), - feeCurrency: mockCusdAddress, - }) - }) - - it('sends a payment successfully for non stable token', async () => { - await expectSaga(sendPayment, { ...mockSendPaymentArgs, tokenId: mockCeloTokenId }) - .withState(storeStateWithTokens.getState()) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(getSendTxFeeDetails), mockViemFeeInfo], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(mockViemWallet.writeContract), mockTxHash], - [matchers.call.fn(publicClient.celo.waitForTransactionReceipt), mockTxReceipt], - [matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }], - ]) - .call(getViemWallet, networkConfig.viemChain.celo) - .not.call.fn(encryptComment) - .call(getSendTxFeeDetails, { - recipientAddress: mockSendPaymentArgs.recipientAddress, - amount: BigNumber(2), - tokenAddress: mockCeloAddress, - feeInfo: mockFeeInfo, - encryptedComment: '', - }) - .put( - addStandbyTransaction({ - ...expectedStandbyTransaction, - amount: { - value: BigNumber(2).negated().toString(), - tokenAddress: mockCeloAddress, - tokenId: mockCeloTokenId, - }, - transactionHash: mockTxHash, - }) - ) - .put( - transactionConfirmed( - 'txId', - { - transactionHash: mockTxHash, - block: '123', - status: TransactionStatus.Complete, - fees: [ - { - type: 'SECURITY_FEE', - amount: { - value: '0.001', - tokenId: mockCeloTokenId, - }, - }, - ], - }, - 1701102971000 - ) - ) - .returns(mockTxReceipt) - .run() - - expect(simulateContractCeloSpy).toHaveBeenCalledWith({ - address: getAddress(mockCeloAddress), - abi: erc20.abi, - functionName: 'transfer', - account: mockViemWallet.account, - args: [getAddress(mockSendPaymentArgs.recipientAddress), BigInt(2e18)], - ...mockViemFeeInfo, - }) - }) - - it('sends a payment successfully for non stable token with prepared transaction', async () => { - await expectSaga(sendPayment, { - ...mockSendPaymentArgs, - tokenId: mockCeloTokenId, - preparedTransaction: mockCip42PreparedTransaction, - }) - .withState(createMockStore().getState()) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(mockViemWallet.writeContract), mockTxHash], - [matchers.call.fn(publicClient.celo.waitForTransactionReceipt), mockTxReceipt], - [matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }], - ]) - .call(getViemWallet, networkConfig.viemChain.celo) - .not.call.fn(encryptComment) - .not.call.fn(getSendTxFeeDetails) - .put( - addStandbyTransaction({ - ...expectedStandbyTransaction, - amount: { - value: BigNumber(2).negated().toString(), - tokenAddress: mockCeloAddress, - tokenId: mockCeloTokenId, - }, - transactionHash: mockTxHash, - feeCurrencyId: mockCusdTokenId, - }) - ) - .put( - transactionConfirmed( - 'txId', - { - transactionHash: mockTxHash, - block: '123', - status: TransactionStatus.Complete, - fees: [ - { - type: 'SECURITY_FEE', - amount: { - value: '0.001', - tokenId: mockCusdTokenId, - }, - }, - ], - }, - 1701102971000 - ) - ) - .returns(mockTxReceipt) - .run() - - expect(simulateContractCeloSpy).toHaveBeenCalledWith({ - address: getAddress(mockCeloAddress), - abi: erc20.abi, - functionName: 'transfer', - account: mockViemWallet.account, - args: [getAddress(mockSendPaymentArgs.recipientAddress), BigInt(2e18)], - gas: BigInt(2000), - maxFeePerGas: BigInt(1000000), - feeCurrency: mockCusdAddress, - }) - }) - - it('throws if simulateContract fails', async () => { - simulateContractCeloSpy.mockRejectedValue(new Error('simulate error')) - - await expectSaga(sendPayment, { ...mockSendPaymentArgs, tokenId: mockCeloTokenId }) - .withState(createMockStore().getState()) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(getSendTxFeeDetails), mockViemFeeInfo], - ]) - .not.put.like({ action: { type: Actions.ADD_STANDBY_TRANSACTION } }) - .not.call.fn(unlockAccount) - .not.call.fn(sendAndMonitorTransaction) - .throws(new Error('simulate error')) - .run() - }) - - it('throws if sendAndMonitorTransaction fails', async () => { - await expectSaga(sendPayment, { ...mockSendPaymentArgs, tokenId: mockCeloTokenId }) - .withState(createMockStore().getState()) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(getSendTxFeeDetails), mockViemFeeInfo], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(sendAndMonitorTransaction), throwError(new Error('tx failed'))], - ]) - .not.put.like({ action: { type: Actions.ADD_STANDBY_TRANSACTION } }) - .throws(new Error('tx failed')) - .run() - }) - - it('throws if writeContract fails', async () => { - await expectSaga(sendPayment, { ...mockSendPaymentArgs, tokenId: mockCeloTokenId }) - .withState(createMockStore().getState()) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(getSendTxFeeDetails), mockViemFeeInfo], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(mockViemWallet.writeContract), throwError(new Error('tx failed'))], - ]) - .not.put.like({ action: { type: Actions.ADD_STANDBY_TRANSACTION } }) - .not.call.fn(publicClient.celo.waitForTransactionReceipt) - .throws(new Error('tx failed')) - .run() - }) - - it('throws if sendTransaction fails', async () => { - await expectSaga(sendPayment, mockSendEthPaymentArgs) - .withState( - createMockStore({ - tokens: { - tokenBalances: { - [mockEthTokenId]: mockEthTokenBalance, - }, - }, - }).getState() - ) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(getSendTxFeeDetails), mockViemFeeInfo], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(mockViemWallet.sendTransaction), throwError(new Error('tx failed'))], - ]) - .not.put.like({ action: { type: Actions.ADD_STANDBY_TRANSACTION } }) - .not.call.fn(publicClient.celo.waitForTransactionReceipt) - .throws(new Error('tx failed')) - .run() - }) - - it('sends a payment successfully for a non-Celo native asset', async () => { - await expectSaga(sendPayment, mockSendEthPaymentArgs) - .withState( - createMockStore({ - tokens: { - tokenBalances: { - [mockEthTokenId]: mockEthTokenBalance, - }, - }, - }).getState() - ) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(mockViemWallet.sendTransaction), mockTxHash], - [matchers.call.fn(publicClient.ethereum.waitForTransactionReceipt), mockTxReceipt], - [matchers.call.fn(publicClient.ethereum.getBlock), { timestamp: 1701102971 }], - ]) - .call(getViemWallet, networkConfig.viemChain.ethereum) - .put( - addStandbyTransaction({ - ...expectedStandbyTransaction, - networkId: NetworkId['ethereum-sepolia'], - amount: { - value: BigNumber(2).negated().toString(), - tokenAddress: undefined, - tokenId: mockEthTokenId, - }, - metadata: { - comment: '', - }, - transactionHash: mockTxHash, - feeCurrencyId: mockEthTokenId, - }) - ) - .put( - transactionConfirmed( - 'txId', - { - transactionHash: mockTxHash, - block: '123', - status: TransactionStatus.Complete, - fees: [ - { - type: 'SECURITY_FEE', - amount: { - value: '0.001', - tokenId: mockEthTokenId, - }, - }, - ], - }, - 1701102971000 - ) - ) - .returns(mockTxReceipt) - .run() - - expect(callSpy).toHaveBeenCalledWith({ - account: mockViemWallet.account, - to: getAddress(mockSendPaymentArgs.recipientAddress), - value: BigInt(2e18), - }) - }) - - it('sends a payment successfully for a non-Celo native asset with prepared transaction', async () => { - await expectSaga(sendPayment, { - ...mockSendEthPaymentArgs, - preparedTransaction: mockEthPreparedTransaction, - }) - .withState( - createMockStore({ - tokens: { - tokenBalances: { - [mockEthTokenId]: mockEthTokenBalance, - }, - }, - }).getState() - ) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(mockViemWallet.sendTransaction), mockTxHash], - [matchers.call.fn(publicClient.ethereum.waitForTransactionReceipt), mockTxReceipt], - [matchers.call.fn(publicClient.ethereum.getBlock), { timestamp: 1701102971 }], - ]) - .call(getViemWallet, networkConfig.viemChain.ethereum) - .put( - addStandbyTransaction({ - ...expectedStandbyTransaction, - networkId: NetworkId['ethereum-sepolia'], - amount: { - value: BigNumber(2).negated().toString(), - tokenAddress: undefined, - tokenId: mockEthTokenId, - }, - metadata: { - comment: '', - }, - transactionHash: mockTxHash, - feeCurrencyId: mockEthTokenId, - }) - ) - .returns(mockTxReceipt) - .run() - - expect(callSpy).toHaveBeenCalledWith({ - account: mockViemWallet.account, - to: getAddress(mockSendPaymentArgs.recipientAddress), - value: BigInt(2e18), - gas: BigInt(2000), - maxFeePerGas: BigInt(1000000), - }) - }) - - it('sends a payment successfully for a non-Celo ERC20', async () => { - const mockSendUSDCPaymentArgs = { - context: { id: 'txId' }, - recipientAddress: mockAccount2, - amount: BigNumber(2), - tokenId: mockUSDCTokenId, - comment: '', - } - await expectSaga(sendPayment, mockSendUSDCPaymentArgs) - .withState(storeStateWithTokens.getState()) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(mockViemWallet.writeContract), mockTxHash], - [matchers.call.fn(publicClient.ethereum.waitForTransactionReceipt), mockTxReceipt], - [matchers.call.fn(publicClient.ethereum.getBlock), { timestamp: 1701102971 }], - ]) - .not.call.fn(encryptComment) - .call(getViemWallet, networkConfig.viemChain.ethereum) - .put( - addStandbyTransaction({ - ...expectedStandbyTransaction, - networkId: NetworkId['ethereum-sepolia'], - amount: { - value: BigNumber(2).negated().toString(), - tokenAddress: mockUSDCAddress, - tokenId: mockUSDCTokenId, - }, - metadata: { - comment: '', - }, - transactionHash: mockTxHash, - feeCurrencyId: mockEthTokenId, - }) - ) - .put( - transactionConfirmed( - 'txId', - { - transactionHash: mockTxHash, - block: '123', - status: TransactionStatus.Complete, - fees: [ - { - type: 'SECURITY_FEE', - amount: { - value: '0.001', - tokenId: mockEthTokenId, - }, - }, - ], - }, - 1701102971000 - ) - ) - .returns(mockTxReceipt) - .run() - - expect(mockSimulateContractEthereum).toHaveBeenCalledWith({ - address: getAddress(mockUSDCAddress), - abi: erc20.abi, - functionName: 'transfer', - account: mockViemWallet.account, - args: [getAddress(mockSendPaymentArgs.recipientAddress), BigInt(2e18)], - gas: undefined, - maxFeePerGad: undefined, - }) - }) - - it('sends a payment successfully for a non-Celo ERC20 with prepared transaction', async () => { - const mockSendUSDCPaymentArgs = { - context: { id: 'txId' }, - recipientAddress: mockAccount2, - amount: BigNumber(2), - tokenId: mockUSDCTokenId, - comment: '', - preparedTransaction: mockEthPreparedTransaction, - } - await expectSaga(sendPayment, mockSendUSDCPaymentArgs) - .withState(storeStateWithTokens.getState()) - .provide([ - [matchers.call.fn(getViemWallet), mockViemWallet], - [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], - [matchers.call.fn(mockViemWallet.writeContract), mockTxHash], - [matchers.call.fn(publicClient.ethereum.waitForTransactionReceipt), mockTxReceipt], - [matchers.call.fn(publicClient.ethereum.getBlock), { timestamp: 1701102971 }], - ]) - .not.call.fn(encryptComment) - .call(getViemWallet, networkConfig.viemChain.ethereum) - .put( - addStandbyTransaction({ - ...expectedStandbyTransaction, - networkId: NetworkId['ethereum-sepolia'], - amount: { - value: BigNumber(2).negated().toString(), - tokenAddress: mockUSDCAddress, - tokenId: mockUSDCTokenId, - }, - metadata: { - comment: '', - }, - transactionHash: mockTxHash, - feeCurrencyId: mockEthTokenId, - }) - ) - .returns(mockTxReceipt) - .run() - - expect(mockSimulateContractEthereum).toHaveBeenCalledWith({ - address: getAddress(mockUSDCAddress), - abi: erc20.abi, - functionName: 'transfer', - account: mockViemWallet.account, - args: [getAddress(mockSendPaymentArgs.recipientAddress), BigInt(2e18)], - gas: BigInt(2000), - maxFeePerGas: BigInt(1000000), - }) - }) -}) - -describe('getSendTxFeeDetails', () => { - it('calls buildSendTx and chooseTxFeeDetails with the expected values and returns fee in viem format', async () => { - const recipientAddress = mockAccount - const amount = new BigNumber(10) - const tokenAddress = mockCusdAddress - const feeInfo = mockFeeInfo - const celoTx = { - txo: 'test', - } as unknown as CeloTransactionObject - const encryptedComment = 'test' - - const mockFeeDetails = { - feeCurrency: mockCusdAddress, - gas: feeInfo.gas, - gasPrice: feeInfo.gasPrice, - } - - await expectSaga(getSendTxFeeDetails, { - recipientAddress, - amount, - tokenAddress, - feeInfo, - encryptedComment, - }) - .withState(createMockStore().getState()) - .provide([ - [matchers.call.fn(buildSendTx), celoTx], - [matchers.call.fn(chooseTxFeeDetails), mockFeeDetails], - ]) - .call(buildSendTx, tokenAddress, amount, recipientAddress, encryptedComment) - .call( - chooseTxFeeDetails, - celoTx.txo, - feeInfo.feeCurrency, - feeInfo.gas.toNumber(), - feeInfo.gasPrice - ) - .returns(mockViemFeeInfo) - .run() - }) - - it('does not include feeCurrency if it is undefined', async () => { - const recipientAddress = mockAccount - const amount = new BigNumber(10) - const tokenAddress = mockCusdAddress - const feeInfo = mockFeeInfo - const celoTx = { - txo: 'test', - } as unknown as CeloTransactionObject - const encryptedComment = 'test' - - const mockFeeDetails = { - feeCurrency: undefined, - gas: feeInfo.gas, - gasPrice: feeInfo.gasPrice, - } - - await expectSaga(getSendTxFeeDetails, { - recipientAddress, - amount, - tokenAddress, - feeInfo, - encryptedComment, - }) - .withState(createMockStore().getState()) - .provide([ - [matchers.call.fn(buildSendTx), celoTx], - [matchers.call.fn(chooseTxFeeDetails), mockFeeDetails], - ]) - .call(buildSendTx, tokenAddress, amount, recipientAddress, encryptedComment) - .call(chooseTxFeeDetails, celoTx.txo, undefined, feeInfo.gas.toNumber(), feeInfo.gasPrice) - .returns({ gas: mockViemFeeInfo.gas, maxFeePerGas: mockViemFeeInfo.maxFeePerGas }) - .run() - }) - - it('returns fee if gas and gasPrice are strings', async () => { - const recipientAddress = mockAccount - const amount = new BigNumber(10) - const tokenAddress = mockCusdAddress - const feeInfo = { - feeCurrency: mockCusdAddress, - gas: mockFeeInfo.gas.toString(), - gasPrice: mockFeeInfo.gasPrice.toString(), - } as any - const celoTx = { - txo: 'test', - } as unknown as CeloTransactionObject - const encryptedComment = 'test' - - const mockFeeDetails = { - feeCurrency: mockCusdAddress, - gas: feeInfo.gas, - gasPrice: feeInfo.gasPrice, - } - - await expectSaga(getSendTxFeeDetails, { - recipientAddress, - amount, - tokenAddress, - feeInfo, - encryptedComment, - }) - .withState(createMockStore().getState()) - .provide([ - [matchers.call.fn(buildSendTx), celoTx], - [matchers.call.fn(chooseTxFeeDetails), mockFeeDetails], - ]) - .call(buildSendTx, tokenAddress, amount, recipientAddress, encryptedComment) - .call( - chooseTxFeeDetails, - celoTx.txo, - feeInfo.feeCurrency, - Number(feeInfo.gas), - feeInfo.gasPrice - ) - .returns(mockViemFeeInfo) - .run() - }) -}) - -describe('sendAndMonitorTransaction', () => { - const mockTxHash: `0x${string}` = '0x12345678901234' - const mockTxReceipt = { - status: 'success', - transactionHash: mockTxHash, - blockNumber: 123, - gasUsed: 1e6, - effectiveGasPrice: 1e9, - } - - const mockArgs = { - context: { id: 'txId' }, - network: Network.Celo, - sendTx: function* () { - return yield* mockTxHash - }, - feeCurrencyId: mockCeloTokenId, - } - - beforeEach(() => { - jest.clearAllMocks() - }) - it('confirms a transaction if successfully executed', async () => { - await expectSaga(sendAndMonitorTransaction, mockArgs) - .withState(storeStateWithTokens.getState()) - .provide([ - [matchers.call.fn(publicClient.celo.waitForTransactionReceipt), mockTxReceipt], - [matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }], - ]) - .put( - transactionConfirmed( - 'txId', - { - transactionHash: mockTxHash, - block: '123', - status: TransactionStatus.Complete, - fees: [ - { - type: 'SECURITY_FEE', - amount: { - value: '0.001', - tokenId: mockCeloTokenId, - }, - }, - ], - }, - 1701102971000 - ) - ) - .put(fetchTokenBalances({ showLoading: true })) - .returns(mockTxReceipt) - .run() - }) - - it('throws and confirms a transaction as failed if receipt status is reverted', async () => { - await expectSaga(sendAndMonitorTransaction, mockArgs) - .withState(storeStateWithTokens.getState()) - .provide([ - [ - matchers.call.fn(publicClient.celo.waitForTransactionReceipt), - { - status: 'reverted', - blockNumber: BigInt(123), - transactionHash: mockTxHash, - gasUsed: 1e4, - effectiveGasPrice: 1e10, - }, - ], - [matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }], - ]) - .put( - transactionConfirmed( - 'txId', - { - transactionHash: mockTxHash, - block: '123', - status: TransactionStatus.Failed, - fees: [ - { - type: 'SECURITY_FEE', - amount: { - value: '0.0001', - tokenId: mockCeloTokenId, - }, - }, - ], - }, - 1701102971000 - ) - ) - .put(showError(ErrorMessages.TRANSACTION_FAILED)) - .throws(new Error('transaction reverted')) - .run() - }) -}) diff --git a/src/viem/saga.ts b/src/viem/saga.ts deleted file mode 100644 index eced9bd3e15..00000000000 --- a/src/viem/saga.ts +++ /dev/null @@ -1,473 +0,0 @@ -import BigNumber from 'bignumber.js' -import erc20 from 'src/abis/IERC20' -import stableToken from 'src/abis/StableToken' -import { showError } from 'src/alert/actions' -import { TransactionEvents } from 'src/analytics/Events' -import ValoraAnalytics from 'src/analytics/ValoraAnalytics' -import { ErrorMessages } from 'src/app/ErrorMessages' -import { FeeInfo } from 'src/fees/saga' -import { encryptComment } from 'src/identity/commentEncryption' -import { buildSendTx } from 'src/send/saga' -import { getTokenInfo, tokenAmountInSmallestUnit } from 'src/tokens/saga' -import { tokensByIdSelector } from 'src/tokens/selectors' -import { - TokenBalanceWithAddress, - fetchTokenBalances, - tokenBalanceHasAddress, -} from 'src/tokens/slice' -import { getTokenId, tokenSupportsComments } from 'src/tokens/utils' -import { addStandbyTransaction } from 'src/transactions/actions' -import { handleTransactionReceiptReceived } from 'src/transactions/saga' -import { chooseTxFeeDetails, wrapSendTransactionWithRetry } from 'src/transactions/send' -import { Network, TokenTransactionTypeV2, TransactionContext } from 'src/transactions/types' -import Logger from 'src/utils/Logger' -import { ensureError } from 'src/utils/ensureError' -import { publicClient } from 'src/viem' -import { ViemWallet } from 'src/viem/getLockableWallet' -import { TransactionRequest, getFeeCurrencyToken } from 'src/viem/prepareTransactions' -import { - SerializableTransactionRequest, - getPreparedTransaction, -} from 'src/viem/preparedTransactionSerialization' -import { getViemWallet } from 'src/web3/contracts' -import networkConfig from 'src/web3/networkConfig' -import { unlockAccount } from 'src/web3/saga' -import { getNetworkFromNetworkId } from 'src/web3/utils' -import { call, put, select } from 'typed-redux-saga' -import { Hash, TransactionReceipt, WriteContractParameters, getAddress } from 'viem' - -const TAG = 'viem/saga' - -/** - * Send a payment with viem. The equivalent of buildAndSendPayment in src/send/saga. - * - * @param options an object containing the arguments - * @param options.context the transaction context - * @param options.recipientAddress the address to send the payment to - * @param options.amount the crypto amount to send - * @param options.tokenAddress the crypto token address - * @param options.comment the comment on the transaction - * @param options.feeInfo an object containing the fee information - * @returns - */ -export function* sendPayment({ - context, - recipientAddress, - amount, - tokenId, - comment, - feeInfo, - preparedTransaction, -}: { - context: TransactionContext - recipientAddress: string - amount: BigNumber - tokenId: string - comment: string - feeInfo?: FeeInfo - preparedTransaction?: SerializableTransactionRequest -}) { - const tokenInfo = yield* call(getTokenInfo, tokenId) - const network = getNetworkFromNetworkId(tokenInfo?.networkId) - if (!tokenInfo || !network) { - throw new Error('Unknown token network') - } - const networkId = tokenInfo.networkId - - const wallet = yield* call(getViemWallet, networkConfig.viemChain[network]) - - if (!wallet.account) { - // this should never happen - throw new Error('no account found in the wallet') - } - - Logger.debug( - TAG, - 'Transferring token', - context.description ?? 'No description', - context.id, - tokenId, - amount, - feeInfo - ) - - const unlockWallet = function* () { - // This will never happen, but Typescript complains otherwise - if (!wallet.account) { - throw new Error('no account found in the wallet') - } - - // unlock account before executing tx - yield* call(unlockAccount, wallet.account.address) - } - - const tokensById = yield* select((state) => tokensByIdSelector(state, [networkId])) - const feeCurrencyId = preparedTransaction - ? getFeeCurrencyToken([getPreparedTransaction(preparedTransaction)], networkId, tokensById) - ?.tokenId - : getTokenId(networkId) - - if (!feeCurrencyId) { - // This should never happen - throw new Error(`No fee currency found with id '${feeCurrencyId}' in network ${networkId}`) - } - - const addPendingStandbyTransaction = function* (hash: string) { - yield* put( - addStandbyTransaction({ - __typename: 'TokenTransferV3', - type: TokenTransactionTypeV2.Sent, - context, - networkId, - amount: { - value: amount.negated().toString(), - tokenAddress: tokenInfo.address ?? undefined, - tokenId, - }, - address: recipientAddress, - metadata: { - comment, - }, - transactionHash: hash, - feeCurrencyId, - }) - ) - } - - // For tokens with an address, we simulate calling 'transfer' on the contract, - // take the request generated by that simulation, and execute that request - // - // For tokens with no address, we perform a simple `call` to test the request. - try { - if (tokenBalanceHasAddress(tokenInfo)) { - // this returns a method which is then passed to call instead of directly - // doing yield* call(publicClient.celo.simulateContract, args) because this - // results in a long TS error - const simulateContractMethod = yield* call(getTransferSimulateContract, { - wallet, - tokenInfo, - amount, - recipientAddress, - comment, - feeInfo, - preparedTransaction, - }) - - const { request } = yield* call(simulateContractMethod) - - yield* call(unlockWallet) - - const sendContractTxMethod = function* () { - const hash = yield* call(wallet.writeContract, request as WriteContractParameters) - yield* call(addPendingStandbyTransaction, hash) - return hash - } - - const receipt = yield* call(sendAndMonitorTransaction, { - context, - network, - sendTx: sendContractTxMethod, - feeCurrencyId, - }) - - return receipt - } else { - const convertedAmount = BigInt(tokenAmountInSmallestUnit(amount, tokenInfo.decimals)) - - let feeFields: Pick = { - gas: undefined, - maxFeePerGas: undefined, - } - if (preparedTransaction) { - const preparedTx = getPreparedTransaction(preparedTransaction) - feeFields = { - gas: preparedTx.gas, - maxFeePerGas: preparedTx.maxFeePerGas, - } - } - - // This call method will throw an error if there are issues with the TX (namely, - // if there are insufficient funds to pay for gas). - const callMethod = () => - publicClient[network].call({ - account: wallet.account, - to: getAddress(recipientAddress), - value: convertedAmount, - ...feeFields, - }) - - Logger.debug(TAG, 'Invoking call for native token transfer', { - recipientAddress, - convertedAmount: convertedAmount.toString(), - network, - gas: feeFields.gas?.toString(), - maxFeePerGas: feeFields.maxFeePerGas?.toString(), - }) - - yield* call(callMethod) - yield* call(unlockWallet) - - const sendNativeTxMethod = function* () { - if (!wallet.account) { - throw new Error('no account found in the wallet') - } - - const hash = yield* call([wallet, 'sendTransaction'], { - account: wallet.account, - to: getAddress(recipientAddress), - value: convertedAmount, - chain: networkConfig.viemChain[network], - ...feeFields, - }) - - yield* call(addPendingStandbyTransaction, hash) - - return hash - } - - const receipt = yield* call(sendAndMonitorTransaction, { - context, - network, - sendTx: sendNativeTxMethod, - feeCurrencyId, - }) - return receipt - } - } catch (err) { - Logger.error(TAG, JSON.stringify(err, null, 4)) - Logger.warn(TAG, 'Transaction failed', err) - throw err - } -} - -/** - * Gets a function that invokes simulateContract for the appropriate contract - * method based on the token. If the token is a stable token, it uses the - * `transferWithComment` on the stable token contract, otherwise the `transfer` - * method on the ERC20 contract - * - * @param options an object containing the arguments - * @returns a function that invokes the simulateContract method - */ -function* getTransferSimulateContract({ - wallet, - tokenInfo, - amount, - recipientAddress, - comment, - feeInfo, - preparedTransaction, -}: { - wallet: ViemWallet - tokenInfo: TokenBalanceWithAddress - recipientAddress: string - amount: BigNumber - comment: string - feeInfo?: FeeInfo - preparedTransaction?: SerializableTransactionRequest -}) { - if (!wallet.account) { - // this should never happen - throw new Error('no account found in the wallet') - } - - const convertedAmount = BigInt(tokenAmountInSmallestUnit(amount, tokenInfo.decimals)) - - const encryptedComment = tokenSupportsComments(tokenInfo) - ? yield* call(encryptComment, comment, recipientAddress, wallet.account.address, true) - : undefined - - const network = getNetworkFromNetworkId(tokenInfo.networkId) - if (!network) { - throw new Error('invalid network for transfer') - } - - let feeFields: Pick & { feeCurrency?: string } = { - gas: undefined, - maxFeePerGas: undefined, - } - - if (preparedTransaction) { - const preparedTx = getPreparedTransaction(preparedTransaction) - feeFields = { - gas: preparedTx.gas, - maxFeePerGas: preparedTx.maxFeePerGas, - } - // @ts-ignore feeCurrency should only be present if tx type is cip42, but we never - // actually set the tx type to cip42 anywhere, but we /do/ set feeCurrency. - // TODO: Remove this once we directly use preparedTransaction to send the TX - // and get rid of simulateContract calls. - if (preparedTx.feeCurrency) { - // @ts-ignore - feeFields.feeCurrency = preparedTx.feeCurrency - } - } else if (feeInfo) { - feeFields = yield* call(getSendTxFeeDetails, { - recipientAddress, - amount, - tokenAddress: tokenInfo.address, - feeInfo, - encryptedComment: encryptedComment || '', - }) - } - - if (tokenSupportsComments(tokenInfo)) { - Logger.debug(TAG, 'Calling simulate contract for transferWithComment', { - recipientAddress, - convertedAmount: convertedAmount.toString(), - tokenAddress: tokenInfo.address, - network, - feeCurrency: feeFields.feeCurrency, - gas: feeFields.gas?.toString(), - maxFeePerGas: feeFields.maxFeePerGas?.toString(), - }) - - return () => - publicClient.celo.simulateContract({ - address: getAddress(tokenInfo.address), - abi: stableToken.abi, - functionName: 'transferWithComment', - account: wallet.account, - args: [getAddress(recipientAddress), convertedAmount, encryptedComment || ''], - ...feeFields, - }) - } - - Logger.debug(TAG, 'Calling simulate contract for transfer', { - recipientAddress, - convertedAmount: convertedAmount.toString(), - tokenAddress: tokenInfo.address, - network, - feeCurrency: feeFields.feeCurrency, - gas: feeFields.gas?.toString(), - maxFeePerGas: feeFields.maxFeePerGas?.toString(), - }) - - return () => - publicClient[network].simulateContract({ - address: getAddress(tokenInfo.address), - abi: erc20.abi, - functionName: 'transfer', - account: wallet.account, - args: [getAddress(recipientAddress), convertedAmount], - ...feeFields, - }) -} - -/** - * Helper function to call chooseTxFeeDetails for send transactions (aka - * transfer contract calls) using parameters that are not specific to contractkit - * - * @deprecated will be cleaned up when old send flow is removed (ACT-1090) - * @param options the getSendTxFeeDetails options - * @returns an object with the feeInfo compatible with viem - */ -export function* getSendTxFeeDetails({ - recipientAddress, - amount, - tokenAddress, - feeInfo, - encryptedComment, -}: { - recipientAddress: string - amount: BigNumber - tokenAddress: string - feeInfo: FeeInfo - encryptedComment?: string -}) { - const celoTx = yield* call( - buildSendTx, - tokenAddress, - amount, - recipientAddress, - encryptedComment || '' - ) - const { feeCurrency, gas, gasPrice } = yield* call( - chooseTxFeeDetails, - celoTx.txo, - feeInfo.feeCurrency, - // gas and gasPrice can either be BigNumber or string. Since these are - // stored in redux, BigNumbers are serialized as strings. - Number(feeInfo.gas), - feeInfo.gasPrice - ) - // Return fields in format compatible with viem - return { - // Don't include the feeCurrency field if not present. Otherwise viem throws - // saying feeCurrency is required for CIP-42 transactions. Not setting the - // field at all bypasses this check and the tx succeeds with fee paid with - // CELO. - ...(feeCurrency && { feeCurrency: getAddress(feeCurrency) }), - gas: gas ? BigInt(gas) : undefined, - maxFeePerGas: gasPrice ? BigInt(Number(gasPrice)) : undefined, - } -} - -export function* sendAndMonitorTransaction({ - context, - network, - sendTx, - feeCurrencyId, -}: { - context: TransactionContext - network: Network - sendTx: () => Generator - feeCurrencyId: string -}) { - Logger.debug(TAG + '@sendAndMonitorTransaction', `Sending transaction with id: ${context.id}`) - - const commonTxAnalyticsProps = { txId: context.id, web3Library: 'viem' as const } - - ValoraAnalytics.track(TransactionEvents.transaction_start, { - ...commonTxAnalyticsProps, - description: context.description, - }) - - const sendTxMethod = function* () { - const hash = yield* call(sendTx) - ValoraAnalytics.track(TransactionEvents.transaction_hash_received, { - ...commonTxAnalyticsProps, - txHash: hash, - }) - const receipt = yield* call([publicClient[network], 'waitForTransactionReceipt'], { hash }) - - ValoraAnalytics.track(TransactionEvents.transaction_receipt_received, commonTxAnalyticsProps) - return receipt as unknown as TransactionReceipt // Need to cast here else the wrapSendTransactionWithRetry call complains - } - - try { - // Reuse existing method which times out the sendTxMethod and includes some - // grace period logic to handle app backgrounding when sending. - // there is a bug with 'race' in typed-redux-saga, so we need to hard cast the result - // https://github.com/agiledigital/typed-redux-saga/issues/43#issuecomment-1259706876 - const receipt = (yield* call( - wrapSendTransactionWithRetry, - sendTxMethod, - context - )) as unknown as TransactionReceipt - - yield* call( - handleTransactionReceiptReceived, - context.id, - receipt, - networkConfig.networkToNetworkId[network], - feeCurrencyId - ) - - if (receipt.status === 'reverted') { - throw new Error('transaction reverted') - } - ValoraAnalytics.track(TransactionEvents.transaction_confirmed, commonTxAnalyticsProps) - yield* put(fetchTokenBalances({ showLoading: true })) - return receipt - } catch (err) { - const error = ensureError(err) - Logger.error(TAG + '@sendAndMonitorTransaction', `Error sending tx ${context.id}`, error) - ValoraAnalytics.track(TransactionEvents.transaction_exception, { - ...commonTxAnalyticsProps, - error: error.message, - }) - yield* put(showError(ErrorMessages.TRANSACTION_FAILED)) - throw error - } -}