diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 8cf9fb19d41..5c02efa94fc 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@celo/client": "9575a01", + "@celo/contractkit": "^0.1.6", "@celo/react-components": "1.0.0", "@celo/react-native-sms-retriever": "git+https://github.com/celo-org/react-native-sms-retriever#d3a2fdb", "@celo/utils": "^0.1.1", diff --git a/packages/mobile/rn-cli.config.js b/packages/mobile/rn-cli.config.js index f17315d3837..b9dd6fcf600 100644 --- a/packages/mobile/rn-cli.config.js +++ b/packages/mobile/rn-cli.config.js @@ -9,7 +9,7 @@ const root = path.resolve(cwd, '../..') const escapedRoot = escapeStringRegexp(root) const rnRegex = new RegExp(`${escapedRoot}\/node_modules\/(react-native)\/.*`) const celoRegex = new RegExp( - `${escapedRoot}\/packages\/(?!mobile|utils|walletkit|react-components).*` + `${escapedRoot}\/packages\/(?!mobile|utils|walletkit|contractkit|react-components).*` ) const nestedRnRegex = new RegExp(`.*\/node_modules\/.*\/node_modules\/(react-native)\/.*`) const componentsRnRegex = new RegExp(`.*react-components\/node_modules\/(react-native)\/.*`) @@ -24,6 +24,7 @@ module.exports = { extraNodeModules: { ...nodeLibs, 'crypto-js': path.resolve(cwd, 'node_modules/crypto-js'), + fs: require.resolve('react-native-fs'), 'isomorphic-fetch': require.resolve('cross-fetch'), net: require.resolve('react-native-tcp'), 'react-native': path.resolve(cwd, 'node_modules/react-native'), diff --git a/packages/mobile/src/exchange/Activity.tsx b/packages/mobile/src/exchange/Activity.tsx index 7e430ec9f3f..4331d0e8d97 100644 --- a/packages/mobile/src/exchange/Activity.tsx +++ b/packages/mobile/src/exchange/Activity.tsx @@ -6,6 +6,7 @@ import { RootState } from 'src/redux/reducers' import { resetStandbyTransactions } from 'src/transactions/actions' import { StandbyTransaction, TransactionTypes } from 'src/transactions/reducer' import TransactionFeed, { FeedType } from 'src/transactions/TransactionFeed' +import Logger from 'src/utils/Logger' import { currentAccountSelector } from 'src/web3/selectors' interface DispatchProps { @@ -29,6 +30,7 @@ function filterToExchangeTxs(tx: StandbyTransaction) { export class Activity extends React.Component { componentDidMount() { this.props.resetStandbyTransactions() + Logger.info('Activity feed', JSON.stringify(transactionQuery)) } render() { diff --git a/packages/mobile/src/exchange/actions.ts b/packages/mobile/src/exchange/actions.ts index ae1201ce8d7..1e0e6c2b39c 100644 --- a/packages/mobile/src/exchange/actions.ts +++ b/packages/mobile/src/exchange/actions.ts @@ -1,38 +1,6 @@ -import { - ContractUtils, - getExchangeContract, - getGoldTokenContract, - getStableTokenContract, -} from '@celo/walletkit' -import { Exchange as ExchangeType } from '@celo/walletkit/types/Exchange' -import { GoldToken as GoldTokenType } from '@celo/walletkit/types/GoldToken' -import { StableToken as StableTokenType } from '@celo/walletkit/types/StableToken' import BigNumber from 'bignumber.js' -import { call, put, select } from 'redux-saga/effects' -import { showError } from 'src/alert/actions' -import { ErrorMessages } from 'src/app/ErrorMessages' import { ExchangeRatePair } from 'src/exchange/reducer' import { CURRENCY_ENUM } from 'src/geth/consts' -import { RootState } from 'src/redux/reducers' -import { - addStandbyTransaction, - generateStandbyTransactionId, - removeStandbyTransaction, -} from 'src/transactions/actions' -import { TransactionStatus, TransactionTypes } from 'src/transactions/reducer' -import { sendAndMonitorTransaction } from 'src/transactions/saga' -import { sendTransaction } from 'src/transactions/send' -import { getRateForMakerToken, getTakerAmount } from 'src/utils/currencyExchange' -import { roundDown } from 'src/utils/formatting' -import Logger from 'src/utils/Logger' -import { web3 } from 'src/web3/contracts' -import { getConnectedAccount, getConnectedUnlockedAccount } from 'src/web3/saga' -import * as util from 'util' - -const TAG = 'exchange/actions' -const LARGE_DOLLARS_SELL_AMOUNT_IN_WEI = new BigNumber(1000 * 1000000000000000000) // To estimate exchange rate from exchange contract -const LARGE_GOLD_SELL_AMOUNT_IN_WEI = new BigNumber(100 * 1000000000000000000) -const EXCHANGE_DIFFERENCE_TOLERATED = 0.01 // Maximum difference between actual and displayed takerAmount export enum Actions { FETCH_EXCHANGE_RATE = 'EXCHANGE/FETCH_EXCHANGE_RATE', @@ -70,223 +38,4 @@ export const exchangeTokens = ( makerToken, makerAmount, }) - export type ActionTypes = SetExchangeRateAction | ExchangeTokensAction - -export function* doFetchExchangeRate(makerAmount?: BigNumber, makerToken?: CURRENCY_ENUM) { - Logger.debug(TAG, 'Calling @doFetchExchangeRate') - - let dollarMakerAmount: BigNumber - let goldMakerAmount: BigNumber - if (makerAmount && makerToken === CURRENCY_ENUM.GOLD) { - dollarMakerAmount = LARGE_DOLLARS_SELL_AMOUNT_IN_WEI - goldMakerAmount = makerAmount - } else if (makerAmount && makerToken === CURRENCY_ENUM.DOLLAR) { - dollarMakerAmount = makerAmount - goldMakerAmount = LARGE_GOLD_SELL_AMOUNT_IN_WEI - } else { - dollarMakerAmount = LARGE_DOLLARS_SELL_AMOUNT_IN_WEI - goldMakerAmount = LARGE_GOLD_SELL_AMOUNT_IN_WEI - if (makerAmount || makerToken) { - Logger.debug( - TAG, - 'Using default makerAmount estimates. Need both makerAmount and makerToken to override. ' - ) - } - } - - try { - yield call(getConnectedAccount) - - const dollarMakerExchangeRate: BigNumber = yield call( - ContractUtils.getExchangeRate, - web3, - CURRENCY_ENUM.DOLLAR, - new BigNumber(dollarMakerAmount) - ) - const goldMakerExchangeRate: BigNumber = yield call( - ContractUtils.getExchangeRate, - web3, - CURRENCY_ENUM.GOLD, - new BigNumber(goldMakerAmount) - ) - - if (!dollarMakerExchangeRate || !goldMakerExchangeRate) { - Logger.error(TAG, 'Invalid exchange rate') - throw new Error('Invalid exchange rate') - } - - Logger.debug( - TAG, - `Retrieved exchange rate: - ${dollarMakerExchangeRate.toString()} gold per dollar, estimated at ${dollarMakerAmount} - ${goldMakerExchangeRate.toString()} dollar per gold, estimated at ${goldMakerAmount}` - ) - - yield put( - setExchangeRate({ - goldMaker: goldMakerExchangeRate.toString(), - dollarMaker: dollarMakerExchangeRate.toString(), - }) - ) - } catch (error) { - Logger.error(TAG, 'Error fetching exchange rate', error) - yield put(showError(ErrorMessages.EXCHANGE_RATE_FAILED)) - } -} - -export function* exchangeGoldAndStableTokens(action: ExchangeTokensAction) { - Logger.debug(`${TAG}@exchangeGoldAndStableTokens`, 'Exchanging gold and stable CURRENCY_ENUM') - const { makerToken, makerAmount } = action - Logger.debug(TAG, `Exchanging ${makerAmount.toString()} of CURRENCY_ENUM ${makerToken}`) - let txId: string | null = null - try { - const account: string = yield call(getConnectedUnlockedAccount) - const exchangeRatePair: ExchangeRatePair = yield select( - (state: RootState) => state.exchange.exchangeRatePair - ) - const exchangeRate = getRateForMakerToken(exchangeRatePair, makerToken) - if (!exchangeRate) { - Logger.error(TAG, 'Invalid exchange rate from exchange contract') - return - } - if (exchangeRate.isZero()) { - Logger.error(TAG, 'Cannot do exchange with rate of 0. Stopping.') - throw new Error('Invalid exchange rate') - } - - txId = yield createStandbyTx(makerToken, makerAmount, exchangeRate, account) - - const goldTokenContract: GoldTokenType = yield call(getGoldTokenContract, web3) - const stableTokenContract: StableTokenType = yield call(getStableTokenContract, web3) - const exchangeContract: ExchangeType = yield call(getExchangeContract, web3) - - const makerTokenContract = - makerToken === CURRENCY_ENUM.DOLLAR ? stableTokenContract : goldTokenContract - - const convertedMakerAmount: BigNumber = yield call( - convertToContractDecimals, - makerAmount, - makerTokenContract - ) - const sellGold = makerToken === CURRENCY_ENUM.GOLD - - const updatedExchangeRate: BigNumber = yield call( - // Updating with actual makerAmount, rather than conservative estimate displayed - ContractUtils.getExchangeRate, - web3, - makerToken, - convertedMakerAmount - ) - - const exceedsExpectedSize = - makerToken === CURRENCY_ENUM.GOLD - ? convertedMakerAmount.isGreaterThan(LARGE_GOLD_SELL_AMOUNT_IN_WEI) - : convertedMakerAmount.isGreaterThan(LARGE_DOLLARS_SELL_AMOUNT_IN_WEI) - - if (exceedsExpectedSize) { - Logger.error( - TAG, - `Displayed exchange rate was estimated with a smaller makerAmount than actual ${convertedMakerAmount}` - ) - // Note that exchange will still go through if makerAmount difference is within EXCHANGE_DIFFERENCE_TOLERATED - } - - // Ensure the user gets makerAmount at least as good as displayed (rounded to EXCHANGE_DIFFERENCE_TOLERATED) - const minimumTakerAmount = getTakerAmount(makerAmount, exchangeRate).minus( - EXCHANGE_DIFFERENCE_TOLERATED - ) - const updatedTakerAmount = getTakerAmount(makerAmount, updatedExchangeRate) - if (minimumTakerAmount.isGreaterThan(updatedTakerAmount)) { - Logger.error( - TAG, - `Not receiving enough ${makerToken} due to change in exchange rate. Exchange failed.` - ) - yield put(showError(ErrorMessages.EXCHANGE_RATE_CHANGE)) - return - } - - const takerTokenContract = - makerToken === CURRENCY_ENUM.DOLLAR ? goldTokenContract : stableTokenContract - const convertedTakerAmount: BigNumber = roundDown( - yield call(convertToContractDecimals, minimumTakerAmount, takerTokenContract), - 0 - ) - Logger.debug( - TAG, - `Will receive at least ${convertedTakerAmount} - wei for ${convertedMakerAmount} wei of ${makerToken}` - ) - - let approveTx - if (makerToken === CURRENCY_ENUM.GOLD) { - approveTx = goldTokenContract.methods.approve( - exchangeContract._address, - convertedMakerAmount.toString() - ) - } else if (makerToken === CURRENCY_ENUM.DOLLAR) { - approveTx = stableTokenContract.methods.approve( - exchangeContract._address, - convertedMakerAmount.toString() - ) - } else { - Logger.error(TAG, `Unexpected maker token ${makerToken}`) - return - } - yield call(sendTransaction, approveTx, account, TAG, 'approval') - Logger.debug(TAG, `Transaction approved: ${util.inspect(approveTx.arguments)}`) - - const tx = exchangeContract.methods.exchange( - convertedMakerAmount.toString(), - convertedTakerAmount.toString(), - sellGold - ) - - if (!txId) { - Logger.error(TAG, 'No txId. Did not exchange.') - return - } - yield call(sendAndMonitorTransaction, txId, tx, account) - } catch (error) { - Logger.error(TAG, 'Error doing exchange', error) - if (txId) { - yield put(removeStandbyTransaction(txId)) - } - - if (error.message === ErrorMessages.INCORRECT_PIN) { - yield put(showError(ErrorMessages.INCORRECT_PIN)) - } else { - yield put(showError(ErrorMessages.EXCHANGE_FAILED)) - } - } -} - -function* createStandbyTx( - makerToken: CURRENCY_ENUM, - makerAmount: BigNumber, - exchangeRate: BigNumber, - account: string -) { - const takerAmount = getTakerAmount(makerAmount, exchangeRate, 2) - const txId = generateStandbyTransactionId(account) - yield put( - addStandbyTransaction({ - id: txId, - type: TransactionTypes.EXCHANGE, - status: TransactionStatus.Pending, - inSymbol: makerToken, - inValue: makerAmount.toString(), - outSymbol: makerToken === CURRENCY_ENUM.DOLLAR ? CURRENCY_ENUM.GOLD : CURRENCY_ENUM.DOLLAR, - outValue: takerAmount.toString(), - timestamp: Math.floor(Date.now() / 1000), - }) - ) - return txId -} - -async function convertToContractDecimals(value: BigNumber | string | number, contract: any) { - // TODO(Rossy): Move this function to SDK and cache this decimals amount - const decimals = await contract.methods.decimals().call() - const one = new BigNumber(10).pow(new BigNumber(decimals).toNumber()) - return one.times(value) -} diff --git a/packages/mobile/src/exchange/reducer.ts b/packages/mobile/src/exchange/reducer.ts index 03a9f571e54..9e91ddc65e8 100644 --- a/packages/mobile/src/exchange/reducer.ts +++ b/packages/mobile/src/exchange/reducer.ts @@ -1,4 +1,5 @@ import { Actions, ActionTypes } from 'src/exchange/actions' +import { RootState } from 'src/redux/reducers' export interface ExchangeRatePair { goldMaker: string // number of dollarTokens received for one goldToken @@ -13,6 +14,8 @@ const initialState = { exchangeRatePair: null, } +export const exchangeRatePairSelector = (state: RootState) => state.exchange.exchangeRatePair + export const reducer = (state: State | undefined = initialState, action: ActionTypes): State => { switch (action.type) { case Actions.SET_EXCHANGE_RATE: diff --git a/packages/mobile/src/exchange/saga.ts b/packages/mobile/src/exchange/saga.ts index 0183b7a7c92..326027667fc 100644 --- a/packages/mobile/src/exchange/saga.ts +++ b/packages/mobile/src/exchange/saga.ts @@ -1,5 +1,229 @@ -import { spawn, takeEvery, takeLatest } from 'redux-saga/effects' -import { Actions, doFetchExchangeRate, exchangeGoldAndStableTokens } from 'src/exchange/actions' +import { + ContractUtils, + getExchangeContract, + getGoldTokenContract, + getStableTokenContract, +} from '@celo/walletkit' +import { Exchange as ExchangeType } from '@celo/walletkit/types/Exchange' +import { GoldToken as GoldTokenType } from '@celo/walletkit/types/GoldToken' +import { StableToken as StableTokenType } from '@celo/walletkit/types/StableToken' +import BigNumber from 'bignumber.js' +import { all, call, put, select, spawn, takeEvery, takeLatest } from 'redux-saga/effects' +import { showError } from 'src/alert/actions' +import { ErrorMessages } from 'src/app/ErrorMessages' +import { Actions, ExchangeTokensAction, setExchangeRate } from 'src/exchange/actions' +import { ExchangeRatePair, exchangeRatePairSelector } from 'src/exchange/reducer' +import { CURRENCY_ENUM } from 'src/geth/consts' +import { convertToContractDecimals } from 'src/tokens/saga' +import { + addStandbyTransaction, + generateStandbyTransactionId, + removeStandbyTransaction, +} from 'src/transactions/actions' +import { TransactionStatus, TransactionTypes } from 'src/transactions/reducer' +import { sendAndMonitorTransaction } from 'src/transactions/saga' +import { sendTransaction } from 'src/transactions/send' +import { getRateForMakerToken, getTakerAmount } from 'src/utils/currencyExchange' +import { roundDown } from 'src/utils/formatting' +import Logger from 'src/utils/Logger' +import { contractKit, web3 } from 'src/web3/contracts' +import { getConnectedAccount, getConnectedUnlockedAccount } from 'src/web3/saga' +import * as util from 'util' + +const TAG = 'exchange/saga' + +const LARGE_DOLLARS_SELL_AMOUNT_IN_WEI = new BigNumber(1000 * 1000000000000000000) // To estimate exchange rate from exchange contract +const LARGE_GOLD_SELL_AMOUNT_IN_WEI = new BigNumber(100 * 1000000000000000000) +const EXCHANGE_DIFFERENCE_TOLERATED = 0.01 // Maximum difference between actual and displayed takerAmount + +export function* doFetchExchangeRate(makerAmount?: BigNumber, makerToken?: CURRENCY_ENUM) { + Logger.debug(TAG, 'Calling @doFetchExchangeRate') + + // If makerAmount and makerToken are given, use them to estimate the exchange rate, + // as exchange rate depends on amount sold. Else default to preset large sell amount. + const goldMakerAmount = + makerAmount && makerToken === CURRENCY_ENUM.GOLD ? makerAmount : LARGE_GOLD_SELL_AMOUNT_IN_WEI + const dollarMakerAmount = + makerAmount && makerToken === CURRENCY_ENUM.DOLLAR + ? makerAmount + : LARGE_DOLLARS_SELL_AMOUNT_IN_WEI + + try { + yield call(getConnectedAccount) + const exchange = yield call([contractKit.contracts, contractKit.contracts.getExchange]) + + const [dollarMakerExchangeRate, goldMakerExchangeRate]: [BigNumber, BigNumber] = yield all([ + call([exchange, exchange.getUsdExchangeRate], dollarMakerAmount), + call([exchange, exchange.getGoldExchangeRate], goldMakerAmount), + ]) + + if (!dollarMakerExchangeRate || !goldMakerExchangeRate) { + Logger.error(TAG, 'Invalid exchange rate') + throw new Error('Invalid exchange rate') + } + + Logger.debug( + TAG, + `Retrieved exchange rate: + ${dollarMakerExchangeRate.toString()} gold per dollar, estimated at ${dollarMakerAmount} + ${goldMakerExchangeRate.toString()} dollar per gold, estimated at ${goldMakerAmount}` + ) + + yield put( + setExchangeRate({ + goldMaker: goldMakerExchangeRate.toString(), + dollarMaker: dollarMakerExchangeRate.toString(), + }) + ) + } catch (error) { + Logger.error(TAG, 'Error fetching exchange rate', error) + yield put(showError(ErrorMessages.EXCHANGE_RATE_FAILED)) + } +} + +export function* exchangeGoldAndStableTokens(action: ExchangeTokensAction) { + Logger.debug(`${TAG}@exchangeGoldAndStableTokens`, 'Exchanging gold and stable token') + const { makerToken, makerAmount } = action + Logger.debug(TAG, `Exchanging ${makerAmount.toString()} of token ${makerToken}`) + let txId: string | null = null + try { + const account: string = yield call(getConnectedUnlockedAccount) + const exchangeRatePair: ExchangeRatePair = yield select(exchangeRatePairSelector) + const exchangeRate = getRateForMakerToken(exchangeRatePair, makerToken) + if (!exchangeRate) { + Logger.error(TAG, 'Invalid exchange rate from exchange contract') + return + } + if (exchangeRate.isZero()) { + Logger.error(TAG, 'Cannot do exchange with rate of 0. Stopping.') + throw new Error('Invalid exchange rate') + } + + txId = yield createStandbyTx(makerToken, makerAmount, exchangeRate, account) + + const goldTokenContract: GoldTokenType = yield call(getGoldTokenContract, web3) + const stableTokenContract: StableTokenType = yield call(getStableTokenContract, web3) + const exchangeContract: ExchangeType = yield call(getExchangeContract, web3) + + const convertedMakerAmount: BigNumber = yield call( + convertToContractDecimals, + makerAmount, + makerToken + ) + const sellGold = makerToken === CURRENCY_ENUM.GOLD + + const updatedExchangeRate: BigNumber = yield call( + // Updating with actual makerAmount, rather than conservative estimate displayed + ContractUtils.getExchangeRate, + web3, + makerToken, + convertedMakerAmount + ) + + const exceedsExpectedSize = + makerToken === CURRENCY_ENUM.GOLD + ? convertedMakerAmount.isGreaterThan(LARGE_GOLD_SELL_AMOUNT_IN_WEI) + : convertedMakerAmount.isGreaterThan(LARGE_DOLLARS_SELL_AMOUNT_IN_WEI) + + if (exceedsExpectedSize) { + Logger.error( + TAG, + `Displayed exchange rate was estimated with a smaller makerAmount than actual ${convertedMakerAmount}` + ) + // Note that exchange will still go through if makerAmount difference is within EXCHANGE_DIFFERENCE_TOLERATED + } + + // Ensure the user gets makerAmount at least as good as displayed (rounded to EXCHANGE_DIFFERENCE_TOLERATED) + const minimumTakerAmount = getTakerAmount(makerAmount, exchangeRate).minus( + EXCHANGE_DIFFERENCE_TOLERATED + ) + const updatedTakerAmount = getTakerAmount(makerAmount, updatedExchangeRate) + if (minimumTakerAmount.isGreaterThan(updatedTakerAmount)) { + Logger.error( + TAG, + `Not receiving enough ${makerToken} due to change in exchange rate. Exchange failed.` + ) + yield put(showError(ErrorMessages.EXCHANGE_RATE_CHANGE)) + return + } + + const takerToken = + makerToken === CURRENCY_ENUM.DOLLAR ? CURRENCY_ENUM.GOLD : CURRENCY_ENUM.DOLLAR + const convertedTakerAmount: BigNumber = roundDown( + yield call(convertToContractDecimals, minimumTakerAmount, takerToken), + 0 + ) + Logger.debug( + TAG, + `Will receive at least ${convertedTakerAmount} + wei for ${convertedMakerAmount} wei of ${makerToken}` + ) + + let approveTx + if (makerToken === CURRENCY_ENUM.GOLD) { + approveTx = goldTokenContract.methods.approve( + exchangeContract._address, + convertedMakerAmount.toString() + ) + } else if (makerToken === CURRENCY_ENUM.DOLLAR) { + approveTx = stableTokenContract.methods.approve( + exchangeContract._address, + convertedMakerAmount.toString() + ) + } else { + Logger.error(TAG, `Unexpected maker token ${makerToken}`) + return + } + yield call(sendTransaction, approveTx, account, TAG, 'approval') + Logger.debug(TAG, `Transaction approved: ${util.inspect(approveTx.arguments)}`) + + const tx = exchangeContract.methods.exchange( + convertedMakerAmount.toString(), + convertedTakerAmount.toString(), + sellGold + ) + + if (!txId) { + Logger.error(TAG, 'No txId. Did not exchange.') + return + } + yield call(sendAndMonitorTransaction, txId, tx, account) + } catch (error) { + Logger.error(TAG, 'Error doing exchange', error) + if (txId) { + yield put(removeStandbyTransaction(txId)) + } + + if (error.message === ErrorMessages.INCORRECT_PIN) { + yield put(showError(ErrorMessages.INCORRECT_PIN)) + } else { + yield put(showError(ErrorMessages.EXCHANGE_FAILED)) + } + } +} + +function* createStandbyTx( + makerToken: CURRENCY_ENUM, + makerAmount: BigNumber, + exchangeRate: BigNumber, + account: string +) { + const takerAmount = getTakerAmount(makerAmount, exchangeRate, 2) + const txId = generateStandbyTransactionId(account) + yield put( + addStandbyTransaction({ + id: txId, + type: TransactionTypes.EXCHANGE, + status: TransactionStatus.Pending, + inSymbol: makerToken, + inValue: makerAmount.toString(), + outSymbol: makerToken === CURRENCY_ENUM.DOLLAR ? CURRENCY_ENUM.GOLD : CURRENCY_ENUM.DOLLAR, + outValue: takerAmount.toString(), + timestamp: Math.floor(Date.now() / 1000), + }) + ) + return txId +} export function* watchFetchExchangeRate() { yield takeLatest(Actions.FETCH_EXCHANGE_RATE, doFetchExchangeRate) diff --git a/packages/mobile/src/goldToken/saga.ts b/packages/mobile/src/goldToken/saga.ts index 06ebe9d00f8..8ea6a35274a 100644 --- a/packages/mobile/src/goldToken/saga.ts +++ b/packages/mobile/src/goldToken/saga.ts @@ -8,7 +8,7 @@ const tag = 'goldToken/saga' export const goldFetch = tokenFetchFactory({ actionName: Actions.FETCH_BALANCE, - contractGetter: getGoldTokenContract, + token: CURRENCY_ENUM.GOLD, actionCreator: setBalance, tag, }) diff --git a/packages/mobile/src/import/saga.ts b/packages/mobile/src/import/saga.ts index af836faec8b..7020a2e9dc2 100644 --- a/packages/mobile/src/import/saga.ts +++ b/packages/mobile/src/import/saga.ts @@ -1,5 +1,4 @@ import { ensureHexLeader } from '@celo/utils/src/signatureUtils' -import { getStableTokenContract } from '@celo/walletkit' import BigNumber from 'bignumber.js' import { validateMnemonic } from 'bip39' import { mnemonicToSeedHex } from 'react-native-bip39' @@ -7,6 +6,7 @@ import { call, put, spawn, takeLeading } from 'redux-saga/effects' import { setBackupCompleted } from 'src/account' import { showError } from 'src/alert/actions' import { ErrorMessages } from 'src/app/ErrorMessages' +import { CURRENCY_ENUM } from 'src/geth/consts' import { refreshAllBalances } from 'src/home/actions' import { Actions, @@ -47,7 +47,7 @@ export function* importBackupPhraseSaga({ phrase, useEmptyWallet }: ImportBackup const dollarBalance: BigNumber = yield call( fetchTokenBalanceWithRetry, - getStableTokenContract, + CURRENCY_ENUM.DOLLAR, backupAccount ) diff --git a/packages/mobile/src/invite/saga.test.ts b/packages/mobile/src/invite/saga.test.ts index 540793015e9..4287f6bac81 100644 --- a/packages/mobile/src/invite/saga.test.ts +++ b/packages/mobile/src/invite/saga.test.ts @@ -1,3 +1,4 @@ +import BigNumber from 'bignumber.js' import { FetchMock } from 'jest-fetch-mock' import { Linking } from 'react-native' import SendIntentAndroid from 'react-native-send-intent' @@ -20,12 +21,16 @@ import { waitWeb3LastBlock } from 'src/networkInfo/saga' import { fetchDollarBalance } from 'src/stableToken/actions' import { transactionConfirmed } from 'src/transactions/actions' import { getConnectedUnlockedAccount, getOrCreateAccount } from 'src/web3/saga' -import { createMockContract, createMockStore } from 'test/utils' +import { + createMockContract, + createMockStore, + mockContractKitBalance, + mockContractKitContract, +} from 'test/utils' import { mockAccount, mockE164Number, mockName } from 'test/values' const mockFetch = fetch as FetchMock const mockKey = '0x1129eb2fbccdc663f4923a6495c35b096249812b589f7c4cd1dba01e1edaf724' -const mockBalance = jest.fn(() => 10) jest.mock('@celo/walletkit', () => ({ ...jest.requireActual('@celo/walletkit'), @@ -33,7 +38,6 @@ jest.mock('@celo/walletkit', () => ({ createMockContract({ getAttestationRequestFee: Math.pow(10, 18) }), getStableTokenContract: jest.fn(async () => createMockContract({ - balanceOf: mockBalance, transfer: () => null, transferWithComment: () => null, decimals: () => '10', @@ -74,6 +78,11 @@ jest.mock('src/web3/contracts', () => ({ sha3: (x: any) => `a sha3 hash`, }, }, + contractKit: { + contracts: { + getStableToken: () => mockContractKitContract, + }, + }, isZeroSyncMode: () => false, })) @@ -123,13 +132,13 @@ describe(watchRedeemInvite, () => { }) beforeEach(() => { - mockBalance.mockReset() + mockContractKitBalance.mockReset() }) it('works with a valid private key and enough money on it', async () => { - mockBalance - .mockReturnValueOnce(10) // temp account - .mockReturnValueOnce(10) // new account + mockContractKitBalance + .mockReturnValueOnce(new BigNumber(10)) // temp account + .mockReturnValueOnce(new BigNumber(10)) // temp account await expectSaga(watchRedeemInvite) .provide([[call(waitWeb3LastBlock), true], [call(getOrCreateAccount), mockAccount]]) @@ -141,9 +150,9 @@ describe(watchRedeemInvite, () => { }) it('fails with a valid private key but unsuccessful transfer', async () => { - mockBalance - .mockReturnValueOnce(10) // temp account - .mockReturnValueOnce(0) // new account + mockContractKitBalance + .mockReturnValueOnce(new BigNumber(10)) // temp account + .mockReturnValueOnce(new BigNumber(0)) // new account await expectSaga(watchRedeemInvite) .provide([ @@ -159,9 +168,9 @@ describe(watchRedeemInvite, () => { }) it('fails with a valid private key but no money on key', async () => { - mockBalance - .mockReturnValueOnce(0) // temp account - .mockReturnValueOnce(0) // current account + mockContractKitBalance + .mockReturnValueOnce(new BigNumber(0)) // temp account + .mockReturnValueOnce(new BigNumber(0)) // current account await expectSaga(watchRedeemInvite) .provide([[call(waitWeb3LastBlock), true], [call(getOrCreateAccount), mockAccount]]) @@ -173,7 +182,7 @@ describe(watchRedeemInvite, () => { }) it('fails with error creating account', async () => { - mockBalance.mockReturnValueOnce(10) // temp account + mockContractKitBalance.mockReturnValueOnce(new BigNumber(10)) // temp account await expectSaga(watchRedeemInvite) .provide([ diff --git a/packages/mobile/src/invite/saga.ts b/packages/mobile/src/invite/saga.ts index f7ce136f64e..5df06e380e5 100644 --- a/packages/mobile/src/invite/saga.ts +++ b/packages/mobile/src/invite/saga.ts @@ -221,7 +221,7 @@ export function* doRedeemInvite(inviteCode: string) { Logger.debug(`TAG@doRedeemInvite`, 'Invite code contains temp account', tempAccount) const tempAccountBalanceWei: BigNumber = yield call( fetchTokenBalanceWithRetry, - getStableTokenContract, + CURRENCY_ENUM.DOLLAR, tempAccount ) if (tempAccountBalanceWei.isLessThanOrEqualTo(0)) { diff --git a/packages/mobile/src/stableToken/saga.test.ts b/packages/mobile/src/stableToken/saga.test.ts index d81baf75eb6..17629676130 100644 --- a/packages/mobile/src/stableToken/saga.test.ts +++ b/packages/mobile/src/stableToken/saga.test.ts @@ -1,5 +1,5 @@ import { CURRENCY_ENUM } from '@celo/utils/src/currencies' -import { getErc20Balance, getStableTokenContract } from '@celo/walletkit' +import BigNumber from 'bignumber.js' import { expectSaga } from 'redux-saga-test-plan' import { call } from 'redux-saga/effects' import { waitWeb3LastBlock } from 'src/networkInfo/saga' @@ -7,13 +7,19 @@ import { fetchDollarBalance, setBalance, transferStableToken } from 'src/stableT import { stableTokenFetch, stableTokenTransfer } from 'src/stableToken/saga' import { addStandbyTransaction, removeStandbyTransaction } from 'src/transactions/actions' import { TransactionStatus, TransactionTypes } from 'src/transactions/reducer' -import { createMockContract, createMockStore } from 'test/utils' +import { + createMockContract, + createMockStore, + mockContractKitBalance, + mockContractKitContract, +} from 'test/utils' import { mockAccount } from 'test/values' const now = Date.now() Date.now = jest.fn(() => now) const BALANCE = '45' +const BALANCE_IN_WEI = '450000000000' const TX_ID = '1234' const COMMENT = 'a comment' @@ -24,6 +30,14 @@ jest.mock('@celo/walletkit', () => ({ getErc20Balance: jest.fn(() => BALANCE), })) +jest.mock('src/web3/contracts', () => ({ + contractKit: { + contracts: { + getStableToken: () => mockContractKitContract, + }, + }, +})) + jest.mock('src/web3/actions', () => ({ ...jest.requireActual('src/web3/actions'), unlockAccount: jest.fn(async () => true), @@ -44,13 +58,14 @@ describe('stableToken saga', () => { jest.useRealTimers() it('should fetch the balance and put the new balance', async () => { + mockContractKitBalance.mockReturnValueOnce(new BigNumber(BALANCE_IN_WEI)) + await expectSaga(stableTokenFetch) .provide([[call(waitWeb3LastBlock), true]]) .withState(state) .dispatch(fetchDollarBalance()) .put(setBalance(BALANCE)) .run() - expect(getErc20Balance).toHaveBeenCalled() }) it('should add a standby transaction and dispatch a sendAndMonitorTransaction', async () => { @@ -93,16 +108,6 @@ describe('stableToken saga', () => { .run() }) - // TODO(cmcewen): Figure out how to mock this so we can get actual contract calls - it('should call the contract getter', async () => { - await expectSaga(stableTokenTransfer) - .provide([[call(waitWeb3LastBlock), true]]) - .withState(state) - .dispatch(TRANSFER_ACTION) - .run() - expect(getStableTokenContract).toHaveBeenCalled() - }) - it('should remove standby transaction when pin unlock fails', async () => { unlockAccount.mockImplementationOnce(async () => false) diff --git a/packages/mobile/src/stableToken/saga.ts b/packages/mobile/src/stableToken/saga.ts index 992811545af..e723b2c2100 100644 --- a/packages/mobile/src/stableToken/saga.ts +++ b/packages/mobile/src/stableToken/saga.ts @@ -8,7 +8,7 @@ const tag = 'stableToken/saga' export const stableTokenFetch = tokenFetchFactory({ actionName: Actions.FETCH_BALANCE, - contractGetter: getStableTokenContract, + token: CURRENCY_ENUM.DOLLAR, actionCreator: setBalance, tag, }) diff --git a/packages/mobile/src/tokens/saga.ts b/packages/mobile/src/tokens/saga.ts index 5fb7e9d9c45..60bd7c8c067 100644 --- a/packages/mobile/src/tokens/saga.ts +++ b/packages/mobile/src/tokens/saga.ts @@ -1,5 +1,5 @@ import { retryAsync } from '@celo/utils/src/async' -import { getErc20Balance, getGoldTokenContract, getStableTokenContract } from '@celo/walletkit' +import { getGoldTokenContract, getStableTokenContract } from '@celo/walletkit' import BigNumber from 'bignumber.js' import { call, put, take, takeEvery } from 'redux-saga/effects' import { showError } from 'src/alert/actions' @@ -11,31 +11,71 @@ import { addStandbyTransaction, removeStandbyTransaction } from 'src/transaction import { TransactionStatus, TransactionTypes } from 'src/transactions/reducer' import { sendAndMonitorTransaction } from 'src/transactions/saga' import Logger from 'src/utils/Logger' -import { web3 } from 'src/web3/contracts' -import { getConnectedAccount, getConnectedUnlockedAccount } from 'src/web3/saga' +import { contractKit, web3 } from 'src/web3/contracts' +import { getConnectedAccount, getConnectedUnlockedAccount, waitForWeb3Sync } from 'src/web3/saga' import * as utf8 from 'utf8' const TAG = 'tokens/saga' +// The number of wei that represent one unit in a contract +const contractWeiPerUnit: { [key in CURRENCY_ENUM]: BigNumber | null } = { + [CURRENCY_ENUM.GOLD]: null, + [CURRENCY_ENUM.DOLLAR]: null, +} + +async function getWeiPerUnit(token: CURRENCY_ENUM) { + let weiPerUnit = contractWeiPerUnit[token] + if (!weiPerUnit) { + const contract = await getTokenContract(token) + const decimals = await contract.decimals() + weiPerUnit = new BigNumber(10).pow(decimals) + contractWeiPerUnit[token] = weiPerUnit + } + return weiPerUnit +} + +export async function convertFromContractDecimals(value: BigNumber, token: CURRENCY_ENUM) { + const weiPerUnit = await getWeiPerUnit(token) + return value.dividedBy(weiPerUnit) +} + +export async function convertToContractDecimals(value: BigNumber, token: CURRENCY_ENUM) { + const weiPerUnit = await getWeiPerUnit(token) + return value.times(weiPerUnit) +} + +export async function getTokenContract(token: CURRENCY_ENUM) { + Logger.debug(TAG + '@getTokenContract', `Fetching contract for ${token}`) + await waitForWeb3Sync() + let tokenContract: any + switch (token) { + case CURRENCY_ENUM.GOLD: + tokenContract = await contractKit.contracts.getGoldToken() + break + case CURRENCY_ENUM.DOLLAR: + tokenContract = await contractKit.contracts.getStableToken() + break + default: + throw new Error(`Could not fetch contract for unknown token ${token}`) + } + return tokenContract +} + interface TokenFetchFactory { actionName: string - contractGetter: (web3: any) => any + token: CURRENCY_ENUM actionCreator: (balance: string) => any tag: string } -export function tokenFetchFactory({ - actionName, - contractGetter, - actionCreator, - tag, -}: TokenFetchFactory) { +export function tokenFetchFactory({ actionName, token, actionCreator, tag }: TokenFetchFactory) { function* tokenFetch() { try { Logger.debug(tag, 'Fetching balance') const account = yield call(getConnectedAccount) - const tokenContract = yield call(contractGetter, web3) - const balance = yield call(getErc20Balance, tokenContract, account, web3) + const tokenContract = yield call(getTokenContract, token) + const balanceInWei: BigNumber = yield call([tokenContract, tokenContract.balanceOf], account) + const balance: BigNumber = yield call(convertFromContractDecimals, balanceInWei, token) CeloAnalytics.track(CustomEventNames.fetch_balance) yield put(actionCreator(balance.toString())) } catch (error) { @@ -94,16 +134,14 @@ export async function createTransaction( return tx } -export async function fetchTokenBalanceWithRetry( - contractGetter: typeof getStableTokenContract | typeof getGoldTokenContract, - account: string -) { +export async function fetchTokenBalanceWithRetry(token: CURRENCY_ENUM, account: string) { Logger.debug(TAG + '@fetchTokenBalanceWithRetry', 'Checking account balance', account) - const tokenContract = await contractGetter(web3) + const tokenContract = await getTokenContract(token) // Retry needed here because it's typically the app's first tx and seems to fail on occasion - const tokenBalance = await retryAsync(tokenContract.methods.balanceOf(account).call, 3, []) - Logger.debug(TAG + '@fetchTokenBalanceWithRetry', 'Account balance', tokenBalance) - return new BigNumber(tokenBalance) + const balanceInWei = await retryAsync(tokenContract.balanceOf, 3, [account]) + const balance = await convertFromContractDecimals(balanceInWei, token) + Logger.debug(TAG + '@fetchTokenBalanceWithRetry', 'Account balance', balance.toString()) + return balance } export function tokenTransferFactory({ diff --git a/packages/mobile/src/web3/contracts.ts b/packages/mobile/src/web3/contracts.ts index 7bfd5d7d6ff..275bf2e891a 100644 --- a/packages/mobile/src/web3/contracts.ts +++ b/packages/mobile/src/web3/contracts.ts @@ -1,3 +1,4 @@ +import { newKitFromWeb3 } from '@celo/contractkit' import { addLocalAccount as web3utilsAddLocalAccount } from '@celo/walletkit' import { Platform } from 'react-native' import * as net from 'react-native-tcp' @@ -12,6 +13,7 @@ import { Provider } from 'web3/providers' const tag = 'web3/contracts' export const web3: Web3 = getWeb3() +export const contractKit = newKitFromWeb3(web3) export function isInitiallyZeroSyncMode() { return networkConfig.initiallyZeroSync diff --git a/packages/mobile/test/utils.ts b/packages/mobile/test/utils.ts index ea6074f3b8f..3c63b1d19a8 100644 --- a/packages/mobile/test/utils.ts +++ b/packages/mobile/test/utils.ts @@ -1,4 +1,5 @@ /* Utilities to facilitate testing */ +import BigNumber from 'bignumber.js' import configureMockStore from 'redux-mock-store' import { InitializationState } from 'src/geth/reducer' import i18n from 'src/i18n' @@ -15,6 +16,13 @@ import { export const sleep = (time: number) => new Promise((resolve) => setTimeout(() => resolve(true), time)) +// ContractKit test utils +export const mockContractKitBalance = jest.fn(() => new BigNumber(10)) +export const mockContractKitContract = { + balanceOf: mockContractKitBalance, + decimals: jest.fn(async () => '10'), +} + interface MockContract { methods: { [methodName: string]: MockMethod