From f585928d260786ed259093f476a785bed7344009 Mon Sep 17 00:00:00 2001 From: Gonzalo Nardini Date: Wed, 3 Nov 2021 18:47:09 -0300 Subject: [PATCH] Allow sending or inviting using multiple tokens (#1342) ### Description The title says it all :D ### Other changes - N/A ### Tested Manually and with unit tests ### How others should test This is disabled by a feature flag, but we'll have instructions soon. Basically just need to send or invite users normally using different tokens and try to find issues! ### Related issues - Part of #1126 ### Backwards compatibility N/A --- .../__mocks__/@celo/contractkit/index.ts | 6 + packages/mobile/locales/en-US/sendFlow7.json | 2 +- packages/mobile/src/analytics/Properties.tsx | 18 +- packages/mobile/src/escrow/actions.ts | 9 +- packages/mobile/src/escrow/saga.test.ts | 66 +++++- packages/mobile/src/escrow/saga.ts | 48 +++-- .../mobile/src/fiatExchanges/saga.test.ts | 6 +- packages/mobile/src/fiatExchanges/saga.ts | 3 +- packages/mobile/src/invite/saga.test.ts | 9 +- packages/mobile/src/invite/saga.ts | 80 +++----- .../mobile/src/send/SendConfirmation.test.tsx | 36 +++- packages/mobile/src/send/SendConfirmation.tsx | 30 +-- .../src/send/SendConfirmationLegacy.tsx | 4 +- packages/mobile/src/send/actions.ts | 45 +++- packages/mobile/src/send/reducers.ts | 1 + packages/mobile/src/send/saga.test.ts | 115 ++++++++++- packages/mobile/src/send/saga.ts | 194 +++++++++++++++++- packages/mobile/src/tokens/e2eTokens.ts | 38 ++++ packages/mobile/src/tokens/saga.test.ts | 34 ++- packages/mobile/src/tokens/saga.ts | 29 ++- packages/mobile/test/values.ts | 7 + 21 files changed, 635 insertions(+), 145 deletions(-) create mode 100644 packages/mobile/src/tokens/e2eTokens.ts diff --git a/packages/mobile/__mocks__/@celo/contractkit/index.ts b/packages/mobile/__mocks__/@celo/contractkit/index.ts index 82b76ea780a..1f3682ca178 100644 --- a/packages/mobile/__mocks__/@celo/contractkit/index.ts +++ b/packages/mobile/__mocks__/@celo/contractkit/index.ts @@ -61,6 +61,11 @@ const Exchange = { exchange: jest.fn(), } +const Escrow = { + getReceivedPaymentIds: jest.fn(() => []), + transfer: jest.fn(), +} + const web3 = new Web3() const connection = { web3: web3 } @@ -74,6 +79,7 @@ const kit = { getAccounts: jest.fn(async () => Accounts), getReserve: jest.fn(async () => Reserve), getExchange: jest.fn(async () => Exchange), + getEscrow: jest.fn(async () => Escrow), }, registry: { addressFor: async (address: string) => 1000, diff --git a/packages/mobile/locales/en-US/sendFlow7.json b/packages/mobile/locales/en-US/sendFlow7.json index be971377645..37bfb92100a 100644 --- a/packages/mobile/locales/en-US/sendFlow7.json +++ b/packages/mobile/locales/en-US/sendFlow7.json @@ -54,7 +54,7 @@ "sendToAddress": "Send to Address", "walletAddress": "Wallet Address", "inviteWithoutPayment": "Hi! I’ve been using {{appName}} to send money worldwide. It’s easy to try and I want to invite you to check it out with this link: {{link}}", - "inviteWithEscrowedPayment": "Hi! I’ve been using {{appName}} to send money worldwide. I just sent you {{amount}} {{currency}}. Please download the {{appName}} App and redeem your {{currency}} using this link: {{link}}", + "inviteWithEscrowedPayment": "Hi! I’ve been using {{appName}} to send money worldwide. I just sent you {{amount}} {{token}}. Please download the {{appName}} App and redeem your {{token}} using this link: {{link}}", "inviteSent": "Invite code sent!", "inviteFailed": "Failure sending invite", "loadingContacts": "Finding your contacts", diff --git a/packages/mobile/src/analytics/Properties.tsx b/packages/mobile/src/analytics/Properties.tsx index 53b9b2ef40f..b16d174cfc0 100644 --- a/packages/mobile/src/analytics/Properties.tsx +++ b/packages/mobile/src/analytics/Properties.tsx @@ -517,16 +517,19 @@ interface InviteEventsProperties { error: string } [InviteEvents.invite_start]: { - escrowIncluded: boolean - amount: string | undefined + amount: string + tokenAddress: string + usdAmount: string } [InviteEvents.invite_complete]: { - escrowIncluded: boolean - amount: string | undefined + amount: string + tokenAddress: string + usdAmount: string } [InviteEvents.invite_error]: { - escrowIncluded: boolean - amount: string | undefined + amount: string + tokenAddress: string + usdAmount: string error: string } [InviteEvents.invite_method_sms]: undefined @@ -648,7 +651,8 @@ interface SendEventsProperties { txId: string recipientAddress: string amount: string - currency: string + usdAmount: string | undefined + tokenAddress: string } [SendEvents.send_tx_error]: { error: string diff --git a/packages/mobile/src/escrow/actions.ts b/packages/mobile/src/escrow/actions.ts index 643447fec7a..f5e578b7caa 100644 --- a/packages/mobile/src/escrow/actions.ts +++ b/packages/mobile/src/escrow/actions.ts @@ -31,9 +31,8 @@ export interface EscrowTransferPaymentAction { type: Actions.TRANSFER_PAYMENT phoneHashDetails: PhoneNumberHashDetails amount: BigNumber - currency: Currency + tokenAddress: string context: TransactionContext - tempWalletAddress?: string feeInfo?: FeeInfo } export interface EscrowReclaimPaymentAction { @@ -80,17 +79,15 @@ export type ActionTypes = export const transferEscrowedPayment = ( phoneHashDetails: PhoneNumberHashDetails, amount: BigNumber, - currency: Currency, + tokenAddress: string, context: TransactionContext, - tempWalletAddress?: string, feeInfo?: FeeInfo ): EscrowTransferPaymentAction => ({ type: Actions.TRANSFER_PAYMENT, phoneHashDetails, amount, - currency, + tokenAddress, context, - tempWalletAddress, feeInfo, }) diff --git a/packages/mobile/src/escrow/saga.test.ts b/packages/mobile/src/escrow/saga.test.ts index a025bd09e95..f55406b0be5 100644 --- a/packages/mobile/src/escrow/saga.test.ts +++ b/packages/mobile/src/escrow/saga.test.ts @@ -5,18 +5,76 @@ import * as matchers from 'redux-saga-test-plan/matchers' import { call } from 'redux-saga/effects' import { showError } from 'src/alert/actions' import { ErrorMessages } from 'src/app/ErrorMessages' +import { ESCROW_PAYMENT_EXPIRY_SECONDS } from 'src/config' import { Actions, EscrowReclaimPaymentAction, EscrowTransferPaymentAction, + fetchSentEscrowPayments, } from 'src/escrow/actions' import { reclaimFromEscrow, transferToEscrow } from 'src/escrow/saga' +import { NUM_ATTESTATIONS_REQUIRED } from 'src/identity/verification' +import { getERC20TokenContract } from 'src/tokens/saga' +import { sendAndMonitorTransaction } from 'src/transactions/saga' +import { sendTransaction } from 'src/transactions/send' import { newTransactionContext } from 'src/transactions/types' -import { Currency } from 'src/utils/currencies' -import { getConnectedAccount, unlockAccount, UnlockResult } from 'src/web3/saga' -import { mockAccount, mockE164Number, mockE164NumberHash, mockE164NumberPepper } from 'test/values' +import { getContractKitAsync } from 'src/web3/contracts' +import { + getConnectedAccount, + getConnectedUnlockedAccount, + unlockAccount, + UnlockResult, +} from 'src/web3/saga' +import { createMockStore } from 'test/utils' +import { + mockAccount, + mockContract, + mockCusdAddress, + mockE164Number, + mockE164NumberHash, + mockE164NumberPepper, +} from 'test/values' describe(transferToEscrow, () => { + it.only('transfers successfully if all parameters are right', async () => { + const kit = await getContractKitAsync() + const phoneHashDetails: PhoneNumberHashDetails = { + e164Number: mockE164Number, + phoneHash: mockE164NumberHash, + pepper: mockE164NumberPepper, + } + const escrowTransferAction: EscrowTransferPaymentAction = { + type: Actions.TRANSFER_PAYMENT, + phoneHashDetails, + amount: new BigNumber(10), + tokenAddress: mockCusdAddress, + context: newTransactionContext('Escrow', 'Transfer'), + } + await expectSaga(transferToEscrow, escrowTransferAction) + .withState(createMockStore().getState()) + .provide([ + [call(getConnectedUnlockedAccount), mockAccount], + [call(getERC20TokenContract, mockCusdAddress), mockContract], + [matchers.call.fn(sendTransaction), true], + [matchers.call.fn(sendAndMonitorTransaction), { receipt: true, error: undefined }], + ]) + .put(fetchSentEscrowPayments()) + .run() + const escrowContract = await kit.contracts.getEscrow() + expect(mockContract.methods.approve).toHaveBeenCalledWith( + escrowContract.address, + '10000000000000000000' + ) + expect(escrowContract.transfer).toHaveBeenCalledWith( + mockE164NumberHash, + mockCusdAddress, + '10000000000000000000', + ESCROW_PAYMENT_EXPIRY_SECONDS, + expect.any(String), + NUM_ATTESTATIONS_REQUIRED + ) + }) + it('fails if user cancels PIN input', async () => { const phoneHashDetails: PhoneNumberHashDetails = { e164Number: mockE164Number, @@ -27,7 +85,7 @@ describe(transferToEscrow, () => { type: Actions.TRANSFER_PAYMENT, phoneHashDetails, amount: new BigNumber(10), - currency: Currency.Dollar, + tokenAddress: mockCusdAddress, context: newTransactionContext('Escrow', 'Transfer'), } await expectSaga(transferToEscrow, escrowTransferAction) diff --git a/packages/mobile/src/escrow/saga.ts b/packages/mobile/src/escrow/saga.ts index ec3c75f6431..e541a3d8284 100644 --- a/packages/mobile/src/escrow/saga.ts +++ b/packages/mobile/src/escrow/saga.ts @@ -1,9 +1,14 @@ import { Result } from '@celo/base' -import { CeloTransactionObject, CeloTxReceipt, Sign } from '@celo/connect' +import { + CeloTransactionObject, + CeloTxReceipt, + Contract, + Sign, + toTransactionObject, +} from '@celo/connect' import { ContractKit } from '@celo/contractkit' import { EscrowWrapper } from '@celo/contractkit/lib/wrappers/Escrow' import { MetaTransactionWalletWrapper } from '@celo/contractkit/lib/wrappers/MetaTransactionWallet' -import { StableTokenWrapper } from '@celo/contractkit/lib/wrappers/StableTokenWrapper' import { PhoneNumberHashDetails } from '@celo/identity/lib/odis/phone-number-identifier' import { FetchError, TxError } from '@komenci/kit/lib/errors' import BigNumber from 'bignumber.js' @@ -27,7 +32,6 @@ import { } from 'src/escrow/actions' import { generateEscrowPaymentIdAndPk, generateUniquePaymentId } from 'src/escrow/utils' import { calculateFee } from 'src/fees/saga' -import { WEI_DECIMALS } from 'src/geth/consts' import { waitForNextBlock } from 'src/geth/saga' import i18n from 'src/i18n' import { Actions as IdentityActions, SetVerificationStatusAction } from 'src/identity/actions' @@ -37,12 +41,15 @@ import { VerificationStatus } from 'src/identity/types' import { NUM_ATTESTATIONS_REQUIRED } from 'src/identity/verification' import { navigateHome } from 'src/navigator/NavigationService' import { fetchStableBalances } from 'src/stableToken/actions' +import { TokenBalance } from 'src/tokens/reducer' import { getCurrencyAddress, + getERC20TokenContract, getStableCurrencyFromAddress, - getTokenContract, getTokenContractFromAddress, + tokenAmountInSmallestUnit, } from 'src/tokens/saga' +import { tokensListSelector } from 'src/tokens/selectors' import { addStandbyTransaction } from 'src/transactions/actions' import { sendAndMonitorTransaction } from 'src/transactions/saga' import { sendTransaction } from 'src/transactions/send' @@ -76,16 +83,16 @@ export function* transferToEscrow(action: EscrowTransferPaymentAction) { Logger.debug(TAG + '@transferToEscrow', 'Begin transfer to escrow') try { ValoraAnalytics.track(EscrowEvents.escrow_transfer_start) - const { phoneHashDetails, amount, currency, feeInfo, context } = action + const { phoneHashDetails, amount, tokenAddress, feeInfo, context } = action const { phoneHash, pepper } = phoneHashDetails - const [contractKit, walletAddress]: [ContractKit, string] = yield all([ + const [kit, walletAddress]: [ContractKit, string] = yield all([ call(getContractKit), call(getConnectedUnlockedAccount), ]) - const [stableTokenWrapper, escrowWrapper]: [StableTokenWrapper, EscrowWrapper] = yield all([ - call(getTokenContract, currency), - call([contractKit.contracts, contractKit.contracts.getEscrow]), + const [tokenContract, escrowWrapper]: [Contract, EscrowWrapper] = yield all([ + call(getERC20TokenContract, tokenAddress), + call([kit.contracts, kit.contracts.getEscrow]), ]) const escrowPaymentIds: string[] = yield call( @@ -103,10 +110,20 @@ export function* transferToEscrow(action: EscrowTransferPaymentAction) { throw Error('Could not generate a unique paymentId for escrow. Should never happen') } + const tokens: TokenBalance[] = yield select(tokensListSelector) + const tokenInfo = tokens.find((token) => token.address === tokenAddress) + if (!tokenInfo) { + throw Error(`Couldnt find token info for address ${tokenAddress}. Should never happen`) + } // Approve a transfer of funds to the Escrow contract. + const convertedAmount: string = yield call(tokenAmountInSmallestUnit, amount, tokenAddress) + Logger.debug(TAG + '@transferToEscrow', 'Approving escrow transfer') - const convertedAmount = contractKit.connection.web3.utils.toWei(amount.toFixed(WEI_DECIMALS)) - const approvalTx = stableTokenWrapper.approve(escrowWrapper.address, convertedAmount) + + const approvalTx = toTransactionObject( + kit.connection, + tokenContract.methods.approve(escrowWrapper.address, convertedAmount) + ) const approvalReceipt: CeloTxReceipt = yield call( sendTransaction, @@ -121,15 +138,10 @@ export function* transferToEscrow(action: EscrowTransferPaymentAction) { // Tranfser the funds to the Escrow contract. Logger.debug(TAG + '@transferToEscrow', 'Transfering to escrow') - yield call( - registerStandbyTransaction, - context, - amount.toFixed(WEI_DECIMALS), - escrowWrapper.address - ) + yield call(registerStandbyTransaction, context, convertedAmount, escrowWrapper.address) const transferTx = escrowWrapper.transfer( phoneHash, - stableTokenWrapper.address, + tokenAddress, convertedAmount, ESCROW_PAYMENT_EXPIRY_SECONDS, paymentId, diff --git a/packages/mobile/src/fiatExchanges/saga.test.ts b/packages/mobile/src/fiatExchanges/saga.test.ts index 607c6d93f15..c9145ffe3bb 100644 --- a/packages/mobile/src/fiatExchanges/saga.test.ts +++ b/packages/mobile/src/fiatExchanges/saga.test.ts @@ -16,8 +16,8 @@ import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { AddressRecipient } from 'src/recipients/recipient' import { - sendPaymentOrInvite, sendPaymentOrInviteFailure, + sendPaymentOrInviteLegacy, sendPaymentOrInviteSuccess, } from 'src/send/actions' import { NewTransactionsInFeedAction } from 'src/transactions/actions' @@ -72,7 +72,7 @@ describe(watchBidaliPaymentRequests, () => { ) ) .dispatch( - sendPaymentOrInvite( + sendPaymentOrInviteLegacy( amount, expectedCurrency, 'Some description (TEST_CHARGE_ID)', @@ -119,7 +119,7 @@ describe(watchBidaliPaymentRequests, () => { ) ) .dispatch( - sendPaymentOrInvite( + sendPaymentOrInviteLegacy( amount, Currency.Dollar, 'Some description (TEST_CHARGE_ID)', diff --git a/packages/mobile/src/fiatExchanges/saga.ts b/packages/mobile/src/fiatExchanges/saga.ts index cda1ffc6153..68c0072fc86 100644 --- a/packages/mobile/src/fiatExchanges/saga.ts +++ b/packages/mobile/src/fiatExchanges/saga.ts @@ -68,14 +68,13 @@ function* bidaliPaymentRequest({ while (true) { const { cancel } = yield race({ - sendStart: take(SendActions.SEND_PAYMENT_OR_INVITE), + sendStart: take(SendActions.SEND_PAYMENT_OR_INVITE_LEGACY), cancel: take( (action: AppActionTypes) => action.type === AppActions.ACTIVE_SCREEN_CHANGED && action.activeScreen === Screens.BidaliScreen ), }) - if (cancel) { Logger.debug(`${TAG}@bidaliPaymentRequest`, 'Cancelled') yield call(onCancelled) diff --git a/packages/mobile/src/invite/saga.test.ts b/packages/mobile/src/invite/saga.test.ts index fbfa8af53d6..e73175e71a0 100644 --- a/packages/mobile/src/invite/saga.test.ts +++ b/packages/mobile/src/invite/saga.test.ts @@ -8,10 +8,9 @@ import i18n from 'src/i18n' import { storeInviteeData } from 'src/invite/actions' import { initiateEscrowTransfer, sendInvite } from 'src/invite/saga' import { transactionConfirmed } from 'src/transactions/actions' -import { Currency } from 'src/utils/currencies' import { getConnectedUnlockedAccount, waitWeb3LastBlock } from 'src/web3/saga' import { createMockStore } from 'test/utils' -import { mockAccount, mockE164Number, mockInviteDetails } from 'test/values' +import { mockAccount, mockCusdAddress, mockE164Number, mockInviteDetails } from 'test/values' const mockReceipt: CeloTxReceipt = { status: true, @@ -66,11 +65,11 @@ describe(sendInvite, () => { it('sends an invite as expected', async () => { i18n.t = jest.fn((key) => key) - await expectSaga(sendInvite, mockE164Number, AMOUNT_TO_SEND, Currency.Dollar) + await expectSaga(sendInvite, mockE164Number, AMOUNT_TO_SEND, AMOUNT_TO_SEND, mockCusdAddress) .provide([ [call(waitWeb3LastBlock), true], [call(getConnectedUnlockedAccount), mockAccount], - [call(initiateEscrowTransfer, mockE164Number, AMOUNT_TO_SEND, Currency.Dollar), undefined], + [call(initiateEscrowTransfer, mockE164Number, AMOUNT_TO_SEND, mockCusdAddress), undefined], ]) .withState(state) .dispatch(storeInviteeData(mockInviteDetails)) @@ -79,7 +78,7 @@ describe(sendInvite, () => { expect(i18n.t).toHaveBeenCalledWith('sendFlow7:inviteWithEscrowedPayment', { amount: AMOUNT_TO_SEND.toFixed(2).toString(), - currency: 'global:celoDollars', + token: 'cUSD', link: DYNAMIC_DOWNLOAD_LINK, }) expect(Share.share).toHaveBeenCalledWith({ message: 'sendFlow7:inviteWithEscrowedPayment' }) diff --git a/packages/mobile/src/invite/saga.ts b/packages/mobile/src/invite/saga.ts index 00c03b1c796..f17f6dc4135 100644 --- a/packages/mobile/src/invite/saga.ts +++ b/packages/mobile/src/invite/saga.ts @@ -1,8 +1,7 @@ import { PhoneNumberHashDetails } from '@celo/identity/lib/odis/phone-number-identifier' import BigNumber from 'bignumber.js' import { Share } from 'react-native' -import { generateSecureRandom } from 'react-native-securerandom' -import { call, put } from 'redux-saga/effects' +import { call, put, select } from 'redux-saga/effects' import { showError } from 'src/alert/actions' import { InviteEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' @@ -13,14 +12,12 @@ import { getEscrowTxGas } from 'src/escrow/saga' import { calculateFee, FeeInfo } from 'src/fees/saga' import i18n from 'src/i18n' import { fetchPhoneHashPrivate } from 'src/identity/privateHashing' -import { InviteDetails, storeInviteeData } from 'src/invite/actions' -import { createInviteCode } from 'src/invite/utils' +import { TokenBalance } from 'src/tokens/reducer' +import { tokensListSelector } from 'src/tokens/selectors' import { waitForTransactionWithId } from 'src/transactions/saga' import { newTransactionContext } from 'src/transactions/types' import { Currency } from 'src/utils/currencies' import Logger from 'src/utils/Logger' -import { getWeb3 } from 'src/web3/contracts' -import Web3 from 'web3' const TAG = 'invite/saga' export const REDEEM_INVITE_TIMEOUT = 1.5 * 60 * 1000 // 1.5 minutes @@ -48,59 +45,40 @@ export async function getInviteFee( export function* sendInvite( e164Number: string, amount: BigNumber, - currency: Currency, + usdAmount: BigNumber, + tokenAddress: string, feeInfo?: FeeInfo ) { try { ValoraAnalytics.track(InviteEvents.invite_start, { - escrowIncluded: true, amount: amount.toString(), + tokenAddress, + usdAmount: usdAmount.toString(), }) - const web3: Web3 = yield call(getWeb3) - const randomness: Uint8Array = yield call(generateSecureRandom, 64) - const temporaryWalletAccount = web3.eth.accounts.create( - Buffer.from(randomness).toString('ascii') - ) - const temporaryAddress = temporaryWalletAccount.address - const inviteCode = createInviteCode(temporaryWalletAccount.privateKey) - - const link = DYNAMIC_DOWNLOAD_LINK - - const messageProp = amount - ? 'sendFlow7:inviteWithEscrowedPayment' - : 'sendFlow7:inviteWithoutPayment' - const message = i18n.t(messageProp, { + const tokens: TokenBalance[] = yield select(tokensListSelector) + const tokenInfo = tokens.find((token) => token.address === tokenAddress) + if (!tokenInfo) { + throw new Error(`Token with address ${tokenAddress} not found`) + } + const message = i18n.t('sendFlow7:inviteWithEscrowedPayment', { amount: amount.toFixed(2), - currency: - currency === Currency.Dollar ? i18n.t('global:celoDollars') : i18n.t('global:celoEuros'), - link, + token: tokenInfo.symbol, + link: DYNAMIC_DOWNLOAD_LINK, }) - - const inviteDetails: InviteDetails = { - timestamp: Date.now(), - e164Number, - tempWalletAddress: temporaryAddress.toLowerCase(), - tempWalletPrivateKey: temporaryWalletAccount.privateKey, - tempWalletRedeemed: false, // no logic in place to toggle this yet - inviteCode, - inviteLink: link, - } - - // Store the Temp Address locally so we know which transactions were invites - yield put(storeInviteeData(inviteDetails)) - - yield call(initiateEscrowTransfer, e164Number, amount, currency, undefined, feeInfo) + yield call(initiateEscrowTransfer, e164Number, amount, tokenAddress, feeInfo) yield call(Share.share, { message }) ValoraAnalytics.track(InviteEvents.invite_complete, { - escrowIncluded: true, - amount: amount?.toString(), + amount: amount.toString(), + tokenAddress, + usdAmount: usdAmount.toString(), }) } catch (e) { ValoraAnalytics.track(InviteEvents.invite_error, { - escrowIncluded: true, error: e.message, - amount: amount?.toString(), + amount: amount.toString(), + tokenAddress, + usdAmount: usdAmount.toString(), }) Logger.error(TAG, 'Send invite error: ', e) throw e @@ -110,23 +88,13 @@ export function* sendInvite( export function* initiateEscrowTransfer( e164Number: string, amount: BigNumber, - currency: Currency, - temporaryAddress?: string, + tokenAddress: string, feeInfo?: FeeInfo ) { const context = newTransactionContext(TAG, 'Escrow funds') try { const phoneHashDetails: PhoneNumberHashDetails = yield call(fetchPhoneHashPrivate, e164Number) - yield put( - transferEscrowedPayment( - phoneHashDetails, - amount, - currency, - context, - temporaryAddress, - feeInfo - ) - ) + yield put(transferEscrowedPayment(phoneHashDetails, amount, tokenAddress, context, feeInfo)) yield call(waitForTransactionWithId, context.id) Logger.debug(TAG + '@sendInviteSaga', 'Escrowed money to new wallet') } catch (e) { diff --git a/packages/mobile/src/send/SendConfirmation.test.tsx b/packages/mobile/src/send/SendConfirmation.test.tsx index 077791bd2ed..a7bcdc024df 100644 --- a/packages/mobile/src/send/SendConfirmation.test.tsx +++ b/packages/mobile/src/send/SendConfirmation.test.tsx @@ -11,6 +11,7 @@ import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { RootState } from 'src/redux/reducers' +import { sendPaymentOrInvite } from 'src/send/actions' import SendConfirmation from 'src/send/SendConfirmation' import { getGasPrice } from 'src/web3/gas' import { @@ -240,8 +241,39 @@ describe('SendConfirmation', () => { const { getByTestId, queryAllByTestId } = renderScreen({}, mockInviteScreenProps) expect(queryAllByTestId('InviteAndSendModal')[0].props.visible).toBe(false) - // Fire event press not working here so instead we call the onClick directly - getByTestId('ConfirmButton').props.onClick() + + fireEvent.press(getByTestId('ConfirmButton')) + expect(queryAllByTestId('InviteAndSendModal')[0].props.visible).toBe(true) }) + + it('dispatches an action when the confirm button is pressed', async () => { + const { store, getByTestId } = renderScreen({}) + + expect(store.getActions().length).toEqual(0) + + fireEvent.press(getByTestId('ConfirmButton')) + + const { + route: { + params: { + transactionData: { inputAmount, tokenAddress, recipient }, + }, + }, + } = mockScreenProps + expect(store.getActions()).toEqual( + expect.arrayContaining([ + sendPaymentOrInvite( + inputAmount, + tokenAddress, + inputAmount.multipliedBy(1.33), // 1.33 is the default local currency exchange rate in tests + inputAmount, + '', + recipient, + undefined, + false + ), + ]) + ) + }) }) diff --git a/packages/mobile/src/send/SendConfirmation.tsx b/packages/mobile/src/send/SendConfirmation.tsx index 3a8dbcaf987..166a6dee5c2 100644 --- a/packages/mobile/src/send/SendConfirmation.tsx +++ b/packages/mobile/src/send/SendConfirmation.tsx @@ -10,6 +10,7 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet, Text, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' +import { useDispatch } from 'react-redux' import { SendEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import CommentTextInput from 'src/components/CommentTextInput' @@ -39,6 +40,7 @@ import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { getDisplayName } from 'src/recipients/recipient' import useSelector from 'src/redux/useSelector' +import { sendPaymentOrInvite } from 'src/send/actions' import { isSendingSelector } from 'src/send/selectors' import { useInputAmounts } from 'src/send/SendAmount' import DisconnectBanner from 'src/shared/DisconnectBanner' @@ -77,6 +79,7 @@ function SendConfirmation(props: Props) { const addressToDataEncryptionKey = useSelector(addressToDataEncryptionKeySelector) const secureSendPhoneNumberMapping = useSelector(secureSendPhoneNumberMappingSelector) const isSending = useSelector(isSendingSelector) + const fromModal = props.route.name === Screens.SendConfirmationModal const localCurrencyCode = useSelector(getLocalCurrencyCode) const { localAmount, tokenAmount, usdAmount } = useInputAmounts( inputAmount.toString(), @@ -84,6 +87,8 @@ function SendConfirmation(props: Props) { tokenAddress ) + const dispatch = useDispatch() + const isInvite = !recipient.address const { loading: feeLoading, error: feeError, result: feeInfo } = useEstimateGasFee( isInvite ? FeeType.INVITE : FeeType.SEND, @@ -191,19 +196,18 @@ function SendConfirmation(props: Props) { commentLength: comment.length, }) - // TODO: Send - // dispatch( - // sendPaymentOrInvite( - // amount, - // currency, - // finalComment, - // recipient, - // recipientAddress, - // feeInfo, - // firebasePendingRequestUid, - // fromModal - // ) - // ) + dispatch( + sendPaymentOrInvite( + tokenAmount, + tokenAddress, + localAmount, + usdAmount, + comment, + recipient, + feeInfo, + fromModal + ) + ) } return ( diff --git a/packages/mobile/src/send/SendConfirmationLegacy.tsx b/packages/mobile/src/send/SendConfirmationLegacy.tsx index a621ce12301..d0bb71c9f51 100644 --- a/packages/mobile/src/send/SendConfirmationLegacy.tsx +++ b/packages/mobile/src/send/SendConfirmationLegacy.tsx @@ -59,7 +59,7 @@ import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { getDisplayName } from 'src/recipients/recipient' import { isAppConnected } from 'src/redux/selectors' -import { sendPaymentOrInvite } from 'src/send/actions' +import { sendPaymentOrInviteLegacy } from 'src/send/actions' import { isSendingSelector } from 'src/send/selectors' import { getConfirmationInput } from 'src/send/utils' import DisconnectBanner from 'src/shared/DisconnectBanner' @@ -205,7 +205,7 @@ function SendConfirmationLegacy(props: Props) { }) dispatch( - sendPaymentOrInvite( + sendPaymentOrInviteLegacy( amount, currency, finalComment, diff --git a/packages/mobile/src/send/actions.ts b/packages/mobile/src/send/actions.ts index b53b2958719..01fd8e67b40 100644 --- a/packages/mobile/src/send/actions.ts +++ b/packages/mobile/src/send/actions.ts @@ -17,6 +17,7 @@ export enum Actions { BARCODE_DETECTED = 'SEND/BARCODE_DETECTED', QRCODE_SHARE = 'SEND/QRCODE_SHARE', SEND_PAYMENT_OR_INVITE = 'SEND/SEND_PAYMENT_OR_INVITE', + SEND_PAYMENT_OR_INVITE_LEGACY = 'SEND/SEND_PAYMENT_OR_INVITE_LEGACY', SEND_PAYMENT_OR_INVITE_SUCCESS = 'SEND/SEND_PAYMENT_OR_INVITE_SUCCESS', SEND_PAYMENT_OR_INVITE_FAILURE = 'SEND/SEND_PAYMENT_OR_INVITE_FAILURE', UPDATE_LAST_USED_CURRENCY = 'SEND/UPDATE_LAST_USED_CURRENCY', @@ -37,8 +38,8 @@ export interface ShareQRCodeAction { qrCodeSvg: SVG } -export interface SendPaymentOrInviteAction { - type: Actions.SEND_PAYMENT_OR_INVITE +export interface SendPaymentOrInviteActionLegacy { + type: Actions.SEND_PAYMENT_OR_INVITE_LEGACY amount: BigNumber currency: Currency comment: string @@ -49,6 +50,18 @@ export interface SendPaymentOrInviteAction { fromModal: boolean } +export interface SendPaymentOrInviteAction { + type: Actions.SEND_PAYMENT_OR_INVITE + amount: BigNumber + tokenAddress: string + amountInLocalCurrency: BigNumber + usdAmount: BigNumber + comment: string + recipient: Recipient + feeInfo?: FeeInfo + fromModal: boolean +} + export interface SendPaymentOrInviteSuccessAction { type: Actions.SEND_PAYMENT_OR_INVITE_SUCCESS amount: BigNumber @@ -72,6 +85,7 @@ export type ActionTypes = | HandleBarcodeDetectedAction | ShareQRCodeAction | SendPaymentOrInviteAction + | SendPaymentOrInviteActionLegacy | SendPaymentOrInviteSuccessAction | SendPaymentOrInviteFailureAction | UpdateLastUsedCurrencyAction @@ -97,7 +111,7 @@ export const shareQRCode = (qrCodeSvg: SVG): ShareQRCodeAction => ({ qrCodeSvg, }) -export const sendPaymentOrInvite = ( +export const sendPaymentOrInviteLegacy = ( amount: BigNumber, currency: Currency, comment: string, @@ -106,8 +120,8 @@ export const sendPaymentOrInvite = ( feeInfo: FeeInfo | undefined, firebasePendingRequestUid: string | null | undefined, fromModal: boolean -): SendPaymentOrInviteAction => ({ - type: Actions.SEND_PAYMENT_OR_INVITE, +): SendPaymentOrInviteActionLegacy => ({ + type: Actions.SEND_PAYMENT_OR_INVITE_LEGACY, amount, currency, comment, @@ -118,6 +132,27 @@ export const sendPaymentOrInvite = ( fromModal, }) +export const sendPaymentOrInvite = ( + amount: BigNumber, + tokenAddress: string, + amountInLocalCurrency: BigNumber, + usdAmount: BigNumber, + comment: string, + recipient: Recipient, + feeInfo: FeeInfo | undefined, + fromModal: boolean +): SendPaymentOrInviteAction => ({ + type: Actions.SEND_PAYMENT_OR_INVITE, + amount, + tokenAddress, + amountInLocalCurrency, + usdAmount, + comment, + recipient, + feeInfo, + fromModal, +}) + export const sendPaymentOrInviteSuccess = ( amount: BigNumber ): SendPaymentOrInviteSuccessAction => ({ diff --git a/packages/mobile/src/send/reducers.ts b/packages/mobile/src/send/reducers.ts index 7d09ea72cee..11ef6ae628e 100644 --- a/packages/mobile/src/send/reducers.ts +++ b/packages/mobile/src/send/reducers.ts @@ -53,6 +53,7 @@ export const sendReducer = ( } } case Actions.SEND_PAYMENT_OR_INVITE: + case Actions.SEND_PAYMENT_OR_INVITE_LEGACY: return { ...storeLatestRecentReducer(state, action.recipient), isSending: true, diff --git a/packages/mobile/src/send/saga.test.ts b/packages/mobile/src/send/saga.test.ts index 1c304e8d201..bdddfe93d68 100644 --- a/packages/mobile/src/send/saga.test.ts +++ b/packages/mobile/src/send/saga.test.ts @@ -9,6 +9,7 @@ import { ErrorMessages } from 'src/app/ErrorMessages' import { validateRecipientAddressSuccess } from 'src/identity/actions' import { encryptComment } from 'src/identity/commentEncryption' import { e164NumberToAddressSelector, E164NumberToAddressType } from 'src/identity/reducer' +import { sendInvite } from 'src/invite/saga' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { urlFromUriData } from 'src/qrcode/schema' @@ -19,17 +20,33 @@ import { HandleBarcodeDetectedAction, QrCode, SendPaymentOrInviteAction, + SendPaymentOrInviteActionLegacy, } from 'src/send/actions' -import { sendPaymentOrInviteSaga, watchQrCodeDetections } from 'src/send/saga' +import { + sendPaymentOrInviteSaga, + sendPaymentOrInviteSagaLegacy, + watchQrCodeDetections, +} from 'src/send/saga' +import { getERC20TokenContract } from 'src/tokens/saga' +import { sendAndMonitorTransaction } from 'src/transactions/saga' import { Currency } from 'src/utils/currencies' -import { getConnectedAccount, unlockAccount, UnlockResult } from 'src/web3/saga' +import { + getConnectedAccount, + getConnectedUnlockedAccount, + unlockAccount, + UnlockResult, +} from 'src/web3/saga' import { currentAccountSelector } from 'src/web3/selectors' +import { createMockStore } from 'test/utils' import { mockAccount, mockAccount2Invite, mockAccountInvite, + mockContract, + mockCusdAddress, mockE164Number, mockE164NumberInvite, + mockInvitableRecipient, mockName, mockQrCodeData, mockQrCodeData2, @@ -42,6 +59,10 @@ jest.mock('src/utils/time', () => ({ clockInSync: () => true, })) +jest.mock('src/invite/saga', () => ({ + sendInvite: jest.fn(), +})) + const mockE164NumberToAddress: E164NumberToAddressType = { [mockE164NumberInvite]: [mockAccountInvite, mockAccount2Invite], } @@ -236,11 +257,11 @@ describe(watchQrCodeDetections, () => { }) }) -describe(sendPaymentOrInviteSaga, () => { +describe(sendPaymentOrInviteSagaLegacy, () => { it('fails if user cancels PIN input', async () => { const account = '0x000123' - const sendPaymentOrInviteAction: SendPaymentOrInviteAction = { - type: Actions.SEND_PAYMENT_OR_INVITE, + const sendPaymentOrInviteAction: SendPaymentOrInviteActionLegacy = { + type: Actions.SEND_PAYMENT_OR_INVITE_LEGACY, amount: new BigNumber(10), currency: Currency.Dollar, comment: '', @@ -248,7 +269,7 @@ describe(sendPaymentOrInviteSaga, () => { firebasePendingRequestUid: null, fromModal: false, } - await expectSaga(sendPaymentOrInviteSaga, sendPaymentOrInviteAction) + await expectSaga(sendPaymentOrInviteSagaLegacy, sendPaymentOrInviteAction) .provide([ [call(getConnectedAccount), account], [matchers.call.fn(unlockAccount), UnlockResult.CANCELED], @@ -259,8 +280,8 @@ describe(sendPaymentOrInviteSaga, () => { it('uploads symmetric keys if transaction sent successfully', async () => { const account = '0x000123' - const sendPaymentOrInviteAction: SendPaymentOrInviteAction = { - type: Actions.SEND_PAYMENT_OR_INVITE, + const sendPaymentOrInviteAction: SendPaymentOrInviteActionLegacy = { + type: Actions.SEND_PAYMENT_OR_INVITE_LEGACY, amount: new BigNumber(10), currency: Currency.Dollar, comment: '', @@ -269,12 +290,88 @@ describe(sendPaymentOrInviteSaga, () => { firebasePendingRequestUid: null, fromModal: false, } - await expectSaga(sendPaymentOrInviteSaga, sendPaymentOrInviteAction) + await expectSaga(sendPaymentOrInviteSagaLegacy, sendPaymentOrInviteAction) + .withState(createMockStore({}).getState()) + .provide([ + [call(getConnectedAccount), account], + [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], + [select(currentAccountSelector), account], + [call(encryptComment, 'asdf', 'asdf', 'asdf', true), 'Asdf'], + ]) + .call.fn(giveProfileAccess) + .run() + }) +}) + +describe(sendPaymentOrInviteSaga, () => { + const amount = new BigNumber(10) + const sendAction: SendPaymentOrInviteAction = { + type: Actions.SEND_PAYMENT_OR_INVITE, + amount, + tokenAddress: mockCusdAddress, + amountInLocalCurrency: amount.multipliedBy(1.33), + usdAmount: amount, + comment: '', + recipient: mockQRCodeRecipient, + fromModal: false, + } + + it('sends a payment successfully', async () => { + await expectSaga(sendPaymentOrInviteSaga, sendAction) + .withState(createMockStore({}).getState()) + .provide([ + [call(getConnectedUnlockedAccount), mockAccount], + [call(encryptComment, 'asdf', 'asdf', 'asdf', true), 'Asdf'], + [call(getERC20TokenContract, mockCusdAddress), mockContract], + ]) + .call.fn(sendAndMonitorTransaction) + .run() + + expect(mockContract.methods.transfer).toHaveBeenCalledWith( + mockQRCodeRecipient.address, + amount.times(1e18).toFixed(0) + ) + }) + + it('sends an invite successfully', async () => { + await expectSaga(sendPaymentOrInviteSaga, { + ...sendAction, + recipient: mockInvitableRecipient, + }) + .withState(createMockStore({}).getState()) + .provide([[call(getConnectedUnlockedAccount), mockAccount]]) + .run() + + expect(sendInvite).toHaveBeenCalledWith( + mockInvitableRecipient.e164PhoneNumber, + amount, + amount, + mockCusdAddress, + undefined + ) + }) + + it('fails if user cancels PIN input', async () => { + const account = '0x000123' + await expectSaga(sendPaymentOrInviteSaga, sendAction) + .provide([ + [call(getConnectedAccount), account], + [matchers.call.fn(unlockAccount), UnlockResult.CANCELED], + ]) + .put(showError(ErrorMessages.PIN_INPUT_CANCELED)) + .run() + }) + + it('uploads symmetric keys if transaction sent successfully', async () => { + const account = '0x000123' + await expectSaga(sendPaymentOrInviteSaga, sendAction) + .withState(createMockStore({}).getState()) .provide([ [call(getConnectedAccount), account], [matchers.call.fn(unlockAccount), UnlockResult.SUCCESS], [select(currentAccountSelector), account], [call(encryptComment, 'asdf', 'asdf', 'asdf', true), 'Asdf'], + [call(getERC20TokenContract, mockCusdAddress), mockContract], ]) .call.fn(giveProfileAccess) .run() diff --git a/packages/mobile/src/send/saga.ts b/packages/mobile/src/send/saga.ts index 54ca784a167..af47d28c2e6 100644 --- a/packages/mobile/src/send/saga.ts +++ b/packages/mobile/src/send/saga.ts @@ -1,3 +1,6 @@ +import { CeloTransactionObject, Contract, toTransactionObject } from '@celo/connect' +import { ContractKit } from '@celo/contractkit' +import { CeloTokenWrapper } from '@celo/contractkit/lib/wrappers/CeloTokenWrapper' import BigNumber from 'bignumber.js' import { call, put, select, spawn, take, takeLeading } from 'redux-saga/effects' import { giveProfileAccess } from 'src/account/profileInfo' @@ -19,23 +22,32 @@ import { Actions, HandleBarcodeDetectedAction, SendPaymentOrInviteAction, + SendPaymentOrInviteActionLegacy, sendPaymentOrInviteFailure, sendPaymentOrInviteSuccess, ShareQRCodeAction, } from 'src/send/actions' import { transferStableToken } from 'src/stableToken/actions' +import { TokenBalance } from 'src/tokens/reducer' import { BasicTokenTransfer, createTokenTransferTransaction, getCurrencyAddress, + getERC20TokenContract, + getTokenContractFromAddress, + tokenAmountInSmallestUnit, } from 'src/tokens/saga' +import { tokensByCurrencySelector } from 'src/tokens/selectors' +import { sendAndMonitorTransaction } from 'src/transactions/saga' import { newTransactionContext } from 'src/transactions/types' import { Currency } from 'src/utils/currencies' import Logger from 'src/utils/Logger' +import { getContractKit } from 'src/web3/contracts' import { getRegisterDekTxGas } from 'src/web3/dataEncryptionKey' import { getConnectedUnlockedAccount } from 'src/web3/saga' import { currentAccountSelector } from 'src/web3/selectors' import { estimateGas } from 'src/web3/utils' +import * as utf8 from 'utf8' const TAG = 'send/saga' @@ -138,7 +150,7 @@ export function* watchQrCodeShare() { } } -function* sendPayment( +function* sendPaymentLegacy( recipientAddress: string, amount: BigNumber, comment: string, @@ -188,17 +200,124 @@ function* sendPayment( txId: context.id, recipientAddress, amount: amount.toString(), - currency, + tokenAddress: currency, + usdAmount: '', }) yield call(giveProfileAccess, recipientAddress) } catch (error) { - Logger.error(`${TAG}/sendPayment`, 'Could not send payment', error) + Logger.error(`${TAG}/sendPaymentLegacy`, 'Could not send payment', error.message) ValoraAnalytics.track(SendEvents.send_tx_error, { error: error.message }) throw error } } -export function* sendPaymentOrInviteSaga({ +function* buildSendTx( + tokenAddress: string, + amount: BigNumber, + recipientAddress: string, + comment: string +) { + const contract: Contract = yield call(getERC20TokenContract, tokenAddress) + const coreContract: CeloTokenWrapper | undefined = yield call( + getTokenContractFromAddress, + tokenAddress + ) + const convertedAmount: string = yield call(tokenAmountInSmallestUnit, amount, tokenAddress) + + const kit: ContractKit = yield call(getContractKit) + return coreContract + ? coreContract.transferWithComment(recipientAddress, convertedAmount, utf8.encode(comment)) + : toTransactionObject( + kit.connection, + contract.methods.transfer(recipientAddress, convertedAmount) + ) +} + +function* sendPayment( + recipientAddress: string, + amount: BigNumber, + amountInLocalCurrency: BigNumber, + usdAmount: BigNumber, + tokenAddress: string, + comment: string, + feeInfo?: FeeInfo +) { + const context = newTransactionContext(TAG, 'Send payment') + + try { + ValoraAnalytics.track(SendEvents.send_tx_start) + + const userAddress: string = yield call(getConnectedUnlockedAccount) + + const encryptedComment: string = yield call( + encryptComment, + comment, + recipientAddress, + userAddress, + true + ) + + Logger.debug( + TAG, + 'Transferring token', + context.description ?? 'No description', + context.id, + tokenAddress, + amount, + feeInfo ? JSON.stringify(feeInfo) : 'undefined' + ) + + // TODO: Add temporary tx to feed. + // yield put( + // addStandbyTransaction({ + // context, + // type: TokenTransactionType.Sent, + // comment, + // status: TransactionStatus.Pending, + // value: amount.toString(), + // tokenAddress, + // timestamp: Math.floor(Date.now() / 1000), + // address: recipientAddress, + // }) + // ) + + const tx: CeloTransactionObject = yield call( + buildSendTx, + tokenAddress, + amount, + recipientAddress, + encryptedComment + ) + + yield call( + sendAndMonitorTransaction, + tx, + userAddress, + context, + undefined, + feeInfo?.currency, + feeInfo?.gas?.toNumber(), + feeInfo?.gasPrice + ) + + ValoraAnalytics.track(SendEvents.send_tx_complete, { + txId: context.id, + recipientAddress, + amount: amount.toString(), + usdAmount: usdAmount.toString(), + tokenAddress, + }) + yield call(giveProfileAccess, recipientAddress) + } catch (error) { + 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* sendPaymentOrInviteSagaLegacy({ amount, currency, comment, @@ -207,14 +326,28 @@ export function* sendPaymentOrInviteSaga({ feeInfo, firebasePendingRequestUid, fromModal, -}: SendPaymentOrInviteAction) { +}: SendPaymentOrInviteActionLegacy) { try { yield call(getConnectedUnlockedAccount) + const tokenByCurrency: Record = yield select( + tokensByCurrencySelector + ) + const tokenInfo = tokenByCurrency[currency] + if (!tokenInfo) { + throw new Error(`No token info found for ${currency}`) + } if (recipientAddress) { - yield call(sendPayment, recipientAddress, amount, comment, currency, feeInfo) + yield call(sendPaymentLegacy, recipientAddress, amount, comment, currency, feeInfo) } else if (recipientHasNumber(recipient)) { - yield call(sendInvite, recipient.e164PhoneNumber, amount, currency, feeInfo) + yield call( + sendInvite, + recipient.e164PhoneNumber, + amount, + amount.multipliedBy(tokenInfo.usdPrice), + tokenInfo.address, + feeInfo + ) } if (firebasePendingRequestUid) { @@ -234,6 +367,52 @@ export function* sendPaymentOrInviteSaga({ } } +export function* watchSendPaymentOrInviteLegacy() { + yield takeLeading(Actions.SEND_PAYMENT_OR_INVITE_LEGACY, sendPaymentOrInviteSagaLegacy) +} + +export function* sendPaymentOrInviteSaga({ + amount, + tokenAddress, + amountInLocalCurrency, + usdAmount, + comment, + recipient, + feeInfo, + fromModal, +}: SendPaymentOrInviteAction) { + try { + yield call(getConnectedUnlockedAccount) + if (recipient.address) { + yield call( + sendPayment, + recipient.address, + amount, + amountInLocalCurrency, + usdAmount, + tokenAddress, + comment, + feeInfo + ) + } else if (recipientHasNumber(recipient)) { + yield call(sendInvite, recipient.e164PhoneNumber, amount, usdAmount, tokenAddress, feeInfo) + } else { + throw new Error('') + } + + if (fromModal) { + navigateBack() + } else { + navigateHome() + } + + yield put(sendPaymentOrInviteSuccess(amount)) + } catch (e) { + yield put(showErrorOrFallback(e, ErrorMessages.SEND_PAYMENT_FAILED)) + yield put(sendPaymentOrInviteFailure()) + } +} + export function* watchSendPaymentOrInvite() { yield takeLeading(Actions.SEND_PAYMENT_OR_INVITE, sendPaymentOrInviteSaga) } @@ -242,4 +421,5 @@ export function* sendSaga() { yield spawn(watchQrCodeDetections) yield spawn(watchQrCodeShare) yield spawn(watchSendPaymentOrInvite) + yield spawn(watchSendPaymentOrInviteLegacy) } diff --git a/packages/mobile/src/tokens/e2eTokens.ts b/packages/mobile/src/tokens/e2eTokens.ts new file mode 100644 index 00000000000..dbfab8bcd07 --- /dev/null +++ b/packages/mobile/src/tokens/e2eTokens.ts @@ -0,0 +1,38 @@ +import { StoredTokenBalances } from 'src/tokens/reducer' + +// alfajores addresses +const cUSD = '0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1' +const cEUR = '0x10c892A6EC43a53E45D0B916B4b7D383B1b78C0F' +const CELO = '0xF194afDf50B03e69Bd7D057c1Aa9e10c9954E4C9' + +export function e2eTokens(): StoredTokenBalances { + return { + [cUSD]: { + address: cUSD, + decimals: 18, + imageUrl: '', + name: 'Celo Dollars', + symbol: 'cUSD', + usdPrice: '1', + balance: null, + }, + [cEUR]: { + address: cEUR, + decimals: 18, + imageUrl: '', + name: 'Celo Euros', + symbol: 'cEUR', + usdPrice: '1.18', + balance: null, + }, + [CELO]: { + address: CELO, + decimals: 18, + imageUrl: '', + name: 'Celo native token', + symbol: 'CELO', + usdPrice: '6.5', + balance: null, + }, + } +} diff --git a/packages/mobile/src/tokens/saga.test.ts b/packages/mobile/src/tokens/saga.test.ts index 1bb98453903..6b33f943a92 100644 --- a/packages/mobile/src/tokens/saga.test.ts +++ b/packages/mobile/src/tokens/saga.test.ts @@ -1,9 +1,11 @@ +import BigNumber from 'bignumber.js' import { expectSaga } from 'redux-saga-test-plan' import { call, select } from 'redux-saga/effects' import { readOnceFromFirebase } from 'src/firebase/firebase' import { setTokenBalances, StoredTokenBalance } from 'src/tokens/reducer' -import { getERC20TokenBalance, importTokenInfo } from 'src/tokens/saga' +import { getERC20TokenBalance, importTokenInfo, tokenAmountInSmallestUnit } from 'src/tokens/saga' import { walletAddressSelector } from 'src/web3/selectors' +import { createMockStore } from 'test/utils' import { mockAccount, mockTokenBalances, mockTokenBalances2 } from 'test/values' const firebaseTokenInfo: StoredTokenBalance[] = [ @@ -54,3 +56,33 @@ describe(importTokenInfo, () => { .run() }) }) + +describe(tokenAmountInSmallestUnit, () => { + const mockAddress = '0xMockAddress' + + it('map to token amount successfully', async () => { + await expectSaga(tokenAmountInSmallestUnit, new BigNumber(10), mockAddress) + .withState( + createMockStore({ + tokens: { + tokenBalances: { + [mockAddress]: { + address: mockAddress, + decimals: 5, + }, + }, + }, + }).getState() + ) + .returns('1000000') + .run() + }) + + it('throw error if token doenst have info', async () => { + await expect( + expectSaga(tokenAmountInSmallestUnit, new BigNumber(10), mockAddress) + .withState(createMockStore({}).getState()) + .run() + ).rejects.toThrowError(`Couldnt find token info for address ${mockAddress}.`) + }) +}) diff --git a/packages/mobile/src/tokens/saga.ts b/packages/mobile/src/tokens/saga.ts index 8a73a3401d9..654186f3a5d 100644 --- a/packages/mobile/src/tokens/saga.ts +++ b/packages/mobile/src/tokens/saga.ts @@ -11,11 +11,18 @@ import { AppEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import { TokenTransactionType } from 'src/apollo/types' import { ErrorMessages } from 'src/app/ErrorMessages' -import { WALLET_BALANCE_UPPER_BOUND } from 'src/config' +import { isE2EEnv, WALLET_BALANCE_UPPER_BOUND } from 'src/config' import { FeeInfo } from 'src/fees/saga' import { readOnceFromFirebase } from 'src/firebase/firebase' import { WEI_PER_TOKEN } from 'src/geth/consts' -import { setTokenBalances, StoredTokenBalance, StoredTokenBalances } from 'src/tokens/reducer' +import { e2eTokens } from 'src/tokens/e2eTokens' +import { + setTokenBalances, + StoredTokenBalance, + StoredTokenBalances, + TokenBalance, +} from 'src/tokens/reducer' +import { tokensListSelector } from 'src/tokens/selectors' import { addStandbyTransaction, removeStandbyTransaction } from 'src/transactions/actions' import { sendAndMonitorTransaction } from 'src/transactions/saga' import { TransactionContext, TransactionStatus } from 'src/transactions/types' @@ -284,10 +291,13 @@ export function* fetchReadableTokenBalance(address: string, token: StoredTokenBa } export function* importTokenInfo() { - const tokens: StoredTokenBalance[] = yield call(readOnceFromFirebase, 'tokensInfo') + // In e2e environment we use a static token list since we can't access Firebase. + const tokens: StoredTokenBalances = isE2EEnv + ? e2eTokens() + : yield call(readOnceFromFirebase, 'tokensInfo') const address: string = yield select(walletAddressSelector) const fetchedTokenBalances: StoredTokenBalance[] = yield all( - tokens.map((token) => call(fetchReadableTokenBalance, address, token)) + Object.values(tokens).map((token) => call(fetchReadableTokenBalance, address, token!)) ) const balances: StoredTokenBalances = {} for (const tokenBalance of fetchedTokenBalances) { @@ -296,6 +306,17 @@ export function* importTokenInfo() { yield put(setTokenBalances(balances)) } +export function* tokenAmountInSmallestUnit(amount: BigNumber, tokenAddress: string) { + const tokens: TokenBalance[] = yield select(tokensListSelector) + const tokenInfo = tokens.find((token) => token.address === tokenAddress) + if (!tokenInfo) { + throw Error(`Couldnt find token info for address ${tokenAddress}.`) + } + + const decimalFactor = new BigNumber(10).pow(tokenInfo.decimals) + return amount.multipliedBy(decimalFactor).toFixed(0) +} + export function* tokensSaga() { yield spawn(importTokenInfo) } diff --git a/packages/mobile/test/values.ts b/packages/mobile/test/values.ts index ed5668e0ce0..ac1f2f5ff2b 100644 --- a/packages/mobile/test/values.ts +++ b/packages/mobile/test/values.ts @@ -473,3 +473,10 @@ export const mockTokenBalances2 = { balance: null, }, } + +export const mockContract = { + methods: { + approve: jest.fn(), + transfer: jest.fn(), + }, +}