diff --git a/src/send/usePrepareSendTransactions.test.ts b/src/send/usePrepareSendTransactions.test.ts index 848d9ffdc14..75a7528f3d6 100644 --- a/src/send/usePrepareSendTransactions.test.ts +++ b/src/send/usePrepareSendTransactions.test.ts @@ -5,9 +5,10 @@ import { import { PreparedTransactionsResult, prepareERC20TransferTransaction, + prepareSendNativeAssetTransaction, prepareTransferWithCommentTransaction, } from 'src/viem/prepareTransactions' -import { mockCeloTokenBalance } from 'test/values' +import { mockCeloTokenBalance, mockEthTokenBalance } from 'test/values' import BigNumber from 'bignumber.js' import mocked = jest.mocked import { renderHook } from '@testing-library/react-native' @@ -69,7 +70,7 @@ describe('usePrepareSendTransactions', () => { }) ).toBeUndefined() }) - it('uses prepareERC20TransferTransaction if token does not support comments', async () => { + it('uses prepareERC20TransferTransaction if token is erc20 and does not support comments', async () => { mocked(tokenSupportsComments).mockReturnValue(false) const mockPrepareTransactionsResult: PreparedTransactionsResult = { type: 'not-enough-balance-for-gas', @@ -119,6 +120,30 @@ describe('usePrepareSendTransactions', () => { comment: 'mock comment', }) }) + it('uses prepareSendNativeAssetTransaction if token is native and does not have address', async () => { + mocked(tokenSupportsComments).mockReturnValue(false) + const mockPrepareTransactionsResult: PreparedTransactionsResult = { + type: 'not-enough-balance-for-gas', + feeCurrencies: [mockEthTokenBalance], + } + mocked(prepareSendNativeAssetTransaction).mockResolvedValue(mockPrepareTransactionsResult) + expect( + await _prepareSendTransactionsCallback({ + amount: new BigNumber(0.05), + token: mockEthTokenBalance, + recipientAddress: '0xabc', + walletAddress: '0x123', + feeCurrencies: [mockEthTokenBalance], + }) + ).toStrictEqual(mockPrepareTransactionsResult) + expect(prepareSendNativeAssetTransaction).toHaveBeenCalledWith({ + fromWalletAddress: '0x123', + toWalletAddress: '0xabc', + sendToken: mockEthTokenBalance, + amount: BigInt('5'.concat('0'.repeat(16))), + feeCurrencies: [mockEthTokenBalance], + }) + }) }) describe('usePrepareSendTransactions', () => { // integration tests (testing both usePrepareSendTransactions and _prepareSendTransactionsCallback at once) diff --git a/src/send/usePrepareSendTransactions.ts b/src/send/usePrepareSendTransactions.ts index de47c8768bc..9dec32fbc9f 100644 --- a/src/send/usePrepareSendTransactions.ts +++ b/src/send/usePrepareSendTransactions.ts @@ -2,9 +2,10 @@ import { useState } from 'react' import { PreparedTransactionsResult, prepareERC20TransferTransaction, + prepareSendNativeAssetTransaction, prepareTransferWithCommentTransaction, } from 'src/viem/prepareTransactions' -import { TokenBalance, tokenBalanceHasAddress } from 'src/tokens/slice' +import { TokenBalance, tokenBalanceHasAddress, isNativeTokenBalance } from 'src/tokens/slice' import BigNumber from 'bignumber.js' import { useAsyncCallback } from 'react-async-hook' import { tokenSupportsComments } from 'src/tokens/utils' @@ -32,22 +33,33 @@ export async function _prepareSendTransactionsCallback({ if (amount.isLessThanOrEqualTo(0)) { return } + const baseTransactionParams = { + // not including sendToken yet because of typing. need to check whether token has address field or not first, required for erc-20 transfers + fromWalletAddress: walletAddress, + toWalletAddress: recipientAddress, + amount: BigInt(tokenAmountInSmallestUnit(amount, token.decimals)), + feeCurrencies, + } if (tokenBalanceHasAddress(token)) { - const transactionParams = { - fromWalletAddress: walletAddress, - toWalletAddress: recipientAddress, - sendToken: token, - amount: BigInt(tokenAmountInSmallestUnit(amount, token.decimals)), - feeCurrencies, - comment, - } + // NOTE: CELO will be sent as ERC-20. This makes analytics easier, but if gas prices increase later on and/or we + // gain analytics coverage for native CELO transfers, we could switch to sending CELO as native asset to save on gas + const transactionParams = { ...baseTransactionParams, sendToken: token, comment } if (tokenSupportsComments(token)) { return prepareTransferWithCommentTransaction(transactionParams) } else { return prepareERC20TransferTransaction(transactionParams) } + } else if (isNativeTokenBalance(token)) { + return prepareSendNativeAssetTransaction({ + ...baseTransactionParams, + sendToken: token, + }) + } else { + Logger.error( + TAG, + `Token does not have address AND is not native. token: ${JSON.stringify(token)}}` + ) } - // TODO(ACT-956): non-ERC20 native asset case } /** diff --git a/src/tokens/slice.ts b/src/tokens/slice.ts index cbe338c3ffd..348a840d53c 100644 --- a/src/tokens/slice.ts +++ b/src/tokens/slice.ts @@ -59,21 +59,17 @@ export interface TokenBalance extends BaseToken { } // The "WithAddress" suffixed types are legacy types, for places in the wallet -// that require an address to be present. As we move to multichain, (where address -// is not guaranteed,) existing code should be updated to use the "address optional" types. +// that require an address to be present. Many are deprecated because in most places, we should +// be able to handle tokens without addresses the same way (just use tokenId instead if you need a token identifier). +// Exceptions include anything that directly interacts with the blockchain, where it makes a difference +// if a token doesn't have an address. + export interface TokenBalanceWithAddress extends TokenBalance { address: string } -// The "WithAddress" suffixed types are legacy types, for places in the wallet -// that require an address to be present. As we move to multichain, (where address -// is not guaranteed,) existing code should be updated to use the "address optional" types. - -/** - * @deprecated use `TokenBalance` for new code - */ -export interface TokenBalanceWithAddress extends TokenBalance { - address: string +export interface NativeTokenBalance extends TokenBalance { + isNative: true } export interface StoredTokenBalances { @@ -114,6 +110,10 @@ export function tokenBalanceHasAddress( return !!tokenInfo.address } +export function isNativeTokenBalance(tokenInfo: TokenBalance): tokenInfo is NativeTokenBalance { + return !!tokenInfo.isNative +} + export const initialState = { tokenBalances: {}, loading: false, diff --git a/src/viem/prepareTransactions.test.ts b/src/viem/prepareTransactions.test.ts index 6f040da7e7f..121e5b02e9e 100644 --- a/src/viem/prepareTransactions.test.ts +++ b/src/viem/prepareTransactions.test.ts @@ -16,8 +16,9 @@ import { prepareTransferWithCommentTransaction, tryEstimateTransaction, tryEstimateTransactions, + prepareSendNativeAssetTransaction, } from 'src/viem/prepareTransactions' -import { mockCeloTokenBalance } from 'test/values' +import { mockCeloTokenBalance, mockEthTokenBalance } from 'test/values' import { Address, BaseError, @@ -641,6 +642,27 @@ describe('prepareTransactions module', () => { }) }) + it('prepareSendNativeAssetTransaction', async () => { + const mockPrepareTransactions = jest.fn() + await prepareSendNativeAssetTransaction( + { + fromWalletAddress: '0x123', + toWalletAddress: '0x456', + amount: BigInt(100), + feeCurrencies: [mockEthTokenBalance], + sendToken: mockEthTokenBalance, + }, + mockPrepareTransactions + ) + expect(mockPrepareTransactions).toHaveBeenCalledWith({ + feeCurrencies: [mockEthTokenBalance], + spendToken: mockEthTokenBalance, + spendTokenAmount: new BigNumber(100), + decreasedAmountGasFeeMultiplier: 1, + baseTransactions: [{ from: '0x123', to: '0x456', value: BigInt(100) }], + }) + }) + it('prepareTransferWithCommentTransaction', async () => { const mockPrepareTransactions = jest.fn() mocked(encodeFunctionData).mockReturnValue('0xabc') diff --git a/src/viem/prepareTransactions.ts b/src/viem/prepareTransactions.ts index 67639cdb921..cf08edd3075 100644 --- a/src/viem/prepareTransactions.ts +++ b/src/viem/prepareTransactions.ts @@ -3,7 +3,7 @@ import { TransactionRequestCIP42 } from 'node_modules/viem/_types/chains/celo/ty import erc20 from 'src/abis/IERC20' import stableToken from 'src/abis/StableToken' import { STATIC_GAS_PADDING } from 'src/config' -import { TokenBalance, TokenBalanceWithAddress } from 'src/tokens/slice' +import { NativeTokenBalance, TokenBalance, TokenBalanceWithAddress } from 'src/tokens/slice' import Logger from 'src/utils/Logger' import { estimateFeesPerGas } from 'src/viem/estimateFeesPerGas' import { publicClient } from 'src/viem/index' @@ -205,7 +205,7 @@ export async function prepareTransactions({ throwOnSpendTokenAmountExceedsBalance = true, }: { feeCurrencies: TokenBalance[] - spendToken: TokenBalanceWithAddress + spendToken: TokenBalance spendTokenAmount: BigNumber decreasedAmountGasFeeMultiplier: number baseTransactions: (TransactionRequest & { gas?: bigint })[] @@ -372,7 +372,46 @@ export async function prepareTransferWithCommentTransaction( }) } -// TODO(ACT-956) create helper for native transfers +/** + * Prepare a transaction for sending native asset. + * + * @param fromWalletAddress - sender address + * @param toWalletAddress - recipient address + * @param amount the amount of the token to send, denominated in the smallest units for that token + * @param feeCurrencies - tokens to consider using for paying the transaction fee + * @param sendToken - native asset to send. MUST be native asset (e.g. sendable using the 'value' field of a transaction, like ETH or CELO) + * + * @param prepareTxs a function that prepares the transactions (for unit testing-- should use default everywhere else) + **/ +export function prepareSendNativeAssetTransaction( + { + fromWalletAddress, + toWalletAddress, + amount, + feeCurrencies, + sendToken, + }: { + fromWalletAddress: string + toWalletAddress: string + amount: bigint + feeCurrencies: TokenBalance[] + sendToken: NativeTokenBalance + }, + prepareTxs = prepareTransactions +): Promise { + const baseSendTx: TransactionRequest = { + from: fromWalletAddress as Address, + to: toWalletAddress as Address, + value: amount, + } + return prepareTxs({ + feeCurrencies, + spendToken: sendToken, + spendTokenAmount: new BigNumber(amount.toString()), + decreasedAmountGasFeeMultiplier: 1, + baseTransactions: [baseSendTx], + }) +} /** * Given prepared transactions, get the fee currency and amount in decimals diff --git a/test/values.ts b/test/values.ts index 6ef42b04fb4..340f68608c9 100644 --- a/test/values.ts +++ b/test/values.ts @@ -46,7 +46,7 @@ import { RecipientType, } from 'src/recipients/recipient' import { TransactionDataInput } from 'src/send/SendAmount' -import { StoredTokenBalance, TokenBalance } from 'src/tokens/slice' +import { NativeTokenBalance, StoredTokenBalance, TokenBalance } from 'src/tokens/slice' import { NetworkId, TokenTransactionTypeV2 } from 'src/transactions/types' import { CiCoCurrency, Currency } from 'src/utils/currencies' import { ONE_DAY_IN_MILLIS } from 'src/utils/time' @@ -509,6 +509,20 @@ export const mockTokenBalances: Record = { priceFetchedAt: Date.now(), isCashInEligible: true, }, + [mockEthTokenId]: { + priceUsd: '1500', + address: null, + tokenId: mockEthTokenId, + networkId: NetworkId['ethereum-sepolia'], + symbol: 'ETH', + imageUrl: + 'https://raw.githubusercontent.com/valora-inc/address-metadata/main/assets/tokens/ETH.png', + name: 'Ether', + decimals: 18, + balance: '0', + priceFetchedAt: Date.now(), + isNative: true, + }, } export const mockCeloTokenBalance: TokenBalance = { @@ -518,6 +532,14 @@ export const mockCeloTokenBalance: TokenBalance = { balance: new BigNumber(5), } +export const mockEthTokenBalance: NativeTokenBalance = { + ...mockTokenBalances[mockEthTokenId], + priceUsd: new BigNumber(1500), + lastKnownPriceUsd: new BigNumber(1500), + balance: new BigNumber(0.1), + isNative: true, +} + export const mockTokenBalancesWithHistoricalPrices = { [mockPoofTokenId]: { ...mockTokenBalances[mockPoofTokenId],