Feature: #528 optimize network requests (#680)

* Generates a cache to avoid multiples getHumanFriendlyToken() for the same token address

* Adds etags implementation for transactions

* Caches outgoing and incoming safe transactions based on etag value

* Removes cachedSafeTransactions, cachedSafeIncommingTransactions

* Refactors getTokenInstance

* Avoid recreating tokens on fetchTokens() once we have them in redux

* Fixs error on catch

* Batch request tokens balances

* Fixs missing token names
Changes the tokens limit from 300 to 3000

* fix: failed to instantiate non-standard ERC-20 tokens

For the batchRequest of balances, we're just using the `balanceOf` method call. So having a simple ABI with that only method prevents errors with non-standard ERC-20 Tokens.

* Removes unnecessary action updateSafeThreshold
Removes unnecessary action fetchEtherBalance

* Updated comments in code
Replaces constant with directly dispatching action

* BatchRequest done right

* fix: invalid action name `savedToken` -> `saveToken`

* Renames getTokenInstance to getTokenInfos
Fixs first load of transactions are empty

* Move fetchTokenBalances to `Balances` and `SendModal` components

* fix: Incoming transaction type

Backend now changed the type from 'incoming' to one of: `'ERC721_TRANSFER', 'ERC20_TRANSFER', 'ETHER_TRANSFER'`

* fix: tokenInstance `symbol` and `decimal` extraction

* Fix property name `decimals` instead of `tokenDecimals`

* Standardize non-standard ERC20 tokens discovery

* fix: isStandardERC20

* Revert "Move fetchTokenBalances to `Balances` and `SendModal` components"

This reverts commit ed84bd92

* Fixs Typo INCOMING_TX_TYPES
Renames tokenInstance with localToken

* Renames getBatchBalances to getTokenBalances
Returns saved tokens instead of tokenInstance in getTokenInfos

* Remove promise returns

Co-authored-by: fernandomg <fernando.greco@gmail.com>
This commit is contained in:
Agustin Pane 2020-03-30 13:14:04 -03:00 committed by GitHub
parent 58130760c4
commit c73dafe3ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 248 additions and 56 deletions

View File

@ -9,7 +9,7 @@ const fetchTokenBalanceList = (safeAddress: string) => {
return axios.get(url, {
params: {
limit: 300,
limit: 3000,
},
})
}

View File

@ -9,7 +9,7 @@ const fetchTokenList = () => {
return axios.get(url, {
params: {
limit: 300,
limit: 3000,
},
})
}

View File

@ -10,8 +10,10 @@ import saveTokens from './saveTokens'
import { fetchTokenList } from '~/logic/tokens/api'
import { type TokenProps, makeToken } from '~/logic/tokens/store/model/token'
import { tokensSelector } from '~/logic/tokens/store/selectors'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { type GlobalState } from '~/store'
import { store } from '~/store/index'
import { ensureOnce } from '~/utils/singleton'
const createStandardTokenContract = async () => {
@ -37,12 +39,67 @@ const createERC721TokenContract = async () => {
return erc721Token
}
const OnlyBalanceToken = {
contractName: 'OnlyBalanceToken',
abi: [
{
constant: true,
inputs: [
{
name: 'owner',
type: 'address',
},
],
name: 'balanceOf',
outputs: [
{
name: '',
type: 'uint256',
},
],
payable: false,
stateMutability: 'view',
type: 'function',
},
{
constant: true,
inputs: [
{
name: 'owner',
type: 'address',
},
],
name: 'balances',
outputs: [
{
name: '',
type: 'uint256',
},
],
payable: false,
stateMutability: 'view',
type: 'function',
},
],
}
// For the `batchRequest` of balances, we're just using the `balanceOf` method call.
// So having a simple ABI only with `balanceOf` prevents errors
// when instantiating non-standard ERC-20 Tokens.
const createOnlyBalanceToken = () => {
const web3 = getWeb3()
const contract = new web3.eth.Contract(OnlyBalanceToken.abi)
return contract
}
export const getHumanFriendlyToken = ensureOnce(createHumanFriendlyTokenContract)
export const getStandardTokenContract = ensureOnce(createStandardTokenContract)
export const getERC721TokenContract = ensureOnce(createERC721TokenContract)
export const getOnlyBalanceToken = ensureOnce(createOnlyBalanceToken)
export const containsMethodByHash = async (contractAddress: string, methodHash: string) => {
const web3 = getWeb3()
const byteCode = await web3.eth.getCode(contractAddress)
@ -50,19 +107,54 @@ export const containsMethodByHash = async (contractAddress: string, methodHash:
return byteCode.indexOf(methodHash.replace('0x', '')) !== -1
}
export const fetchTokens = () => async (dispatch: ReduxDispatch<GlobalState>) => {
export const getTokenInfos = async (tokenAddress: string) => {
if (!tokenAddress) {
return null
}
const { tokens } = store.getState()
const localToken = tokens.get(tokenAddress)
// If the token is inside the store we return the store token
if (localToken) {
return localToken
}
// Otherwise we fetch it, save it to the store and return it
const tokenContract = await getHumanFriendlyToken()
const tokenInstance = await tokenContract.at(tokenAddress)
const [tokenSymbol, tokenDecimals, name] = await Promise.all([
tokenInstance.symbol(),
tokenInstance.decimals(),
tokenInstance.name(),
])
const savedToken = makeToken({
address: tokenAddress,
name: name ? name : tokenSymbol,
symbol: tokenSymbol,
decimals: tokenDecimals,
logoUri: '',
})
const newTokens = tokens.set(tokenAddress, savedToken)
store.dispatch(saveTokens(newTokens))
return savedToken
}
export const fetchTokens = () => async (dispatch: ReduxDispatch<GlobalState>, getState: Function) => {
try {
const currentSavedTokens = tokensSelector(getState())
const {
data: { results: tokenList },
} = await fetchTokenList()
if (currentSavedTokens && currentSavedTokens.size === tokenList.length) {
return
}
const tokens = List(tokenList.map((token: TokenProps) => makeToken(token)))
dispatch(saveTokens(tokens))
} catch (err) {
console.error('Error fetching token list', err)
return Promise.resolve()
}
}

View File

@ -21,7 +21,7 @@ import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import Span from '~/components/layout/Span'
import IncomingTxDescription from '~/routes/safe/components/Transactions/TxsTable/ExpandedTx/IncomingTxDescription'
import { INCOMING_TX_TYPE } from '~/routes/safe/store/models/incomingTransaction'
import { INCOMING_TX_TYPES } from '~/routes/safe/store/models/incomingTransaction'
import { type Owner } from '~/routes/safe/store/models/owner'
import { type Transaction } from '~/routes/safe/store/models/transaction'
@ -58,8 +58,8 @@ const ExpandedTx = ({
const [openModal, setOpenModal] = useState<OpenModal>(null)
const openApproveModal = () => setOpenModal('approveTx')
const closeModal = () => setOpenModal(null)
const thresholdReached = tx.type !== INCOMING_TX_TYPE && threshold <= tx.confirmations.size
const canExecute = tx.type !== INCOMING_TX_TYPE && nonce === tx.nonce
const thresholdReached = !INCOMING_TX_TYPES.includes(tx.type) && threshold <= tx.confirmations.size
const canExecute = !INCOMING_TX_TYPES.includes(tx.type) && nonce === tx.nonce
const cancelThresholdReached = !!cancelTx && threshold <= cancelTx.confirmations.size
const canExecuteCancel = nonce === tx.nonce
@ -76,7 +76,9 @@ const ExpandedTx = ({
<Block className={classes.expandedTxBlock}>
<Row>
<Col layout="column" xs={6}>
<Block className={cn(classes.txDataContainer, tx.type === INCOMING_TX_TYPE && classes.incomingTxBlock)}>
<Block
className={cn(classes.txDataContainer, INCOMING_TX_TYPES.includes(tx.type) && classes.incomingTxBlock)}
>
<Block align="left" className={classes.txData}>
<Bold className={classes.txHash}>Hash:</Bold>
{tx.executionTxHash ? <EtherScanLink cut={8} type="tx" value={tx.executionTxHash} /> : 'n/a'}
@ -89,7 +91,7 @@ const ExpandedTx = ({
<Bold>Fee: </Bold>
{tx.fee ? tx.fee : 'n/a'}
</Paragraph>
{tx.type === INCOMING_TX_TYPE ? (
{INCOMING_TX_TYPES.includes(tx.type) ? (
<>
<Paragraph noMargin>
<Bold>Created: </Bold>
@ -128,9 +130,9 @@ const ExpandedTx = ({
)}
</Block>
<Hairline />
{tx.type === INCOMING_TX_TYPE ? <IncomingTxDescription tx={tx} /> : <TxDescription tx={tx} />}
{INCOMING_TX_TYPES.includes(tx.type) ? <IncomingTxDescription tx={tx} /> : <TxDescription tx={tx} />}
</Col>
{tx.type !== INCOMING_TX_TYPE && (
{!INCOMING_TX_TYPES.includes(tx.type) && (
<OwnersColumn
cancelThresholdReached={cancelThresholdReached}
cancelTx={cancelTx}

View File

@ -9,7 +9,7 @@ import TxType from './TxType'
import { type Column } from '~/components/Table/TableHead'
import { type SortRow, buildOrderFieldFrom } from '~/components/Table/sorting'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { INCOMING_TX_TYPE, type IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction'
import { INCOMING_TX_TYPES, type IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction'
import { type Transaction } from '~/routes/safe/store/models/transaction'
export const TX_TABLE_ID = 'id'
@ -101,7 +101,7 @@ export const getTxTableData = (
const cancelTxsByNonce = cancelTxs.reduce((acc, tx) => acc.set(tx.nonce, tx), Map())
return transactions.map(tx => {
if (tx.type === INCOMING_TX_TYPE) {
if (INCOMING_TX_TYPES.includes(tx.type)) {
return getIncomingTxTableData(tx)
}

View File

@ -125,11 +125,12 @@ class SafeView extends React.Component<Props, State> {
fetchEtherBalance,
fetchTokenBalances,
fetchTransactions,
safe,
safeUrl,
} = this.props
checkAndUpdateSafeOwners(safeUrl)
fetchTokenBalances(safeUrl, activeTokens)
fetchEtherBalance(safeUrl)
fetchEtherBalance(safe)
fetchTransactions(safeUrl)
}

View File

@ -3,13 +3,17 @@ import type { Dispatch as ReduxDispatch } from 'redux'
import { getBalanceInEtherOf } from '~/logic/wallets/getWeb3'
import updateSafe from '~/routes/safe/store/actions/updateSafe'
import type { Safe } from '~/routes/safe/store/models/safe'
import { type GlobalState } from '~/store/index'
const fetchEtherBalance = (safeAddress: string) => async (dispatch: ReduxDispatch<GlobalState>) => {
const fetchEtherBalance = (safe: Safe) => async (dispatch: ReduxDispatch<GlobalState>) => {
try {
const ethBalance = await getBalanceInEtherOf(safeAddress)
const { address, ethBalance } = safe
const newEthBalance = await getBalanceInEtherOf(address)
dispatch(updateSafe({ address: safeAddress, ethBalance }))
if (newEthBalance !== ethBalance) {
dispatch(updateSafe({ address, newEthBalance }))
}
} catch (err) {
// eslint-disable-next-line
console.error('Error when fetching Ether balance:', err)

View File

@ -70,12 +70,16 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: ReduxDis
const remoteOwners = await gnosisSafe.getOwners()
// Converts from [ { address, ownerName} ] to address array
const localOwners = localSafe.owners.map(localOwner => localOwner.address)
const localThreshold = localSafe.threshold
// Updates threshold values
const threshold = await gnosisSafe.getThreshold()
localSafe.threshold = threshold.toNumber()
const remoteThreshold = await gnosisSafe.getThreshold()
localSafe.threshold = remoteThreshold.toNumber()
if (localThreshold !== remoteThreshold.toNumber()) {
dispatch(updateSafeThreshold({ safeAddress, threshold: remoteThreshold.toNumber() }))
}
dispatch(updateSafeThreshold({ safeAddress, threshold: threshold.toNumber() }))
// If the remote owners does not contain a local address, we remove that local owner
localOwners.forEach(localAddress => {
const remoteOwnerIndex = remoteOwners.findIndex(remoteAddress => sameAddress(remoteAddress, localAddress))

View File

@ -5,10 +5,61 @@ import type { Dispatch as ReduxDispatch } from 'redux'
import updateSafe from './updateSafe'
import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
import { getOnlyBalanceToken, getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
import { type Token } from '~/logic/tokens/store/model/token'
import { ETH_ADDRESS } from '~/logic/tokens/utils/tokenHelpers'
import { sameAddress } from '~/logic/wallets/ethAddresses'
import { ETHEREUM_NETWORK, getWeb3 } from '~/logic/wallets/getWeb3'
import { type GlobalState } from '~/store/index'
import { NETWORK } from '~/utils/constants'
// List of all the non-standard ERC20 tokens
const nonStandardERC20 = [
// DATAcoin
{ network: ETHEREUM_NETWORK.RINKEBY, address: '0x0cf0ee63788a0849fe5297f3407f701e122cc023' },
]
// This is done due to an issues with DATAcoin contract in Rinkeby
// https://rinkeby.etherscan.io/address/0x0cf0ee63788a0849fe5297f3407f701e122cc023#readContract
// It doesn't have a `balanceOf` method implemented.
const isStandardERC20 = (address: string): boolean => {
return !nonStandardERC20.find(token => sameAddress(address, token.address) && sameAddress(NETWORK, token.network))
}
const getTokenBalances = (tokens: List<Token>, safeAddress: string) => {
const web3 = getWeb3()
const batch = new web3.BatchRequest()
const safeTokens = tokens.toJS().filter(({ address }) => address !== ETH_ADDRESS)
const safeTokensBalances = safeTokens.map(({ address, decimals }: any) => {
const onlyBalanceToken = getOnlyBalanceToken()
onlyBalanceToken.options.address = address
// As a fallback, we're using `balances`
const method = isStandardERC20(address) ? 'balanceOf' : 'balances'
return new Promise(resolve => {
const request = onlyBalanceToken.methods[method](safeAddress).call.request((error, balance) => {
if (error) {
// if there's no balance, we log the error, but `resolve` with a default '0'
console.error('No balance method found', error)
resolve('0')
} else {
resolve({
address,
balance: new BigNumber(balance).div(`1e${decimals}`).toFixed(),
})
}
})
batch.add(request)
})
})
batch.execute()
return Promise.all(safeTokensBalances)
}
export const calculateBalanceOf = async (tokenAddress: string, safeAddress: string, decimals: number = 18) => {
if (tokenAddress === ETH_ADDRESS) {
@ -34,15 +85,7 @@ const fetchTokenBalances = (safeAddress: string, tokens: List<Token>) => async (
return
}
try {
const withBalances = await Promise.all(
tokens.map(async token => {
const balance = await calculateBalanceOf(token.address, safeAddress, token.decimals)
return {
address: token.address,
balance,
}
}),
)
const withBalances = await getTokenBalances(tokens, safeAddress)
const balances = Map().withMutations(map => {
withBalances.forEach(({ address, balance }) => {

View File

@ -11,7 +11,7 @@ import { decodeParamsFromSafeMethod } from '~/logic/contracts/methodIds'
import { buildIncomingTxServiceUrl } from '~/logic/safe/transactions/incomingTxHistory'
import { type TxServiceType, buildTxServiceUrl } from '~/logic/safe/transactions/txHistory'
import { getLocalSafe } from '~/logic/safe/utils'
import { getHumanFriendlyToken } from '~/logic/tokens/store/actions/fetchTokens'
import { getTokenInfos } from '~/logic/tokens/store/actions/fetchTokens'
import { ALTERNATIVE_TOKEN_ABI } from '~/logic/tokens/utils/alternativeAbi'
import {
DECIMALS_METHOD_HASH,
@ -111,9 +111,9 @@ export const buildTransactionFrom = async (safeAddress: string, tx: TxServiceMod
let refundSymbol = 'ETH'
let decimals = 18
if (tx.gasToken !== ZERO_ADDRESS) {
const gasToken = await (await getHumanFriendlyToken()).at(tx.gasToken)
refundSymbol = await gasToken.symbol()
decimals = await gasToken.decimals()
const gasToken = await getTokenInfos(tx.gasToken)
refundSymbol = gasToken.symbol
decimals = gasToken.decimals
}
const feeString = (tx.gasPrice * (tx.baseGas + tx.safeTxGas)).toString().padStart(decimals, 0)
@ -131,10 +131,10 @@ export const buildTransactionFrom = async (safeAddress: string, tx: TxServiceMod
let decimals = 18
let decodedParams
if (isSendTokenTx) {
const tokenContract = await getHumanFriendlyToken()
const tokenInstance = await tokenContract.at(tx.to)
const tokenInstance = await getTokenInfos(tx.to)
try {
;[symbol, decimals] = await Promise.all([tokenInstance.symbol(), tokenInstance.decimals()])
symbol = tokenInstance.symbol
decimals = tokenInstance.decimals
} catch (err) {
const alternativeTokenInstance = new web3.eth.Contract(ALTERNATIVE_TOKEN_ABI, tx.to)
const [tokenSymbol, tokenDecimals] = await Promise.all([
@ -230,11 +230,9 @@ export const buildIncomingTransactionFrom = async (tx: IncomingTxServiceModel) =
if (tx.tokenAddress) {
try {
const tokenContract = await getHumanFriendlyToken()
const tokenInstance = await tokenContract.at(tx.tokenAddress)
const [tokenSymbol, tokenDecimals] = await Promise.all([tokenInstance.symbol(), tokenInstance.decimals()])
symbol = tokenSymbol
decimals = tokenDecimals
const tokenInstance = await getTokenInfos(tx.tokenAddress)
symbol = tokenInstance.symbol
decimals = tokenInstance.decimals
} catch (err) {
try {
const { methods } = new web3.eth.Contract(ALTERNATIVE_TOKEN_ABI, tx.tokenAddress)
@ -269,19 +267,41 @@ export type SafeTransactionsType = {
cancel: Map<string, List<TransactionProps>>,
}
let etagSafeTransactions = null
let etagCachedSafeIncommingTransactions = null
export const loadSafeTransactions = async (safeAddress: string): Promise<SafeTransactionsType> => {
let transactions: TxServiceModel[] = addMockSafeCreationTx(safeAddress)
try {
const config = etagSafeTransactions
? {
headers: {
'If-None-Match': etagSafeTransactions,
},
}
: undefined
const url = buildTxServiceUrl(safeAddress)
const response = await axios.get(url)
const response = await axios.get(url, config)
if (response.data.count > 0) {
transactions = transactions.concat(response.data.results)
if (etagSafeTransactions === response.headers.etag) {
// The txs are the same, we can return the cached ones
return
}
etagSafeTransactions = response.headers.etag
}
} catch (err) {
console.error(`Requests for outgoing transactions for ${safeAddress} failed with 404`, err)
if (err && err.response && err.response.status === 304) {
// NOTE: this is the expected implementation, currently the backend is not returning 304.
// So I check if the returned etag is the same instead (see above)
return
} else {
console.error(`Requests for outgoing transactions for ${safeAddress} failed with 404`, err)
}
}
// In case that the etags don't match, we parse the new transactions and save them to the cache
const txsRecord: Array<RecordInstance<TransactionProps>> = await Promise.all(
transactions.map((tx: TxServiceModel) => buildTransactionFrom(safeAddress, tx)),
)
@ -297,26 +317,50 @@ export const loadSafeTransactions = async (safeAddress: string): Promise<SafeTra
export const loadSafeIncomingTransactions = async (safeAddress: string) => {
let incomingTransactions: IncomingTxServiceModel[] = []
try {
const config = etagCachedSafeIncommingTransactions
? {
headers: {
'If-None-Match': etagCachedSafeIncommingTransactions,
},
}
: undefined
const url = buildIncomingTxServiceUrl(safeAddress)
const response = await axios.get(url)
const response = await axios.get(url, config)
if (response.data.count > 0) {
incomingTransactions = response.data.results
if (etagCachedSafeIncommingTransactions === response.headers.etag) {
// The txs are the same, we can return the cached ones
return
}
etagCachedSafeIncommingTransactions = response.headers.etag
}
} catch (err) {
console.error(`Requests for incoming transactions for ${safeAddress} failed with 404`, err)
if (err && err.response && err.response.status === 304) {
// We return cached transactions
return
} else {
console.error(`Requests for incoming transactions for ${safeAddress} failed with 404`, err)
}
}
const incomingTxsRecord = await Promise.all(incomingTransactions.map(buildIncomingTransactionFrom))
return Map().set(safeAddress, List(incomingTxsRecord))
}
export default (safeAddress: string) => async (dispatch: ReduxDispatch<GlobalState>) => {
web3 = await getWeb3()
const { cancel, outgoing }: SafeTransactionsType = await loadSafeTransactions(safeAddress)
const incomingTransactions: Map<string, List<IncomingTransaction>> = await loadSafeIncomingTransactions(safeAddress)
dispatch(addCancellationTransactions(cancel))
dispatch(addTransactions(outgoing))
dispatch(addIncomingTransactions(incomingTransactions))
const transactions: SafeTransactionsType | undefined = await loadSafeTransactions(safeAddress)
if (transactions) {
const { cancel, outgoing } = transactions
dispatch(addCancellationTransactions(cancel))
dispatch(addTransactions(outgoing))
}
const incomingTransactions: Map<string, List<IncomingTransaction>> | undefined = await loadSafeIncomingTransactions(
safeAddress,
)
if (incomingTransactions) {
dispatch(addIncomingTransactions(incomingTransactions))
}
}

View File

@ -2,7 +2,7 @@
import { Record } from 'immutable'
import type { RecordFactory, RecordOf } from 'immutable'
export const INCOMING_TX_TYPE = 'incoming'
export const INCOMING_TX_TYPES = ['ERC721_TRANSFER', 'ERC20_TRANSFER', 'ETHER_TRANSFER']
export type IncomingTransactionProps = {
blockNumber: number,
@ -53,7 +53,7 @@ export const makeIncomingTransaction: RecordFactory<IncomingTransactionProps> =
decimals: 18,
fee: '',
executionDate: '',
type: INCOMING_TX_TYPE,
type: INCOMING_TX_TYPES,
status: 'success',
nonce: null,
confirmations: null,

View File

@ -1,5 +1,7 @@
// @flow
export const NETWORK = process.env.REACT_APP_NETWORK
import { ETHEREUM_NETWORK } from '~/logic/wallets/getWeb3'
export const NETWORK = process.env.REACT_APP_NETWORK || ETHEREUM_NETWORK.RINKEBY
export const GOOGLE_ANALYTICS_ID_RINKEBY = process.env.REACT_APP_GOOGLE_ANALYTICS_ID_RINKEBY
export const GOOGLE_ANALYTICS_ID_MAINNET = process.env.REACT_APP_GOOGLE_ANALYTICS_ID_MAINNET
export const INTERCOM_ID = process.env.REACT_APP_INTERCOM_ID