diff --git a/.gitignore b/.gitignore index 5c7da685..78cf7778 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ node_modules/ build/ .DS_Store -build/ yarn-error.log .env* .idea/ diff --git a/config/jest/LocalStorageMock.js b/config/jest/LocalStorageMock.js index 787ab7f2..45637cca 100644 --- a/config/jest/LocalStorageMock.js +++ b/config/jest/LocalStorageMock.js @@ -1,6 +1,5 @@ -// @flow class LocalStorageMock { - store: Object + store constructor() { this.store = {} diff --git a/config/jest/Web3Mock.js b/config/jest/Web3Mock.js index 87a55856..1c88c8ee 100644 --- a/config/jest/Web3Mock.js +++ b/config/jest/Web3Mock.js @@ -1,4 +1,3 @@ -// @flow import Web3 from 'web3' const window = global.window || {} diff --git a/config/jest/cssTransform.js b/config/jest/cssTransform.js index 05ec203f..b316fa67 100644 --- a/config/jest/cssTransform.js +++ b/config/jest/cssTransform.js @@ -1,4 +1,3 @@ -// @flow // This is a custom Jest transformer turning style imports into empty objects. // http://facebook.github.io/jest/docs/tutorial-webpack.html diff --git a/config/jest/fileTransform.js b/config/jest/fileTransform.js index 06760aa5..75969015 100644 --- a/config/jest/fileTransform.js +++ b/config/jest/fileTransform.js @@ -1,4 +1,3 @@ -// @flow const path = require('path') // This is a custom Jest transformer turning file imports into filenames. diff --git a/config/jest/jest.setup.js b/config/jest/jest.setup.js index 70a90313..7f0aedda 100644 --- a/config/jest/jest.setup.js +++ b/config/jest/jest.setup.js @@ -1,2 +1 @@ -// @flow jest.setTimeout(60000) diff --git a/migrations/1_initial_migration.js b/migrations/1_initial_migration.js index 19434bb4..75fdd419 100644 --- a/migrations/1_initial_migration.js +++ b/migrations/1_initial_migration.js @@ -1,4 +1,3 @@ -// @flow const Migrations = artifacts.require('./Migrations.sol') module.exports = deployer => deployer.deploy(Migrations) diff --git a/migrations/2_DEV_deploy_token.js b/migrations/2_DEV_deploy_token.js index d92ff42d..208ffe77 100644 --- a/migrations/2_DEV_deploy_token.js +++ b/migrations/2_DEV_deploy_token.js @@ -1,4 +1,3 @@ -// @flow /* eslint-disable no-console */ const TokenOMG = artifacts.require('TokenOMG') const TokenRDN = artifacts.require('TokenRDN') diff --git a/package.json b/package.json index 285e1064..1e3f6f82 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "electron-is-dev": "^1.1.0", "electron-log": "4.2.1", "electron-updater": "4.3.1", + "eth-sig-util": "^2.5.3", "express": "^4.17.1", "final-form": "4.20.0", "final-form-calculate": "^1.3.1", diff --git a/src/components/Identicon/index.tsx b/src/components/Identicon/index.tsx index 8fb283df..c0c499b7 100644 --- a/src/components/Identicon/index.tsx +++ b/src/components/Identicon/index.tsx @@ -1,4 +1,6 @@ import * as React from 'react' +import { ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses' + import { toDataUrl } from './blockies' export default class Identicon extends React.PureComponent { @@ -45,7 +47,7 @@ export default class Identicon extends React.PureComponent { generateBlockieIdenticon = (address, diameter) => { const image = new window.Image() - image.src = toDataUrl(address) + image.src = toDataUrl(address || ZERO_ADDRESS) image.height = diameter image.width = diameter image.style.borderRadius = `${diameter / 2}px` diff --git a/src/config/index.ts b/src/config/index.ts index bdcdb6dc..38f4102a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,48 +1,47 @@ -// -import { ensureOnce } from "src/utils/singleton" +import { ensureOnce } from 'src/utils/singleton' import { ETHEREUM_NETWORK, getWeb3 } from 'src/logic/wallets/getWeb3' import { RELAY_API_URL, SIGNATURES_VIA_METAMASK, TX_SERVICE_HOST -} from "src/config/names" -import devConfig from "./development" -import testConfig from "./testing" -import stagingConfig from "./staging" -import prodConfig from "./production" -import mainnetDevConfig from "./development-mainnet" -import mainnetProdConfig from "./production-mainnet" -import mainnetStagingConfig from "./staging-mainnet" +} from 'src/config/names' +import devConfig from './development' +import testConfig from './testing' +import stagingConfig from './staging' +import prodConfig from './production' +import mainnetDevConfig from './development-mainnet' +import mainnetProdConfig from './production-mainnet' +import mainnetStagingConfig from './staging-mainnet' const configuration = () => { - if (process.env.NODE_ENV === "test") { + if (process.env.NODE_ENV === 'test') { return testConfig } - if (process.env.NODE_ENV === "production") { - if (process.env.REACT_APP_NETWORK === "mainnet") { - return process.env.REACT_APP_ENV === "production" + if (process.env.NODE_ENV === 'production') { + if (process.env.REACT_APP_NETWORK === 'mainnet') { + return process.env.REACT_APP_ENV === 'production' ? mainnetProdConfig : mainnetStagingConfig } - return process.env.REACT_APP_ENV === "production" + return process.env.REACT_APP_ENV === 'production' ? prodConfig : stagingConfig } - return process.env.REACT_APP_NETWORK === "mainnet" + return process.env.REACT_APP_NETWORK === 'mainnet' ? mainnetDevConfig : devConfig } export const getNetwork = () => - process.env.REACT_APP_NETWORK === "mainnet" + process.env.REACT_APP_NETWORK === 'mainnet' ? ETHEREUM_NETWORK.MAINNET : ETHEREUM_NETWORK.RINKEBY export const getNetworkId = () => - process.env.REACT_APP_NETWORK === "mainnet" ? 1 : 4 + process.env.REACT_APP_NETWORK === 'mainnet' ? 1 : 4 const getConfig = ensureOnce(configuration) @@ -74,9 +73,9 @@ export const getGoogleAnalyticsTrackingID = () => : process.env.REACT_APP_GOOGLE_ANALYTICS_ID_RINKEBY export const getIntercomId = () => - process.env.REACT_APP_ENV === "production" + process.env.REACT_APP_ENV === 'production' ? process.env.REACT_APP_INTERCOM_ID - : "plssl1fl" + : 'plssl1fl' export const getExchangeRatesUrl = () => 'https://api.exchangeratesapi.io/latest' @@ -88,4 +87,4 @@ export const buildSafeCreationTxUrl = (safeAddress) => { const base = getSafeCreationTxUri(address) return `${host}${base}` -} \ No newline at end of file +} diff --git a/src/logic/contracts/generateBatchRequests.ts b/src/logic/contracts/generateBatchRequests.ts index a898d70f..95602f29 100644 --- a/src/logic/contracts/generateBatchRequests.ts +++ b/src/logic/contracts/generateBatchRequests.ts @@ -1,5 +1,4 @@ -// -import { getWeb3 } from 'src/logic/wallets/getWeb3' +import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3' /** * Generates a batch request for grouping RPC calls @@ -12,7 +11,6 @@ import { getWeb3 } from 'src/logic/wallets/getWeb3' * @returns {Promise<[*]>} */ const generateBatchRequests = ({ abi, address, batch, context, methods }: any): any => { - const web3 = getWeb3() const contractInstance: any = new web3.eth.Contract(abi, address) const localBatch = batch ? null : new web3.BatchRequest() diff --git a/src/logic/contracts/methodIds.ts b/src/logic/contracts/methodIds.ts index 7ced61db..351df109 100644 --- a/src/logic/contracts/methodIds.ts +++ b/src/logic/contracts/methodIds.ts @@ -1,5 +1,4 @@ -// -import { getWeb3 } from 'src/logic/wallets/getWeb3' +import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3' // SAFE METHODS TO ITS ID // https://github.com/gnosis/safe-contracts/blob/development/test/safeMethodNaming.js @@ -53,40 +52,124 @@ const METHOD_TO_ID = { '0x694e80c3': SAFE_METHODS_NAMES.CHANGE_THRESHOLD, } -export const decodeParamsFromSafeMethod = (data) => { - const web3 = getWeb3() - const [methodId, params] = [data.slice(0, 10), data.slice(10)] +type SafeMethods = typeof SAFE_METHODS_NAMES[keyof typeof SAFE_METHODS_NAMES] +type TokenMethods = 'transfer' | 'transferFrom' | 'safeTransferFrom' + +type DecodedValues = Array<{ + name: string + value: string +}> + +type SafeDecodedParams = { + [key in SafeMethods]?: DecodedValues +} + +type TokenDecodedParams = { + [key in TokenMethods]?: DecodedValues +} + +export type DecodedMethods = SafeDecodedParams | TokenDecodedParams | null + +export const decodeParamsFromSafeMethod = (data: string): SafeDecodedParams | null => { + const [methodId, params] = [data.slice(0, 10) as keyof typeof METHOD_TO_ID | string, data.slice(10)] switch (methodId) { // swapOwner - case '0xe318b52b': + case '0xe318b52b': { + const decodedParameters = web3.eth.abi.decodeParameters(['uint', 'address', 'address'], params) return { - methodName: METHOD_TO_ID[methodId], - args: web3.eth.abi.decodeParameters(['uint', 'address', 'address'], params), + [METHOD_TO_ID[methodId]]: [ + { name: 'oldOwner', value: decodedParameters[1] }, + { name: 'newOwner', value: decodedParameters[2] }, + ] } + } // addOwnerWithThreshold - case '0x0d582f13': + case '0x0d582f13': { + const decodedParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params) return { - methodName: METHOD_TO_ID[methodId], - args: web3.eth.abi.decodeParameters(['address', 'uint'], params), + [METHOD_TO_ID[methodId]]: [ + { name: 'owner', value: decodedParameters[0] }, + { name: '_threshold', value: decodedParameters[1] }, + ] } + } // removeOwner - case '0xf8dc5dd9': + case '0xf8dc5dd9': { + const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params) return { - methodName: METHOD_TO_ID[methodId], - args: web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params), + [METHOD_TO_ID[methodId]]: [ + { name: 'oldOwner', value: decodedParameters[1] }, + { name: '_threshold', value: decodedParameters[2] }, + ] } + } // changeThreshold - case '0x694e80c3': + case '0x694e80c3': { + const decodedParameters = web3.eth.abi.decodeParameters(['uint'], params) return { - methodName: METHOD_TO_ID[methodId], - args: web3.eth.abi.decodeParameters(['uint'], params), + [METHOD_TO_ID[methodId]]: [ + { name: '_threshold', value: decodedParameters[0] }, + ] } + } default: - return {} + return null + } +} + +const isSafeMethod = (methodId: string): boolean => { + return !!METHOD_TO_ID[methodId] +} + +export const decodeMethods = (data: string): DecodedMethods => { + const [methodId, params] = [data.slice(0, 10), data.slice(10)] + + if (isSafeMethod(methodId)) { + return decodeParamsFromSafeMethod(data) + } + + switch (methodId) { + // a9059cbb - transfer(address,uint256) + case '0xa9059cbb': { + const decodeParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params) + return { + transfer: [ + { name: 'to', value: decodeParameters[0] }, + { name: 'value', value: decodeParameters[1] }, + ] + } + } + + // 23b872dd - transferFrom(address,address,uint256) + case '0x23b872dd': { + const decodeParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params) + return { + transferFrom: [ + { name: 'from', value: decodeParameters[0] }, + { name: 'to', value: decodeParameters[1] }, + { name: 'value', value: decodeParameters[2] }, + ] + } + } + + // 42842e0e - safeTransferFrom(address,address,uint256) + case '0x42842e0e':{ + const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params) + return { + safeTransferFrom: [ + { name: 'from', value: decodedParameters[0] }, + { name: 'to', value: decodedParameters[1] }, + { name: 'value', value: decodedParameters[2] }, + ] + } + } + + default: + return null } } diff --git a/src/logic/safe/transactions/awaitingTransactions.ts b/src/logic/safe/transactions/awaitingTransactions.ts index 25d3f3ae..c15bfc91 100644 --- a/src/logic/safe/transactions/awaitingTransactions.ts +++ b/src/logic/safe/transactions/awaitingTransactions.ts @@ -1,33 +1,18 @@ -import { Map } from 'immutable' +import { List } from 'immutable' -export const getAwaitingTransactions = (allTransactions, cancellationTransactionsByNonce, userAccount) => { - if (!allTransactions) { - return Map({}) - } +import { isPendingTransaction } from 'src/routes/safe/store/actions/transactions/utils/transactionHelpers' - const allAwaitingTransactions = allTransactions.map((safeTransactions) => { - const nonCancelledTransactions = safeTransactions.filter((transaction) => { - // If transactions are not executed, but there's a transaction with the same nonce EXECUTED later - // it means that the transaction was cancelled (Replaced) and shouldn't get executed - let isTransactionCancelled = false - if (!transaction.isExecuted) { - if (cancellationTransactionsByNonce.get(transaction.nonce)) { - // eslint-disable-next-line no-param-reassign - isTransactionCancelled = true - } - } - // The transaction is not executed and is not cancelled, so it's still waiting confirmations - if (!transaction.executionTxHash && !isTransactionCancelled) { - // Then we check if the waiting confirmations are not from the current user, otherwise, filters this - // transaction - const transactionWaitingUser = transaction.confirmations.filter(({ owner }) => owner !== userAccount) +export const getAwaitingTransactions = (allTransactions = List([]), cancellationTxs, userAccount: string) => { + return allTransactions.filter((tx) => { + const cancelTx = !!tx.nonce && !isNaN(Number(tx.nonce)) ? cancellationTxs.get(`${tx.nonce}`) : null - return transactionWaitingUser.size > 0 - } - return false - }) - return nonCancelledTransactions + // The transaction is not executed and is not cancelled, nor pending, so it's still waiting confirmations + if (!tx.executionTxHash && !tx.cancelled && !isPendingTransaction(tx, cancelTx)) { + // Then we check if the waiting confirmations are not from the current user, otherwise, filters this transaction + const transactionWaitingUser = tx.confirmations.filter(({ owner }) => owner !== userAccount) + return transactionWaitingUser.size > 0 + } + + return false }) - - return allAwaitingTransactions } diff --git a/src/logic/safe/transactions/gasNew.ts b/src/logic/safe/transactions/gasNew.ts index 60d6a655..5f29c1e3 100644 --- a/src/logic/safe/transactions/gasNew.ts +++ b/src/logic/safe/transactions/gasNew.ts @@ -46,7 +46,18 @@ export const estimateTxGasCosts = async (safeAddress, to, data, tx?: any, preApp '', )}000000000000000000000000000000000000000000000000000000000000000001` txData = await safeInstance.methods - .execTransaction(to, tx ? tx.value : 0, data, CALL, 0, 0, 0, ZERO_ADDRESS, ZERO_ADDRESS, signatures) + .execTransaction( + to, + tx ? tx.value : 0, + data, + CALL, + tx ? tx.safeTxGas : 0, + 0, + 0, + ZERO_ADDRESS, + ZERO_ADDRESS, + signatures, + ) .encodeABI() } else { const txHash = await safeInstance.methods diff --git a/src/logic/safe/transactions/offchainSigner/index.ts b/src/logic/safe/transactions/offchainSigner/index.ts index 430a6d8c..047c04d7 100644 --- a/src/logic/safe/transactions/offchainSigner/index.ts +++ b/src/logic/safe/transactions/offchainSigner/index.ts @@ -3,7 +3,7 @@ import { ethSigner } from './ethSigner' // 1. we try to sign via EIP-712 if user's wallet supports it // 2. If not, try to use eth_sign (Safe version has to be >1.1.1) -// If eth_sign, doesn't work continue with the regular flow (on-chain signatures, more in createTransaction.js) +// If eth_sign, doesn't work continue with the regular flow (on-chain signatures, more in createTransaction.ts) const SIGNERS = { EIP712_V3: getEIP712Signer('v3'), diff --git a/src/logic/safe/transactions/txHistory.ts b/src/logic/safe/transactions/txHistory.ts index 8ffa95c6..d2b4042c 100644 --- a/src/logic/safe/transactions/txHistory.ts +++ b/src/logic/safe/transactions/txHistory.ts @@ -56,7 +56,7 @@ export const buildTxServiceUrl = (safeAddress) => { const host = getTxServiceHost() const address = checksumAddress(safeAddress) const base = getTxServiceUriFrom(address) - return `${host}${base}` + return `${host}${base}?has_confirmations=True` } const SUCCESS_STATUS = 201 // CREATED status diff --git a/src/logic/tokens/store/actions/fetchTokens.ts b/src/logic/tokens/store/actions/fetchTokens.ts index d7794b57..0e178f47 100644 --- a/src/logic/tokens/store/actions/fetchTokens.ts +++ b/src/logic/tokens/store/actions/fetchTokens.ts @@ -1,11 +1,13 @@ import StandardToken from '@gnosis.pm/util-contracts/build/contracts/GnosisStandardToken.json' import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json' -import ERC721 from '@openzeppelin/contracts/build/contracts/ERC721' +import ERC20Detailed from '@openzeppelin/contracts/build/contracts/ERC20Detailed.json' +import ERC721 from '@openzeppelin/contracts/build/contracts/ERC721.json' import { List } from 'immutable' import contract from 'truffle-contract' import saveTokens from './saveTokens' +import generateBatchRequests from 'src/logic/contracts/generateBatchRequests' import { fetchTokenList } from 'src/logic/tokens/api' import { makeToken } from 'src/logic/tokens/store/model/token' import { tokensSelector } from 'src/logic/tokens/store/selectors' @@ -36,53 +38,6 @@ const createERC721TokenContract = async () => { return erc721Token } -// 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. -export 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', - }, - ], -} - export const getHumanFriendlyToken = ensureOnce(createHumanFriendlyTokenContract) export const getStandardTokenContract = ensureOnce(createStandardTokenContract) @@ -96,35 +51,45 @@ export const containsMethodByHash = async (contractAddress, methodHash) => { return byteCode.indexOf(methodHash.replace('0x', '')) !== -1 } +const getTokenValues = (tokenAddress) => + generateBatchRequests({ + abi: ERC20Detailed.abi, + address: tokenAddress, + methods: ['decimals', 'name', 'symbol'], + }) + export const getTokenInfos = async (tokenAddress) => { 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({ + const [tokenDecimals, tokenName, tokenSymbol] = await getTokenValues(tokenAddress) + + if (tokenDecimals === null) { + return null + } + + const token = makeToken({ address: tokenAddress, - name: name ? name : tokenSymbol, + name: tokenName ? tokenName : tokenSymbol, symbol: tokenSymbol, - decimals: tokenDecimals.toNumber(), + decimals: Number(tokenDecimals), logoUri: '', }) - const newTokens = tokens.set(tokenAddress, savedToken) + + const newTokens = tokens.set(tokenAddress, token) store.dispatch(saveTokens(newTokens)) - return savedToken + return token } export const fetchTokens = () => async (dispatch, getState) => { diff --git a/src/logic/tokens/store/model/token.ts b/src/logic/tokens/store/model/token.ts index 1034fed5..fc072be2 100644 --- a/src/logic/tokens/store/model/token.ts +++ b/src/logic/tokens/store/model/token.ts @@ -1,6 +1,15 @@ -import { Record } from 'immutable' +import { Record, RecordOf } from 'immutable' -export const makeToken = Record({ +export type TokenProps = { + address: string + name: string + symbol: string + decimals: number | string + logoUri?: string | null + balance?: number | string +} + +export const makeToken = Record({ address: '', name: '', symbol: '', @@ -8,5 +17,6 @@ export const makeToken = Record({ logoUri: '', balance: undefined, }) - // balance is only set in extendedSafeTokensSelector when we display user's token balances + +export type Token = RecordOf diff --git a/src/logic/tokens/utils/tokenHelpers.ts b/src/logic/tokens/utils/tokenHelpers.ts index 334d2ab3..3e94fb19 100644 --- a/src/logic/tokens/utils/tokenHelpers.ts +++ b/src/logic/tokens/utils/tokenHelpers.ts @@ -1,19 +1,17 @@ -import ERC20Detailed from '@openzeppelin/contracts/build/contracts/ERC20Detailed.json' -import { List } from 'immutable' - import logo from 'src/assets/icons/icon_etherTokens.svg' -import { getStandardTokenContract } from 'src/logic/tokens/store/actions/fetchTokens' -import { makeToken } from 'src/logic/tokens/store/model/token' -import { getWeb3 } from 'src/logic/wallets/getWeb3' +import generateBatchRequests from 'src/logic/contracts/generateBatchRequests' +import { getStandardTokenContract, getTokenInfos } from 'src/logic/tokens/store/actions/fetchTokens' +import { makeToken, Token } from 'src/logic/tokens/store/model/token' +import { ALTERNATIVE_TOKEN_ABI } from 'src/logic/tokens/utils/alternativeAbi' +import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3' +import { isEmptyData } from 'src/routes/safe/store/actions/transactions/utils/transactionHelpers' +import { TxServiceModel } from 'src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions' export const ETH_ADDRESS = '0x000' -export const SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH = '0x42842e0e' -export const DECIMALS_METHOD_HASH = '313ce567' +export const SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH = '42842e0e' -export const isEther = (symbol) => symbol === 'ETH' - -export const getEthAsToken = (balance) => { - const eth = makeToken({ +export const getEthAsToken = (balance: string): Token => { + return makeToken({ address: ETH_ADDRESS, name: 'Ether', symbol: 'ETH', @@ -21,26 +19,9 @@ export const getEthAsToken = (balance) => { logoUri: logo, balance, }) - - return eth } -export const calculateActiveErc20TokensFrom = (tokens) => { - const activeTokens = List().withMutations((list) => - tokens.forEach((token) => { - const isDeactivated = isEther(token.symbol) || !token.status - if (isDeactivated) { - return - } - - list.push(token) - }), - ) - - return activeTokens -} - -export const isAddressAToken = async (tokenAddress) => { +export const isAddressAToken = async (tokenAddress): Promise => { // SECOND APPROACH: // They both seem to work the same // const tokenContract = await getStandardTokenContract() @@ -49,41 +30,69 @@ export const isAddressAToken = async (tokenAddress) => { // } catch { // return 'Not a token address' // } - - const web3 = getWeb3() const call = await web3.eth.call({ to: tokenAddress, data: web3.utils.sha3('totalSupply()') }) return call !== '0x' } -export const hasDecimalsMethod = async (address) => { - try { - const web3 = getWeb3() - const token: any = new web3.eth.Contract(ERC20Detailed.abi as any, address) - await token.methods.decimals().call() - return true - } catch (e) { - return false - } +export const isTokenTransfer = (tx: any): boolean => { + return !isEmptyData(tx.data) && tx.data.substring(0, 10) === '0xa9059cbb' && Number(tx.value) === 0 } -export const isTokenTransfer = (data, value) => !!data && data.substring(0, 10) === '0xa9059cbb' && value === 0 +export const isSendERC721Transaction = (tx: any, txCode: string, knownTokens: any) => { + return ( + (txCode && txCode.includes(SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH)) || + (isTokenTransfer(tx) && !knownTokens.get(tx.to)) + ) +} -export const isMultisendTransaction = (data, value) => !!data && data.substring(0, 10) === '0x8d80ff0a' && value === 0 +export const getERC20DecimalsAndSymbol = async ( + tokenAddress: string, +): Promise<{ decimals: number; symbol: string }> => { + const tokenInfos = await getTokenInfos(tokenAddress) -// 7de7edef - changeMasterCopy (308, 8) -// f08a0323 - setFallbackHandler (550, 8) -export const isUpgradeTransaction = (data) => - !!data && data.substr(308, 8) === '7de7edef' && data.substr(550, 8) === 'f08a0323' + if (tokenInfos === null) { + const [tokenDecimals, tokenSymbol] = await generateBatchRequests({ + abi: ALTERNATIVE_TOKEN_ABI, + address: tokenAddress, + methods: ['decimals', 'symbol'], + }) -export const isERC721Contract = async (contractAddress) => { + return { decimals: Number(tokenDecimals), symbol: tokenSymbol } + } + + return { decimals: Number(tokenInfos.decimals), symbol: tokenInfos.symbol } +} + +export const isSendERC20Transaction = async ( + tx: TxServiceModel, + txCode: string, + knownTokens: any, +): Promise => { + let isSendTokenTx = !isSendERC721Transaction(tx, txCode, knownTokens) && isTokenTransfer(tx) + + if (isSendTokenTx) { + const { decimals, symbol } = await getERC20DecimalsAndSymbol(tx.to) + + // some contracts may implement the same methods as in ERC20 standard + // we may falsely treat them as tokens, so in case we get any errors when getting token info + // we fallback to displaying custom transaction + isSendTokenTx = decimals !== null && symbol !== null + } + + return isSendTokenTx +} + +export const isERC721Contract = async (contractAddress: string): Promise => { const ERC721Token = await getStandardTokenContract() let isERC721 = false + try { isERC721 = true await ERC721Token.at(contractAddress) } catch (error) { console.warn('Asset not found') } + return isERC721 } diff --git a/src/logic/wallets/store/actions/fetchProvider.ts b/src/logic/wallets/store/actions/fetchProvider.ts index e535f7d0..1d3006ab 100644 --- a/src/logic/wallets/store/actions/fetchProvider.ts +++ b/src/logic/wallets/store/actions/fetchProvider.ts @@ -7,11 +7,12 @@ import { NOTIFICATIONS, enhanceSnackbarForAction } from 'src/logic/notifications import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar' import { ETHEREUM_NETWORK, ETHEREUM_NETWORK_IDS, getProviderInfo, getWeb3 } from 'src/logic/wallets/getWeb3' import { makeProvider } from 'src/logic/wallets/store/model/provider' +import { updateStoredTransactionsStatus } from 'src/routes/safe/store/actions/transactions/utils/transactionHelpers' export const processProviderResponse = (dispatch, provider) => { const walletRecord = makeProvider(provider) - dispatch(addProvider(walletRecord)) + updateStoredTransactionsStatus(dispatch, walletRecord) } const handleProviderNotification = (provider, dispatch) => { diff --git a/src/routes/opening/components/Footer.tsx b/src/routes/opening/components/Footer.tsx index 77bd8c4f..764e1190 100644 --- a/src/routes/opening/components/Footer.tsx +++ b/src/routes/opening/components/Footer.tsx @@ -1,4 +1,3 @@ -// @flow import React from 'react' import styled from 'styled-components' diff --git a/src/routes/opening/steps.ts b/src/routes/opening/steps.ts index 421d43c3..8863d7a4 100644 --- a/src/routes/opening/steps.ts +++ b/src/routes/opening/steps.ts @@ -1,4 +1,3 @@ -// @flow import { ContinueFooter, GenericFooter } from './components/Footer' export const isConfirmationStep = (stepIndex?: number) => stepIndex === 0 diff --git a/src/routes/safe/components/Layout/Header/index.tsx b/src/routes/safe/components/Layout/Header/index.tsx index 3d889147..f3bbad71 100644 --- a/src/routes/safe/components/Layout/Header/index.tsx +++ b/src/routes/safe/components/Layout/Header/index.tsx @@ -51,7 +51,7 @@ const LayoutHeader = (props) => { className={classes.send} color="primary" disabled={!granted} - onClick={() => showSendFunds('Ether')} + onClick={() => showSendFunds('')} size="small" variant="contained" > diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/CreationTx/index.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/CreationTx/index.tsx index 8305985b..8d899ace 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/CreationTx/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/CreationTx/index.tsx @@ -1,11 +1,12 @@ -// @flow -import React from 'react' -import { formatDate } from '../../columns' -import Bold from '../../../../../../../components/layout/Bold' -import Paragraph from '../../../../../../../components/layout/Paragraph' -import EtherscanLink from '../../../../../../../components/EtherscanLink' import { makeStyles } from '@material-ui/core/styles' -import Block from '../../../../../../../components/layout/Block' +import React from 'react' + +import { formatDate } from 'src/routes/safe/components/Transactions/TxsTable/columns' +import Bold from 'src/components/layout/Bold' +import Paragraph from 'src/components/layout/Paragraph' +import EtherscanLink from 'src/components/EtherscanLink' +import Block from 'src/components/layout/Block' +import { TransactionTypes } from 'src/routes/safe/store/models/types/transaction' const useStyles = makeStyles({ address: { @@ -21,15 +22,16 @@ const useStyles = makeStyles({ }, }) -export const CreationTx = (props) => { - const { tx } = props +export const CreationTx = ({ tx }) => { const classes = useStyles() - if (!tx) return null - const isCreationTx = tx.type === 'creation' - console.log('Classes', classes) + if (!tx) { + return null + } - return !isCreationTx ? null : ( + const isCreationTx = tx.type === TransactionTypes.CREATION + + return isCreationTx ? ( <> Created: @@ -48,5 +50,5 @@ export const CreationTx = (props) => { {tx.masterCopy ? : 'n/a'} - ) + ) : null } diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/IncomingTx/index.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/IncomingTx/index.tsx index d0f9d383..33aeca5c 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/IncomingTx/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/IncomingTx/index.tsx @@ -1,19 +1,20 @@ import React from 'react' -import { INCOMING_TX_TYPES } from '../../../../../store/models/incomingTransaction' -import { formatDate } from '../../columns' -import Bold from '../../../../../../../components/layout/Bold' -import Paragraph from '../../../../../../../components/layout/Paragraph' +import { INCOMING_TX_TYPES } from 'src/routes/safe/store/models/incomingTransaction' +import { formatDate } from 'src/routes/safe/components/Transactions/TxsTable/columns' +import Bold from 'src/components/layout/Bold' +import Paragraph from 'src/components/layout/Paragraph' + +export const IncomingTx = ({ tx }) => { + if (!tx) { + return null + } -export const IncomingTx = (props) => { - const { tx } = props - if (!tx) return null const isIncomingTx = !!INCOMING_TX_TYPES[tx.type] - return !isIncomingTx ? null : ( - <> - - Created: - {formatDate(tx.executionDate)} - - - ) + + return isIncomingTx ? ( + + Created: + {formatDate(tx.executionDate)} + + ) : null } diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OutgoingTx/index.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OutgoingTx/index.tsx index 1fafd089..a1e3a60f 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OutgoingTx/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OutgoingTx/index.tsx @@ -1,13 +1,25 @@ -// @flow import React from 'react' -import { formatDate } from '../../columns' -import Bold from '../../../../../../../components/layout/Bold' -import Paragraph from '../../../../../../../components/layout/Paragraph' -export const OutgoingTx = (props) => { - const { tx } = props - if (!tx || !(tx.type === 'outgoing')) return null - return ( +import { formatDate } from 'src/routes/safe/components/Transactions/TxsTable/columns' +import Bold from 'src/components/layout/Bold' +import Paragraph from 'src/components/layout/Paragraph' +import { TransactionTypes } from 'src/routes/safe/store/models/types/transaction' + +export const OutgoingTx = ({ tx }) => { + if (!tx) { + return null + } + + const isOutgoingTx = [ + TransactionTypes.OUTGOING, + TransactionTypes.UPGRADE, + TransactionTypes.CUSTOM, + TransactionTypes.SETTINGS, + TransactionTypes.COLLECTIBLE, + TransactionTypes.TOKEN, + ].includes(tx.type) + + return isOutgoingTx ? ( <> Created: @@ -36,5 +48,5 @@ export const OutgoingTx = (props) => { )} - ) + ) : null } diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.tsx index c4ca0a60..bd381a32 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.tsx @@ -8,6 +8,7 @@ import ConfirmSmallFilledCircle from './assets/confirm-small-filled.svg' import ConfirmSmallGreenCircle from './assets/confirm-small-green.svg' import ConfirmSmallGreyCircle from './assets/confirm-small-grey.svg' import ConfirmSmallRedCircle from './assets/confirm-small-red.svg' +import PendingSmallYellowCircle from './assets/confirm-small-yellow.svg' import { styles } from './style' import EtherscanLink from 'src/components/EtherscanLink' @@ -32,6 +33,8 @@ const OwnerComponent = ({ onTxExecute, onTxReject, owner, + pendingAcceptAction, + pendingRejectAction, showConfirmBtn, showExecuteBtn, showExecuteRejectBtn, @@ -43,18 +46,110 @@ const OwnerComponent = ({ const [imgCircle, setImgCircle] = React.useState(ConfirmSmallGreyCircle) React.useMemo(() => { + if (pendingAcceptAction || pendingRejectAction) { + setImgCircle(PendingSmallYellowCircle) + return + } if (confirmed) { setImgCircle(isCancelTx ? CancelSmallFilledCircle : ConfirmSmallFilledCircle) - } else if (thresholdReached || executor) { - setImgCircle(isCancelTx ? ConfirmSmallRedCircle : ConfirmSmallGreenCircle) + return } - }, [confirmed, thresholdReached, executor, isCancelTx]) + if (thresholdReached || executor) { + setImgCircle(isCancelTx ? ConfirmSmallRedCircle : ConfirmSmallGreenCircle) + return + } + setImgCircle(ConfirmSmallGreyCircle) + }, [confirmed, thresholdReached, executor, isCancelTx, pendingAcceptAction, pendingRejectAction]) - const getTimelineLine = () => (isCancelTx ? classes.verticalLineCancel : classes.verticalLineDone) + const getTimelineLine = () => { + if (pendingAcceptAction || pendingRejectAction) { + return classes.verticalPendingAction + } + if (isCancelTx) { + return classes.verticalLineCancel + } + return classes.verticalLineDone + } + + const confirmButton = () => { + if (pendingRejectAction) { + return null + } + if (pendingAcceptAction) { + return Pending + } + return ( + <> + {showConfirmBtn && ( + + )} + {showExecuteBtn && ( + + )} + + ) + } + + const rejectButton = () => { + if (pendingRejectAction) { + return Pending + } + if (pendingAcceptAction) { + return null + } + return ( + <> + {showRejectBtn && ( + + )} + {showExecuteRejectBtn && ( + + )} + + ) + } return ( -
+
@@ -66,61 +161,7 @@ const OwnerComponent = ({ - {owner === userAddress && ( - - {isCancelTx ? ( - <> - {showRejectBtn && ( - - )} - {showExecuteRejectBtn && ( - - )} - - ) : ( - <> - {showConfirmBtn && ( - - )} - {showExecuteBtn && ( - - )} - - )} - - )} + {owner === userAddress && {isCancelTx ? rejectButton() : confirmButton()}} {owner === executor && Executor} ) diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.tsx index 385da37e..8cd107f9 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.tsx @@ -38,7 +38,7 @@ const OwnersList = ({ userAddress={userAddress} /> ))} - {ownersUnconfirmed.map((owner) => ( + {ownersUnconfirmed.map(({ hasPendingAcceptActions, hasPendingRejectActions, owner }) => ( + + diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/index.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/index.tsx index 51c351d2..96624aeb 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/index.tsx @@ -15,10 +15,10 @@ import Block from 'src/components/layout/Block' import Col from 'src/components/layout/Col' import Img from 'src/components/layout/Img' import Paragraph from 'src/components/layout/Paragraph/index' -import { TX_TYPE_CONFIRMATION } from 'src/logic/safe/transactions/send' import { userAccountSelector } from 'src/logic/wallets/store/selectors' import { makeTransaction } from 'src/routes/safe/store/models/transaction' import { safeOwnersSelector, safeThresholdSelector } from 'src/routes/safe/store/selectors' +import { TransactionStatus } from 'src/routes/safe/store/models/types/transaction' function getOwnersConfirmations(tx, userAddress) { const ownersWhoConfirmed = [] @@ -29,34 +29,54 @@ function getOwnersConfirmations(tx, userAddress) { currentUserAlreadyConfirmed = true } - if (conf.type === TX_TYPE_CONFIRMATION) { - ownersWhoConfirmed.push(conf.owner) - } + ownersWhoConfirmed.push(conf.owner) }) - return [ownersWhoConfirmed, currentUserAlreadyConfirmed] } function getPendingOwnersConfirmations(owners, tx, userAddress) { - const ownersNotConfirmed = [] + const ownersWithNoConfirmations = [] let currentUserNotConfirmed = true owners.forEach((owner) => { const confirmationsEntry = tx.confirmations.find((conf) => conf.owner === owner.address) if (!confirmationsEntry) { - ownersNotConfirmed.push(owner.address) + ownersWithNoConfirmations.push(owner.address) } if (confirmationsEntry && confirmationsEntry.owner === userAddress) { currentUserNotConfirmed = false } }) - return [ownersNotConfirmed, currentUserNotConfirmed] + const confirmationPendingActions = tx.ownersWithPendingActions.get('confirm') + const confirmationRejectActions = tx.ownersWithPendingActions.get('reject') + + const ownersWithNoConfirmationsSorted = ownersWithNoConfirmations + .map((owner) => ({ + hasPendingAcceptActions: confirmationPendingActions.includes(owner), + hasPendingRejectActions: confirmationRejectActions.includes(owner), + owner, + })) + // Reorders the list of unconfirmed owners, owners with pendingActions should be first + .sort((ownerA, ownerB) => { + // If the first owner has pending actions, A should be before B + if (ownerA.hasPendingRejectActions || ownerA.hasPendingAcceptActions) { + return -1 + } + // The first owner has not pending actions but the second yes, B should be before A + if (ownerB.hasPendingRejectActions || ownerB.hasPendingAcceptActions) { + return 1 + } + // Otherwise do not change order + return 0 + }) + + return [ownersWithNoConfirmationsSorted, currentUserNotConfirmed] } const OwnersColumn = ({ tx, - cancelTx = makeTransaction(), + cancelTx = makeTransaction({ isCancellationTx: true, status: TransactionStatus.AWAITING_YOUR_CONFIRMATION }), classes, thresholdReached, cancelThresholdReached, @@ -99,6 +119,7 @@ const OwnersColumn = ({ const showConfirmBtn = !tx.isExecuted && tx.status !== 'pending' && + cancelTx.status !== 'pending' && !tx.cancelled && userIsUnconfirmedOwner && !currentUserAlreadyConfirmed && @@ -109,6 +130,7 @@ const OwnersColumn = ({ const showRejectBtn = !cancelTx.isExecuted && !tx.isExecuted && + tx.status !== 'pending' && cancelTx.status !== 'pending' && userIsUnconfirmedCancelOwner && !currentUserAlreadyConfirmedCancel && diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/style.ts b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/style.ts index 41400c39..cfee3724 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/style.ts +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/style.ts @@ -1,4 +1,4 @@ -import { boldFont, border, error, primary, secondary, secondaryText, sm } from 'src/theme/variables' +import { boldFont, border, error, primary, secondary, secondaryText, sm, warning } from 'src/theme/variables' export const styles = () => ({ ownersList: { @@ -29,6 +29,9 @@ export const styles = () => ({ verticalLineCancel: { backgroundColor: error, }, + verticalPendingAction: { + backgroundColor: warning, + }, icon: { marginRight: sm, }, diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/utils.ts b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/utils.ts index 05356aca..01159987 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/utils.ts +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/utils.ts @@ -1,5 +1,4 @@ import { SAFE_METHODS_NAMES } from 'src/logic/contracts/methodIds' -import { getWeb3 } from 'src/logic/wallets/getWeb3' const getSafeVersion = (data) => { const contractAddress = data.substr(340, 40).toLowerCase() @@ -12,43 +11,53 @@ const getSafeVersion = (data) => { } export const getTxData = (tx) => { - const web3 = getWeb3() - const { fromWei, toBN } = web3.utils - const txData: any = {} - if (tx.isTokenTransfer && tx.decodedParams) { - txData.recipient = tx.decodedParams.recipient - txData.value = fromWei(toBN(tx.decodedParams.value), 'ether') + if (tx.decodedParams) { + if (tx.isTokenTransfer) { + const { to } = tx.decodedParams.transfer + txData.recipient = to + txData.isTokenTransfer = true + } + if (tx.isCollectibleTransfer) { + const { safeTransferFrom, transfer, transferFrom } = tx.decodedParams + const { to, value } = safeTransferFrom || transferFrom || transfer + txData.recipient = to + txData.tokenId = value + txData.isCollectibleTransfer = true + } + if (tx.modifySettingsTx) { + txData.recipient = tx.recipient + txData.modifySettingsTx = true + + if (tx.decodedParams[SAFE_METHODS_NAMES.REMOVE_OWNER]) { + const { _threshold, owner } = tx.decodedParams[SAFE_METHODS_NAMES.REMOVE_OWNER] + txData.action = SAFE_METHODS_NAMES.REMOVE_OWNER + txData.removedOwner = owner + txData.newThreshold = _threshold + } else if (tx.decodedParams[SAFE_METHODS_NAMES.CHANGE_THRESHOLD]) { + const { _threshold } = tx.decodedParams[SAFE_METHODS_NAMES.CHANGE_THRESHOLD] + txData.action = SAFE_METHODS_NAMES.CHANGE_THRESHOLD + txData.newThreshold = _threshold + } else if (tx.decodedParams[SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD]) { + const { _threshold, owner } = tx.decodedParams[SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD] + txData.action = SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD + txData.addedOwner = owner + txData.newThreshold = _threshold + } else if (tx.decodedParams[SAFE_METHODS_NAMES.SWAP_OWNER]) { + const { newOwner, oldOwner } = tx.decodedParams[SAFE_METHODS_NAMES.SWAP_OWNER] + txData.action = SAFE_METHODS_NAMES.SWAP_OWNER + txData.removedOwner = oldOwner + txData.addedOwner = newOwner + } + } } else if (tx.customTx) { txData.recipient = tx.recipient - txData.value = fromWei(toBN(tx.value), 'ether') txData.data = tx.data txData.customTx = true } else if (Number(tx.value) > 0) { txData.recipient = tx.recipient - txData.value = fromWei(toBN(tx.value), 'ether') - } else if (tx.modifySettingsTx) { - txData.recipient = tx.recipient - txData.modifySettingsTx = true - - if (tx.decodedParams) { - txData.action = tx.decodedParams.methodName - - if (txData.action === SAFE_METHODS_NAMES.REMOVE_OWNER) { - txData.removedOwner = tx.decodedParams.args[1] - txData.newThreshold = tx.decodedParams.args[2] - } else if (txData.action === SAFE_METHODS_NAMES.CHANGE_THRESHOLD) { - txData.newThreshold = tx.decodedParams.args[0] - } else if (txData.action === SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD) { - txData.addedOwner = tx.decodedParams.args[0] - txData.newThreshold = tx.decodedParams.args[1] - } else if (txData.action === SAFE_METHODS_NAMES.SWAP_OWNER) { - txData.removedOwner = tx.decodedParams.args[1] - txData.addedOwner = tx.decodedParams.args[2] - } - } - } else if (tx.cancellationTx) { + } else if (tx.isCancellationTx) { txData.cancellationTx = true } else if (tx.creationTx) { txData.creationTx = true @@ -57,7 +66,6 @@ export const getTxData = (tx) => { txData.data = `The contract of this Safe is upgraded to Version ${getSafeVersion(tx.data)}` } else { txData.recipient = tx.recipient - txData.value = 0 } return txData diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/index.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/index.tsx index ca1f1cef..1abfbd59 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/index.tsx @@ -24,6 +24,7 @@ import { safeNonceSelector, safeThresholdSelector } from 'src/routes/safe/store/ import { IncomingTx } from './IncomingTx' import { CreationTx } from './CreationTx' import { OutgoingTx } from './OutgoingTx' +import { TransactionTypes } from 'src/routes/safe/store/models/types/transaction' const useStyles = makeStyles(styles as any) @@ -35,7 +36,7 @@ const ExpandedTx = ({ cancelTx, tx }) => { const openApproveModal = () => setOpenModal('approveTx') const closeModal = () => setOpenModal(null) const isIncomingTx = !!INCOMING_TX_TYPES[tx.type] - const isCreationTx = tx.type === 'creation' + const isCreationTx = tx.type === TransactionTypes.CREATION const thresholdReached = !isIncomingTx && threshold <= tx.confirmations.size const canExecute = !isIncomingTx && nonce === tx.nonce diff --git a/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx b/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx index bec45804..487f628a 100644 --- a/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx @@ -11,6 +11,8 @@ import { getAppInfoFromOrigin, getAppInfoFromUrl } from 'src/routes/safe/compone const typeToIcon = { outgoing: OutgoingTxIcon, + token: OutgoingTxIcon, + collectible: OutgoingTxIcon, incoming: IncomingTxIcon, custom: CustomTxIcon, settings: SettingsTxIcon, @@ -21,6 +23,8 @@ const typeToIcon = { const typeToLabel = { outgoing: 'Outgoing transfer', + token: 'Outgoing transfer', + collectible: 'Outgoing transfer', incoming: 'Incoming transfer', custom: 'Contract Interaction', settings: 'Modify settings', diff --git a/src/routes/safe/components/Transactions/TxsTable/columns.tsx b/src/routes/safe/components/Transactions/TxsTable/columns.tsx index a856fdb7..b5cda0cb 100644 --- a/src/routes/safe/components/Transactions/TxsTable/columns.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/columns.tsx @@ -2,7 +2,7 @@ import { BigNumber } from 'bignumber.js' import format from 'date-fns/format' import getTime from 'date-fns/getTime' import parseISO from 'date-fns/parseISO' -import { List, Map } from 'immutable' +import { List } from 'immutable' import React from 'react' import TxType from './TxType' @@ -43,7 +43,7 @@ export const getIncomingTxAmount = (tx, formatted = true) => { export const getTxAmount = (tx, formatted = true) => { const { decimals = 18, decodedParams, isTokenTransfer, symbol } = tx - const { value } = isTokenTransfer && decodedParams && decodedParams.value ? decodedParams : tx + const { value } = isTokenTransfer && !!decodedParams && !!decodedParams.transfer ? decodedParams.transfer : tx if (!isTokenTransfer && !(Number(value) > 0)) { return NOT_AVAILABLE @@ -65,22 +65,9 @@ const getIncomingTxTableData = (tx) => ({ const getTransactionTableData = (tx, cancelTx) => { const txDate = tx.submissionDate - let txType = 'outgoing' - if (tx.modifySettingsTx) { - txType = 'settings' - } else if (tx.cancellationTx) { - txType = 'cancellation' - } else if (tx.customTx) { - txType = 'custom' - } else if (tx.creationTx) { - txType = 'creation' - } else if (tx.upgradeTx) { - txType = 'upgrade' - } - return { [TX_TABLE_ID]: tx.blockNumber, - [TX_TABLE_TYPE_ID]: , + [TX_TABLE_TYPE_ID]: , [TX_TABLE_DATE_ID]: txDate ? formatDate(txDate) : '', [buildOrderFieldFrom(TX_TABLE_DATE_ID)]: txDate ? getTime(parseISO(txDate)) : null, [TX_TABLE_AMOUNT_ID]: getTxAmount(tx), @@ -91,17 +78,12 @@ const getTransactionTableData = (tx, cancelTx) => { } export const getTxTableData = (transactions, cancelTxs) => { - const cancelTxsByNonce = cancelTxs.reduce((acc, tx) => acc.set(tx.nonce, tx), Map()) - return transactions.map((tx) => { - if (INCOMING_TX_TYPES[tx.type]) { + if (INCOMING_TX_TYPES[tx.type] !== undefined) { return getIncomingTxTableData(tx) } - return getTransactionTableData( - tx, - Number.isInteger(Number.parseInt(tx.nonce, 10)) ? cancelTxsByNonce.get(tx.nonce) : undefined, - ) + return getTransactionTableData(tx, cancelTxs.get(`${tx.nonce}`)) }) } diff --git a/src/routes/safe/components/Transactions/TxsTable/index.tsx b/src/routes/safe/components/Transactions/TxsTable/index.tsx index 59c31371..bb74eea8 100644 --- a/src/routes/safe/components/Transactions/TxsTable/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/index.tsx @@ -1,3 +1,4 @@ +import Collapse from '@material-ui/core/Collapse' import IconButton from '@material-ui/core/IconButton' import TableCell from '@material-ui/core/TableCell' import TableContainer from '@material-ui/core/TableContainer' @@ -18,9 +19,8 @@ import Table from 'src/components/Table' import { cellWidth } from 'src/components/Table/TableHead' import Block from 'src/components/layout/Block' import Row from 'src/components/layout/Row' -import { extendedTransactionsSelector } from 'src/routes/safe/container/selector' import { safeCancellationTransactionsSelector } from 'src/routes/safe/store/selectors' -import { Collapse } from '@material-ui/core' +import { extendedTransactionsSelector } from 'src/routes/safe/store/selectors/transactions' export const TRANSACTION_ROW_TEST_ID = 'transaction-row' @@ -101,49 +101,27 @@ const TxsTable = ({ classes }) => { - {!row.tx.creationTx && ( - - {expandedTx === row.safeTxHash ? : } - - )} + + {expandedTx === row.tx.safeTxHash ? : } + + + + + + ( + + )} + in={expandedTx === row.tx.safeTxHash} + timeout="auto" + unmountOnExit + /> - {!row.tx.creationTx && ( - - - ( - - )} - in={expandedTx === row.tx.safeTxHash} - timeout="auto" - unmountOnExit - /> - - - )} - {row.tx.creationTx && ( - - - ( - - )} - in={expandedTx === row.tx.safeTxHash} - timeout="auto" - unmountOnExit - /> - - - )} )) } diff --git a/src/routes/safe/container/hooks/useCheckForUpdates.tsx b/src/routes/safe/container/hooks/useCheckForUpdates.tsx index 407ff510..62a0da70 100644 --- a/src/routes/safe/container/hooks/useCheckForUpdates.tsx +++ b/src/routes/safe/container/hooks/useCheckForUpdates.tsx @@ -5,7 +5,7 @@ import fetchCollectibles from 'src/logic/collectibles/store/actions/fetchCollect import fetchSafeTokens from 'src/logic/tokens/store/actions/fetchSafeTokens' import fetchEtherBalance from 'src/routes/safe/store/actions/fetchEtherBalance' import { checkAndUpdateSafe } from 'src/routes/safe/store/actions/fetchSafe' -import fetchTransactions from 'src/routes/safe/store/actions/fetchTransactions' +import fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions' import { safeParamAddressFromStateSelector } from 'src/routes/safe/store/selectors' import { TIMEOUT } from 'src/utils/constants' diff --git a/src/routes/safe/container/hooks/useLoadSafe.tsx b/src/routes/safe/container/hooks/useLoadSafe.tsx index d80d149e..ae704154 100644 --- a/src/routes/safe/container/hooks/useLoadSafe.tsx +++ b/src/routes/safe/container/hooks/useLoadSafe.tsx @@ -6,7 +6,7 @@ import addViewedSafe from 'src/logic/currentSession/store/actions/addViewedSafe' import fetchSafeTokens from 'src/logic/tokens/store/actions/fetchSafeTokens' import fetchLatestMasterContractVersion from 'src/routes/safe/store/actions/fetchLatestMasterContractVersion' import fetchSafe from 'src/routes/safe/store/actions/fetchSafe' -import fetchTransactions from 'src/routes/safe/store/actions/fetchTransactions' +import fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions' import fetchSafeCreationTx from '../../store/actions/fetchSafeCreationTx' export const useLoadSafe = (safeAddress) => { @@ -16,14 +16,16 @@ export const useLoadSafe = (safeAddress) => { const fetchData = () => { if (safeAddress) { dispatch(fetchLatestMasterContractVersion()) - .then(() => dispatch(fetchSafe(safeAddress))) .then(() => { - dispatch(fetchSafeTokens(safeAddress)) + dispatch(fetchSafe(safeAddress)) + return dispatch(fetchSafeTokens(safeAddress)) + }) + .then(() => { dispatch(loadAddressBookFromStorage()) dispatch(fetchSafeCreationTx(safeAddress)) - return dispatch(fetchTransactions(safeAddress)) + dispatch(fetchTransactions(safeAddress)) + return dispatch(addViewedSafe(safeAddress)) }) - .then(() => dispatch(addViewedSafe(safeAddress))) } } fetchData() diff --git a/src/routes/safe/container/selector.ts b/src/routes/safe/container/selector.ts index 9c63b816..9c9fb0b4 100644 --- a/src/routes/safe/container/selector.ts +++ b/src/routes/safe/container/selector.ts @@ -1,4 +1,4 @@ -import { List, Map } from 'immutable' +import { Map } from 'immutable' import { createSelector } from 'reselect' import { tokensSelector } from 'src/logic/tokens/store/selectors' @@ -6,39 +6,7 @@ import { getEthAsToken } from 'src/logic/tokens/utils/tokenHelpers' import { isUserOwner } from 'src/logic/wallets/ethAddresses' import { userAccountSelector } from 'src/logic/wallets/store/selectors' -import { - safeActiveTokensSelector, - safeBalancesSelector, - safeCancellationTransactionsSelector, - safeIncomingTransactionsSelector, - safeSelector, - safeTransactionsSelector, -} from 'src/routes/safe/store/selectors' - -const getTxStatus = (tx, userAddress, safe) => { - let txStatus - if (tx.executionTxHash) { - txStatus = 'success' - } else if (tx.cancelled) { - txStatus = 'cancelled' - } else if (tx.confirmations.size === safe.threshold) { - txStatus = 'awaiting_execution' - } else if (tx.creationTx) { - txStatus = 'success' - } else if (!tx.confirmations.size) { - txStatus = 'pending' - } else { - const userConfirmed = tx.confirmations.filter((conf) => conf.owner === userAddress).size === 1 - const userIsSafeOwner = safe.owners.filter((owner) => owner.address === userAddress).size === 1 - txStatus = !userConfirmed && userIsSafeOwner ? 'awaiting_your_confirmation' : 'awaiting_confirmations' - } - - if (tx.isSuccessful === false) { - txStatus = 'failed' - } - - return txStatus -} +import { safeActiveTokensSelector, safeBalancesSelector, safeSelector } from 'src/routes/safe/store/selectors' export const grantedSelector = createSelector(userAccountSelector, safeSelector, (userAccount, safe) => isUserOwner(safe, userAccount), @@ -76,31 +44,3 @@ export const extendedSafeTokensSelector = createSelector( return extendedTokens.toList() }, ) - -export const extendedTransactionsSelector = createSelector( - safeSelector, - userAccountSelector, - safeTransactionsSelector, - safeCancellationTransactionsSelector, - safeIncomingTransactionsSelector, - (safe, userAddress, transactions, cancellationTransactions, incomingTransactions) => { - const cancellationTransactionsByNonce = cancellationTransactions.reduce((acc, tx) => acc.set(tx.nonce, tx), Map()) - const extendedTransactions = transactions.map((tx) => { - let extendedTx = tx - - if (!tx.isExecuted) { - if ( - (cancellationTransactionsByNonce.get(tx.nonce) && - cancellationTransactionsByNonce.get(tx.nonce).get('isExecuted')) || - transactions.find((safeTx) => tx.nonce === safeTx.nonce && safeTx.isExecuted) - ) { - extendedTx = tx.set('cancelled', true) - } - } - - return extendedTx.set('status', getTxStatus(extendedTx, userAddress, safe)) - }) - - return List([...extendedTransactions, ...incomingTransactions]) - }, -) diff --git a/src/routes/safe/store/actions/addCancellationTransactions.ts b/src/routes/safe/store/actions/addCancellationTransactions.ts deleted file mode 100644 index 6d91ef5d..00000000 --- a/src/routes/safe/store/actions/addCancellationTransactions.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAction } from 'redux-actions' - -export const ADD_CANCELLATION_TRANSACTIONS = 'ADD_CANCELLATION_TRANSACTIONS' - -export const addCancellationTransactions = createAction(ADD_CANCELLATION_TRANSACTIONS) diff --git a/src/routes/safe/store/actions/addTransactions.ts b/src/routes/safe/store/actions/addTransactions.ts deleted file mode 100644 index 5dc28279..00000000 --- a/src/routes/safe/store/actions/addTransactions.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAction } from 'redux-actions' - -export const ADD_TRANSACTIONS = 'ADD_TRANSACTIONS' - -export const addTransactions = createAction(ADD_TRANSACTIONS) diff --git a/src/routes/safe/store/actions/createTransaction.ts b/src/routes/safe/store/actions/createTransaction.ts index bbdc3f68..be558b0e 100644 --- a/src/routes/safe/store/actions/createTransaction.ts +++ b/src/routes/safe/store/actions/createTransaction.ts @@ -1,21 +1,82 @@ import { push } from 'connected-react-router' +import { List, Map } from 'immutable' +import { batch } from 'react-redux' import semverSatisfies from 'semver/functions/satisfies' import { onboardUser } from 'src/components/ConnectButton' import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts' import { getNotificationsFromTxType, showSnackbar } from 'src/logic/notifications' -import { CALL, getApprovalTransaction, getExecutionTransaction, saveTxToHistory } from 'src/logic/safe/transactions' +import { + CALL, + getApprovalTransaction, + getExecutionTransaction, + SAFE_VERSION_FOR_OFFCHAIN_SIGNATURES, + saveTxToHistory, + tryOffchainSigning, +} from 'src/logic/safe/transactions' import { estimateSafeTxGas } from 'src/logic/safe/transactions/gasNew' -import { SAFE_VERSION_FOR_OFFCHAIN_SIGNATURES, tryOffchainSigning } from 'src/logic/safe/transactions/offchainSigner' import { getCurrentSafeVersion } from 'src/logic/safe/utils/safeVersion' import { ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses' import { EMPTY_DATA } from 'src/logic/wallets/ethTransactions' import { providerSelector } from 'src/logic/wallets/store/selectors' import { SAFELIST_ADDRESS } from 'src/routes/routes' -import fetchTransactions from 'src/routes/safe/store/actions/fetchTransactions' +import { addOrUpdateCancellationTransactions } from 'src/routes/safe/store/actions/transactions/addOrUpdateCancellationTransactions' +import { addOrUpdateTransactions } from 'src/routes/safe/store/actions/transactions/addOrUpdateTransactions' +import { removeCancellationTransaction } from 'src/routes/safe/store/actions/transactions/removeCancellationTransaction' +import { removeTransaction } from 'src/routes/safe/store/actions/transactions/removeTransaction' +import { + generateSafeTxHash, + mockTransaction, +} from 'src/routes/safe/store/actions/transactions/utils/transactionHelpers' import { getLastTx, getNewTxNonce, shouldExecuteTransaction } from 'src/routes/safe/store/actions/utils' - import { getErrorMessage } from 'src/test/utils/ethereumErrors' +import { makeConfirmation } from '../models/confirmation' +import fetchTransactions from './transactions/fetchTransactions' +import { safeTransactionsSelector } from 'src/routes/safe/store/selectors' +import { TransactionStatus } from 'src/routes/safe/store/models/types/transaction' + +export const removeTxFromStore = (tx, safeAddress, dispatch, state) => { + if (tx.isCancellationTx) { + const newTxStatus = TransactionStatus.AWAITING_YOUR_CONFIRMATION + const transactions = safeTransactionsSelector(state) + const txsToUpdate = transactions + .filter((transaction) => Number(transaction.nonce) === Number(tx.nonce)) + .withMutations((list) => list.map((tx) => tx.set('status', newTxStatus))) + + batch(() => { + dispatch(addOrUpdateTransactions({ safeAddress, transactions: txsToUpdate })) + dispatch(removeCancellationTransaction({ safeAddress, transaction: tx })) + }) + } else { + dispatch(removeTransaction({ safeAddress, transaction: tx })) + } +} + +export const storeTx = async (tx, safeAddress, dispatch, state) => { + if (tx.isCancellationTx) { + let newTxStatus: TransactionStatus = TransactionStatus.AWAITING_YOUR_CONFIRMATION + + if (tx.isExecuted) { + newTxStatus = TransactionStatus.CANCELLED + } else if (tx.status === TransactionStatus.PENDING) { + newTxStatus = tx.status + } + + const transactions = safeTransactionsSelector(state) + const txsToUpdate = transactions + .filter((transaction) => Number(transaction.nonce) === Number(tx.nonce)) + .withMutations((list) => + list.map((tx) => tx.set('status', newTxStatus).set('cancelled', newTxStatus === TransactionStatus.CANCELLED)), + ) + + batch(() => { + dispatch(addOrUpdateCancellationTransactions({ safeAddress, transactions: Map({ [`${tx.nonce}`]: tx }) })) + dispatch(addOrUpdateTransactions({ safeAddress, transactions: txsToUpdate })) + }) + } else { + dispatch(addOrUpdateTransactions({ safeAddress, transactions: List([tx]) })) + } +} const createTransaction = ({ safeAddress, @@ -42,7 +103,7 @@ const createTransaction = ({ const { account: from, hardwareWallet, smartContractWallet } = providerSelector(state) const safeInstance = await getGnosisSafeInstanceAt(safeAddress) const lastTx = await getLastTx(safeAddress) - const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance) + const nonce = Number(await getNewTxNonce(txNonce, lastTx, safeInstance)) const isExecution = await shouldExecuteTransaction(safeInstance, nonce, lastTx) const safeVersion = await getCurrentSafeVersion(safeInstance) const safeTxGas = await estimateSafeTxGas(safeInstance, safeAddress, txData, to, valueInWei, operation) @@ -78,9 +139,6 @@ const createTransaction = ({ try { // Here we're checking that safe contract version is greater or equal 1.1.1, but // theoretically EIP712 should also work for 1.0.0 contracts - // Also, offchain signatures are not working for ledger/trezor wallet because of a bug in their library: - // https://github.com/LedgerHQ/ledgerjs/issues/378 - // Couldn't find an issue for trezor but the error is almost the same const canTryOffchainSigning = !isExecution && !smartContractWallet && semverSatisfies(safeVersion, SAFE_VERSION_FOR_OFFCHAIN_SIGNATURES) if (canTryOffchainSigning) { @@ -89,11 +147,7 @@ const createTransaction = ({ if (signature) { closeSnackbar(beforeExecutionKey) - await saveTxToHistory({ - ...txArgs, - signature, - origin, - } as any) + await saveTxToHistory({ ...txArgs, signature, origin }) showSnackbar(notificationsQueue.afterExecution.moreConfirmationsNeeded, enqueueSnackbar, closeSnackbar) dispatch(fetchTransactions(safeAddress)) @@ -110,30 +164,50 @@ const createTransaction = ({ sendParams.gas = '7000000' } + const txToMock = { + ...txArgs, + confirmations: [], // this is used to determine if a tx is pending or not. See `calculateTransactionStatus` helper + value: txArgs.valueInWei, + safeTxHash: generateSafeTxHash(safeAddress, txArgs), + } + const mockedTx = await mockTransaction(txToMock, safeAddress, state) + await tx .send(sendParams) .once('transactionHash', async (hash) => { - txHash = hash - closeSnackbar(beforeExecutionKey) - - pendingExecutionKey = showSnackbar(notificationsQueue.pendingExecution, enqueueSnackbar, closeSnackbar) - try { - await saveTxToHistory({ - ...txArgs, - txHash, - origin, - } as any) + txHash = hash + closeSnackbar(beforeExecutionKey) + + pendingExecutionKey = showSnackbar(notificationsQueue.pendingExecution, enqueueSnackbar, closeSnackbar) + + await Promise.all([ + saveTxToHistory({ ...txArgs, txHash, origin }), + storeTx( + mockedTx.updateIn( + ['ownersWithPendingActions', mockedTx.isCancellationTx ? 'reject' : 'confirm'], + (previous) => previous.push(from), + ), + safeAddress, + dispatch, + state, + ), + ]) dispatch(fetchTransactions(safeAddress)) - } catch (err) { - console.error(err) + } catch (e) { + removeTxFromStore(mockedTx, safeAddress, dispatch, state) } }) .on('error', (error) => { + closeSnackbar(pendingExecutionKey) + removeTxFromStore(mockedTx, safeAddress, dispatch, state) console.error('Tx error: ', error) }) - .then((receipt) => { - closeSnackbar(pendingExecutionKey) + .then(async (receipt) => { + if (pendingExecutionKey) { + closeSnackbar(pendingExecutionKey) + } + showSnackbar( isExecution ? notificationsQueue.afterExecution.noMoreConfirmationsNeeded @@ -142,6 +216,30 @@ const createTransaction = ({ closeSnackbar, ) + const toStoreTx = isExecution + ? mockedTx.withMutations((record) => { + record + .set('executionTxHash', receipt.transactionHash) + .set('executor', from) + .set('isExecuted', true) + .set('isSuccessful', receipt.status) + .set('status', receipt.status ? 'success' : 'failed') + }) + : mockedTx.set('status', 'awaiting_confirmations') + + await storeTx( + toStoreTx.withMutations((record) => { + record + .set('confirmations', List([makeConfirmation({ owner: from })])) + .updateIn(['ownersWithPendingActions', toStoreTx.isCancellationTx ? 'reject' : 'confirm'], (previous) => + previous.pop(from), + ) + }), + safeAddress, + dispatch, + state, + ) + dispatch(fetchTransactions(safeAddress)) return receipt.transactionHash @@ -149,7 +247,11 @@ const createTransaction = ({ } catch (err) { console.error(err) closeSnackbar(beforeExecutionKey) - closeSnackbar(pendingExecutionKey) + + if (pendingExecutionKey) { + closeSnackbar(pendingExecutionKey) + } + showSnackbar(notificationsQueue.afterExecutionError, enqueueSnackbar, closeSnackbar) const executeDataUsedSignatures = safeInstance.contract.methods diff --git a/src/routes/safe/store/actions/fetchSafeCreationTx.ts b/src/routes/safe/store/actions/fetchSafeCreationTx.ts index 99a72a8b..928d3b57 100644 --- a/src/routes/safe/store/actions/fetchSafeCreationTx.ts +++ b/src/routes/safe/store/actions/fetchSafeCreationTx.ts @@ -1,9 +1,11 @@ -// @flow import axios from 'axios' import { List } from 'immutable' -import { buildSafeCreationTxUrl } from '../../../../config' + +import { buildSafeCreationTxUrl } from 'src/config' import { addOrUpdateTransactions } from './transactions/addOrUpdateTransactions' -import { makeTransaction } from '../models/transaction' +import { makeTransaction } from 'src/routes/safe/store/models/transaction' +import { TransactionTypes, TransactionStatus } from 'src/routes/safe/store/models/types/transaction' +import { web3ReadOnly } from 'src/logic/wallets/getWeb3' const getCreationTx = async (safeAddress) => { const url = buildSafeCreationTxUrl(safeAddress) @@ -29,17 +31,21 @@ const fetchSafeCreationTx = (safeAddress) => async (dispatch) => { transactionHash, type, } = creationTxFetched - const txType = type || 'creation' + const txType = type || TransactionTypes.CREATION + const safeTxHash = web3ReadOnly.utils.toHex('this is the creation transaction') const creationTxAsRecord = makeTransaction({ created, creator, factoryAddress, masterCopy, + nonce: -1, setupData, creationTx, executionTxHash: transactionHash, type: txType, + safeTxHash, + status: TransactionStatus.SUCCESS, submissionDate: created, }) diff --git a/src/routes/safe/store/actions/fetchTransactions.ts b/src/routes/safe/store/actions/fetchTransactions.ts deleted file mode 100644 index de647bc0..00000000 --- a/src/routes/safe/store/actions/fetchTransactions.ts +++ /dev/null @@ -1,322 +0,0 @@ -import ERC20Detailed from '@openzeppelin/contracts/build/contracts/ERC20Detailed.json' -import axios from 'axios' -import bn from 'bignumber.js' -import { List, Map } from 'immutable' -import { batch } from 'react-redux' - -import { addIncomingTransactions } from './addIncomingTransactions' - -import generateBatchRequests from 'src/logic/contracts/generateBatchRequests' -import { decodeParamsFromSafeMethod } from 'src/logic/contracts/methodIds' -import { buildIncomingTxServiceUrl } from 'src/logic/safe/transactions/incomingTxHistory' -import { buildTxServiceUrl } from 'src/logic/safe/transactions/txHistory' -import { TOKEN_REDUCER_ID } from 'src/logic/tokens/store/reducer/tokens' -import { ALTERNATIVE_TOKEN_ABI } from 'src/logic/tokens/utils/alternativeAbi' -import { - SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH, - isMultisendTransaction, - isTokenTransfer, - isUpgradeTransaction, -} from 'src/logic/tokens/utils/tokenHelpers' -import { ZERO_ADDRESS, sameAddress } from 'src/logic/wallets/ethAddresses' -import { EMPTY_DATA } from 'src/logic/wallets/ethTransactions' -import { getWeb3 } from 'src/logic/wallets/getWeb3' -import { addCancellationTransactions } from 'src/routes/safe/store/actions/addCancellationTransactions' -import { makeConfirmation } from 'src/routes/safe/store/models/confirmation' -import { makeIncomingTransaction } from 'src/routes/safe/store/models/incomingTransaction' -import { addOrUpdateTransactions } from './transactions/addOrUpdateTransactions' -import { makeTransaction } from '../models/transaction' - -let web3 - -export const buildTransactionFrom = async ( - safeAddress, - knownTokens, - tx, - txTokenCode, - txTokenDecimals, - txTokenName, - txTokenSymbol, -) => { - const confirmations = List( - tx.confirmations.map((conf) => - makeConfirmation({ - owner: conf.owner, - type: conf.confirmationType.toLowerCase(), - hash: conf.transactionHash, - signature: conf.signature, - }), - ), - ) - const modifySettingsTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !!tx.data - const cancellationTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !tx.data - const isERC721Token = - (txTokenCode && txTokenCode.includes(SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH)) || - (isTokenTransfer(tx.data, Number(tx.value)) && !knownTokens.get(tx.to) && txTokenDecimals !== null) - let isSendTokenTx = !isERC721Token && isTokenTransfer(tx.data, Number(tx.value)) - const isMultiSendTx = isMultisendTransaction(tx.data, Number(tx.value)) - const isUpgradeTx = isMultiSendTx && isUpgradeTransaction(tx.data) - let customTx = !sameAddress(tx.to, safeAddress) && !!tx.data && !isSendTokenTx && !isUpgradeTx && !isERC721Token - - let refundParams = null - if (tx.gasPrice > 0) { - const refundSymbol = txTokenSymbol || 'ETH' - const decimals = txTokenDecimals || 18 - const feeString = (tx.gasPrice * (tx.baseGas + tx.safeTxGas)).toString().padStart(decimals, '0') - const whole = feeString.slice(0, feeString.length - decimals) || '0' - const fraction = feeString.slice(feeString.length - decimals) - - const formattedFee = `${whole}.${fraction}` - refundParams = { - fee: formattedFee, - symbol: refundSymbol, - } - } - - let symbol = txTokenSymbol || 'ETH' - let decimals = txTokenDecimals || 18 - let decodedParams - if (isSendTokenTx) { - if (txTokenSymbol === null || txTokenDecimals === null) { - try { - const [tokenSymbol, tokenDecimals] = await Promise.all( - generateBatchRequests({ - abi: ALTERNATIVE_TOKEN_ABI, - address: tx.to, - methods: ['symbol', 'decimals'], - }), - ) - - symbol = tokenSymbol - decimals = tokenDecimals - } catch (e) { - // some contracts may implement the same methods as in ERC20 standard - // we may falsely treat them as tokens, so in case we get any errors when getting token info - // we fallback to displaying custom transaction - isSendTokenTx = false - customTx = true - } - } - - const params = web3.eth.abi.decodeParameters(['address', 'uint256'], tx.data.slice(10)) - decodedParams = { - recipient: params[0], - value: params[1], - } - } else if (modifySettingsTx && tx.data) { - decodedParams = decodeParamsFromSafeMethod(tx.data) - } else if (customTx && tx.data) { - decodedParams = decodeParamsFromSafeMethod(tx.data) - } - - return makeTransaction({ - symbol, - nonce: tx.nonce, - blockNumber: tx.blockNumber, - value: tx.value.toString(), - confirmations, - decimals, - recipient: tx.to, - data: tx.data ? tx.data : EMPTY_DATA, - operation: tx.operation, - safeTxGas: tx.safeTxGas, - baseGas: tx.baseGas, - gasPrice: tx.gasPrice, - gasToken: tx.gasToken || ZERO_ADDRESS, - refundReceiver: tx.refundReceiver || ZERO_ADDRESS, - refundParams, - isExecuted: tx.isExecuted, - isSuccessful: tx.isSuccessful, - submissionDate: tx.submissionDate, - executor: tx.executor, - executionDate: tx.executionDate, - executionTxHash: tx.transactionHash, - safeTxHash: tx.safeTxHash, - isTokenTransfer: isSendTokenTx, - multiSendTx: isMultiSendTx, - upgradeTx: isUpgradeTx, - decodedParams, - modifySettingsTx, - customTx, - cancellationTx, - creationTx: tx.creationTx, - origin: tx.origin, - }) -} - -const batchRequestTxsData = (txs: any[]) => { - const web3Batch = new web3.BatchRequest() - - const txsTokenInfo = txs.map((tx) => { - const methods = [{ method: 'getCode', type: 'eth', args: [tx.to] }, 'decimals', 'name', 'symbol'] - return generateBatchRequests({ - abi: ERC20Detailed.abi, - address: tx.to, - batch: web3Batch, - context: tx, - methods, - }) - }) - - web3Batch.execute() - - return Promise.all(txsTokenInfo) -} - -const batchRequestIncomingTxsData = (txs) => { - const web3Batch = new web3.BatchRequest() - - const whenTxsValues = txs.map((tx) => { - const methods = ['symbol', 'decimals', { method: 'getTransaction', args: [tx.transactionHash], type: 'eth' }] - - return generateBatchRequests({ - abi: ALTERNATIVE_TOKEN_ABI, - address: tx.tokenAddress, - batch: web3Batch, - context: tx, - methods, - }) - }) - - web3Batch.execute() - - return Promise.all(whenTxsValues).then((txsValues) => - txsValues.map(([tx, symbol, decimals, { gas, gasPrice }]) => [ - tx, - symbol === null ? 'ETH' : symbol, - decimals === null ? '18' : decimals, - new bn(gas).div(gasPrice).toFixed(), - ]), - ) -} - -export const buildIncomingTransactionFrom = ([tx, symbol, decimals, fee]) => { - // this is a particular treatment for the DCD token, as it seems to lack of symbol and decimal methods - if (tx.tokenAddress && tx.tokenAddress.toLowerCase() === '0xe0b7927c4af23765cb51314a0e0521a9645f0e2a') { - symbol = 'DCD' - decimals = '9' - } - - const { transactionHash, ...incomingTx } = tx - - return makeIncomingTransaction({ - ...incomingTx, - symbol, - decimals, - fee, - executionTxHash: transactionHash, - safeTxHash: transactionHash, - }) -} - -let etagSafeTransactions = null -let etagCachedSafeIncomingTransactions = null -export const loadSafeTransactions = async (safeAddress, getState) => { - let transactions = [] - - try { - const config = etagSafeTransactions - ? { - headers: { - 'If-None-Match': etagSafeTransactions, - }, - } - : undefined - - const url = buildTxServiceUrl(safeAddress) - const response = await axios.get(url, config) - if (response.data.count > 0) { - if (etagSafeTransactions === response.headers.etag) { - // The txs are the same, we can return the cached ones - return - } - transactions = transactions.concat(response.data.results) - etagSafeTransactions = response.headers.etag - } - } catch (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) - } - } - - const state = getState() - const knownTokens = state[TOKEN_REDUCER_ID] - const txsWithData = await batchRequestTxsData(transactions) - // In case that the etags don't match, we parse the new transactions and save them to the cache - const txsRecord = await Promise.all( - txsWithData.map(([tx, code, decimals, name, symbol]) => { - const knownToken = knownTokens.get(tx.to) - - if (knownToken) { - ;({ decimals, name, symbol } = knownToken) - } - - return buildTransactionFrom(safeAddress, knownTokens, tx, code, decimals, name, symbol) - }), - ) - - const groupedTxs = List(txsRecord).groupBy((tx) => (tx.get('cancellationTx') ? 'cancel' : 'outgoing')) - - return { - outgoing: groupedTxs.get('outgoing') || List([]), - cancel: Map().set(safeAddress, groupedTxs.get('cancel')), - } -} - -export const loadSafeIncomingTransactions = async (safeAddress) => { - let incomingTransactions = [] - try { - const config = etagCachedSafeIncomingTransactions - ? { - headers: { - 'If-None-Match': etagCachedSafeIncomingTransactions, - }, - } - : undefined - const url = buildIncomingTxServiceUrl(safeAddress) - const response = await axios.get(url, config) - if (response.data.count > 0) { - incomingTransactions = response.data.results - if (etagCachedSafeIncomingTransactions === response.headers.etag) { - // The txs are the same, we can return the cached ones - return - } - etagCachedSafeIncomingTransactions = response.headers.etag - } - } catch (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 incomingTxsWithData = await batchRequestIncomingTxsData(incomingTransactions) - const incomingTxsRecord = incomingTxsWithData.map(buildIncomingTransactionFrom) - return Map().set(safeAddress, List(incomingTxsRecord)) -} - -export default (safeAddress) => async (dispatch, getState) => { - web3 = await getWeb3() - - const transactions = await loadSafeTransactions(safeAddress, getState) - if (transactions) { - const { cancel, outgoing } = transactions - - batch(() => { - dispatch(addCancellationTransactions(cancel)) - dispatch(addOrUpdateTransactions({ safeAddress, transactions: outgoing })) - }) - } - - const incomingTransactions = await loadSafeIncomingTransactions(safeAddress) - - if (incomingTransactions) { - dispatch(addIncomingTransactions(incomingTransactions)) - } -} diff --git a/src/routes/safe/store/actions/processTransaction.ts b/src/routes/safe/store/actions/processTransaction.ts index c5af9c34..1d9556db 100644 --- a/src/routes/safe/store/actions/processTransaction.ts +++ b/src/routes/safe/store/actions/processTransaction.ts @@ -1,3 +1,4 @@ +import { fromJS } from 'immutable' import semverSatisfies from 'semver/functions/satisfies' import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts' @@ -8,10 +9,16 @@ import { SAFE_VERSION_FOR_OFFCHAIN_SIGNATURES, tryOffchainSigning } from 'src/lo import { getCurrentSafeVersion } from 'src/logic/safe/utils/safeVersion' import { providerSelector } from 'src/logic/wallets/store/selectors' import fetchSafe from 'src/routes/safe/store/actions/fetchSafe' -import fetchTransactions from 'src/routes/safe/store/actions/fetchTransactions' +import fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions' +import { + isCancelTransaction, + mockTransaction, +} from 'src/routes/safe/store/actions/transactions/utils/transactionHelpers' import { getLastTx, getNewTxNonce, shouldExecuteTransaction } from 'src/routes/safe/store/actions/utils' import { getErrorMessage } from 'src/test/utils/ethereumErrors' +import { makeConfirmation } from '../models/confirmation' +import { storeTx } from './createTransaction' const processTransaction = ({ approveAndExecute, @@ -47,6 +54,7 @@ const processTransaction = ({ let txHash let transaction const txArgs = { + ...tx.toJS(), // merge the previous tx with new data safeInstance, to: tx.recipient, valueInWei: tx.value, @@ -76,11 +84,7 @@ const processTransaction = ({ if (signature) { closeSnackbar(beforeExecutionKey) - await saveTxToHistory({ - ...txArgs, - signature, - origin, - } as any) + await saveTxToHistory({ ...txArgs, signature }) showSnackbar(notificationsQueue.afterExecution.moreConfirmationsNeeded, enqueueSnackbar, closeSnackbar) dispatch(fetchTransactions(safeAddress)) @@ -97,6 +101,13 @@ const processTransaction = ({ sendParams.gas = '7000000' } + const txToMock = { + ...txArgs, + confirmations: [], // this is used to determine if a tx is pending or not. See `calculateTransactionStatus` helper + value: txArgs.valueInWei, + } + const mockedTx = await mockTransaction(txToMock, safeAddress, state) + await transaction .send(sendParams) .once('transactionHash', async (hash) => { @@ -106,20 +117,34 @@ const processTransaction = ({ pendingExecutionKey = showSnackbar(notificationsQueue.pendingExecution, enqueueSnackbar, closeSnackbar) try { - await saveTxToHistory({ - ...txArgs, - txHash, - }) + await Promise.all([ + saveTxToHistory({ ...txArgs, txHash }), + storeTx( + mockedTx.updateIn( + ['ownersWithPendingActions', mockedTx.isCancellationTx ? 'reject' : 'confirm'], + (previous) => previous.push(from), + ), + safeAddress, + dispatch, + state, + ), + ]) dispatch(fetchTransactions(safeAddress)) - } catch (err) { - console.error(err) + } catch (e) { + closeSnackbar(pendingExecutionKey) + await storeTx(tx, safeAddress, dispatch, state) + console.error(e) } }) .on('error', (error) => { + closeSnackbar(pendingExecutionKey) + storeTx(tx, safeAddress, dispatch, state) console.error('Processing transaction error: ', error) }) - .then((receipt) => { - closeSnackbar(pendingExecutionKey) + .then(async (receipt) => { + if (pendingExecutionKey) { + closeSnackbar(pendingExecutionKey) + } showSnackbar( isExecution @@ -128,6 +153,37 @@ const processTransaction = ({ enqueueSnackbar, closeSnackbar, ) + + const toStoreTx = isExecution + ? mockedTx.withMutations((record) => { + record + .set('executionTxHash', receipt.transactionHash) + .set('blockNumber', receipt.blockNumber) + .set('executionDate', record.submissionDate) + .set('executor', from) + .set('isExecuted', true) + .set('isSuccessful', receipt.status) + .set( + 'status', + receipt.status ? (isCancelTransaction(record, safeAddress) ? 'cancelled' : 'success') : 'failed', + ) + .updateIn(['ownersWithPendingActions', 'reject'], (prev) => prev.clear()) + }) + : mockedTx.set('status', 'awaiting_confirmations') + + await storeTx( + toStoreTx.withMutations((record) => { + record + .set('confirmations', fromJS([...tx.confirmations, makeConfirmation({ owner: from })])) + .updateIn(['ownersWithPendingActions', toStoreTx.isCancellationTx ? 'reject' : 'confirm'], (previous) => + previous.pop(from), + ) + }), + safeAddress, + dispatch, + state, + ) + dispatch(fetchTransactions(safeAddress)) if (isExecution) { @@ -138,13 +194,20 @@ const processTransaction = ({ }) } catch (err) { console.error(err) - closeSnackbar(beforeExecutionKey) - closeSnackbar(pendingExecutionKey) - showSnackbar(notificationsQueue.afterExecutionError, enqueueSnackbar, closeSnackbar) - const executeData = safeInstance.contract.methods.approveHash(txHash).encodeABI() - const errMsg = await getErrorMessage(safeInstance.address, 0, executeData, from) - console.error(`Error executing the TX: ${errMsg}`) + if (txHash !== undefined) { + closeSnackbar(beforeExecutionKey) + + if (pendingExecutionKey) { + closeSnackbar(pendingExecutionKey) + } + + showSnackbar(notificationsQueue.afterExecutionError, enqueueSnackbar, closeSnackbar) + + const executeData = safeInstance.contract.methods.approveHash(txHash).encodeABI() + const errMsg = await getErrorMessage(safeInstance.address, 0, executeData, from) + console.error(`Error executing the TX: ${errMsg}`) + } } return txHash diff --git a/src/routes/safe/store/actions/transactions/addOrUpdateCancellationTransactions.ts b/src/routes/safe/store/actions/transactions/addOrUpdateCancellationTransactions.ts new file mode 100644 index 00000000..cf156eed --- /dev/null +++ b/src/routes/safe/store/actions/transactions/addOrUpdateCancellationTransactions.ts @@ -0,0 +1,5 @@ +import { createAction } from 'redux-actions' + +export const ADD_OR_UPDATE_CANCELLATION_TRANSACTIONS = 'ADD_OR_UPDATE_CANCELLATION_TRANSACTIONS' + +export const addOrUpdateCancellationTransactions = createAction(ADD_OR_UPDATE_CANCELLATION_TRANSACTIONS) diff --git a/src/routes/safe/store/actions/transactions/addOrUpdateTransactions.ts b/src/routes/safe/store/actions/transactions/addOrUpdateTransactions.ts index ce167f40..8d6cb275 100644 --- a/src/routes/safe/store/actions/transactions/addOrUpdateTransactions.ts +++ b/src/routes/safe/store/actions/transactions/addOrUpdateTransactions.ts @@ -1,4 +1,3 @@ -// @flow import { createAction } from 'redux-actions' export const ADD_OR_UPDATE_TRANSACTIONS = 'ADD_OR_UPDATE_TRANSACTIONS' diff --git a/src/routes/safe/store/actions/transactions/fetchTransactions/fetchTransactions.ts b/src/routes/safe/store/actions/transactions/fetchTransactions/fetchTransactions.ts new file mode 100644 index 00000000..224c7c81 --- /dev/null +++ b/src/routes/safe/store/actions/transactions/fetchTransactions/fetchTransactions.ts @@ -0,0 +1,58 @@ +import axios from 'axios' + +import { buildTxServiceUrl } from 'src/logic/safe/transactions' +import { buildIncomingTxServiceUrl } from 'src/logic/safe/transactions/incomingTxHistory' +import { TxServiceModel } from 'src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions' +import { IncomingTxServiceModel } from 'src/routes/safe/store/actions/transactions/fetchTransactions/loadIncomingTransactions' +import { TransactionTypes } from 'src/routes/safe/store/models/types/transaction' + +const getServiceUrl = (txType: string, safeAddress: string): string => { + return { + [TransactionTypes.INCOMING]: buildIncomingTxServiceUrl, + [TransactionTypes.OUTGOING]: buildTxServiceUrl, + }[txType](safeAddress) +} + +async function fetchTransactions( + txType: TransactionTypes.INCOMING, + safeAddress: string, + eTag: string | null, +): Promise<{ eTag: string | null; results: IncomingTxServiceModel[] }> +async function fetchTransactions( + txType: TransactionTypes.OUTGOING, + safeAddress: string, + eTag: string | null, +): Promise<{ eTag: string | null; results: TxServiceModel[] }> +async function fetchTransactions( + txType: TransactionTypes.INCOMING | TransactionTypes.OUTGOING, + safeAddress: string, + eTag: string | null, +): Promise<{ eTag: string; results: TxServiceModel[] | IncomingTxServiceModel[] }> { + try { + const url = getServiceUrl(txType, safeAddress) + const response = await axios.get(url, eTag ? { headers: { 'If-None-Match': eTag } } : undefined) + + if (response.data.count > 0) { + const { etag } = response.headers + + if (eTag !== etag) { + return { + eTag: etag, + results: response.data.results, + } + } + } + } catch (err) { + if (!(err && err.response && err.response.status === 304)) { + console.error(`Requests for outgoing transactions for ${safeAddress || 'unknown'} failed with 404`, err) + } else { + // 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) + } + } + + // defaults to an empty array to avoid type errors + return { eTag, results: [] } +} + +export default fetchTransactions diff --git a/src/routes/safe/store/actions/transactions/fetchTransactions/index.ts b/src/routes/safe/store/actions/transactions/fetchTransactions/index.ts new file mode 100644 index 00000000..6e20a5c4 --- /dev/null +++ b/src/routes/safe/store/actions/transactions/fetchTransactions/index.ts @@ -0,0 +1,34 @@ +import { batch } from 'react-redux' + +import { addIncomingTransactions } from '../../addIncomingTransactions' + +import { loadIncomingTransactions } from './loadIncomingTransactions' +import { loadOutgoingTransactions } from './loadOutgoingTransactions' + +import { addOrUpdateCancellationTransactions } from 'src/routes/safe/store/actions/transactions/addOrUpdateCancellationTransactions' +import { addOrUpdateTransactions } from 'src/routes/safe/store/actions/transactions/addOrUpdateTransactions' + +const noFunc = () => {} + +export default (safeAddress: string) => async (dispatch) => { + const transactions = await loadOutgoingTransactions(safeAddress) + + if (transactions) { + const { cancel, outgoing } = transactions + const updateCancellationTxs = cancel.size + ? addOrUpdateCancellationTransactions({ safeAddress, transactions: cancel }) + : noFunc + const updateOutgoingTxs = outgoing.size ? addOrUpdateTransactions({ safeAddress, transactions: outgoing }) : noFunc + + batch(() => { + dispatch(updateCancellationTxs) + dispatch(updateOutgoingTxs) + }) + } + + const incomingTransactions = await loadIncomingTransactions(safeAddress) + + if (incomingTransactions.get(safeAddress).size) { + dispatch(addIncomingTransactions(incomingTransactions)) + } +} diff --git a/src/routes/safe/store/actions/transactions/fetchTransactions/loadIncomingTransactions.ts b/src/routes/safe/store/actions/transactions/fetchTransactions/loadIncomingTransactions.ts new file mode 100644 index 00000000..5bcb104c --- /dev/null +++ b/src/routes/safe/store/actions/transactions/fetchTransactions/loadIncomingTransactions.ts @@ -0,0 +1,79 @@ +import bn from 'bignumber.js' +import { List, Map } from 'immutable' + +import generateBatchRequests from 'src/logic/contracts/generateBatchRequests' +import { ALTERNATIVE_TOKEN_ABI } from 'src/logic/tokens/utils/alternativeAbi' +import { web3ReadOnly } from 'src/logic/wallets/getWeb3' +import { makeIncomingTransaction } from 'src/routes/safe/store/models/incomingTransaction' +import fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions/fetchTransactions' +import { TransactionTypes } from 'src/routes/safe/store/models/types/transaction' + +export type IncomingTxServiceModel = { + blockNumber: number + transactionHash: string + to: string + value: number + tokenAddress: string + from: string +} + +const buildIncomingTransactionFrom = ([tx, symbol, decimals, fee]: [ + IncomingTxServiceModel, + string, + number, + string, +]) => { + // this is a particular treatment for the DCD token, as it seems to lack of symbol and decimal methods + if (tx.tokenAddress && tx.tokenAddress.toLowerCase() === '0xe0b7927c4af23765cb51314a0e0521a9645f0e2a') { + symbol = 'DCD' + decimals = 9 + } + + const { transactionHash, ...incomingTx } = tx + + return makeIncomingTransaction({ + ...incomingTx, + symbol, + decimals, + fee, + executionTxHash: transactionHash, + safeTxHash: transactionHash, + }) +} + +const batchIncomingTxsTokenDataRequest = (txs: IncomingTxServiceModel[]) => { + const batch = new web3ReadOnly.BatchRequest() + + const whenTxsValues = txs.map((tx) => { + const methods = ['symbol', 'decimals', { method: 'getTransaction', args: [tx.transactionHash], type: 'eth' }] + + return generateBatchRequests({ + abi: ALTERNATIVE_TOKEN_ABI, + address: tx.tokenAddress, + batch, + context: tx, + methods, + }) + }) + + batch.execute() + + return Promise.all(whenTxsValues).then((txsValues) => + txsValues.map(([tx, symbol, decimals, { gas, gasPrice }]) => [ + tx, + symbol === null ? 'ETH' : symbol, + decimals === null ? '18' : decimals, + new bn(gas).div(gasPrice).toFixed(), + ]), + ) +} + +let previousETag = null +export const loadIncomingTransactions = async (safeAddress: string) => { + const { eTag, results } = await fetchTransactions(TransactionTypes.INCOMING, safeAddress, previousETag) + previousETag = eTag + + const incomingTxsWithData = await batchIncomingTxsTokenDataRequest(results) + const incomingTxsRecord = incomingTxsWithData.map(buildIncomingTransactionFrom) + return Map({ [safeAddress]: List(incomingTxsRecord) }) +} diff --git a/src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions.ts b/src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions.ts new file mode 100644 index 00000000..409453f8 --- /dev/null +++ b/src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions.ts @@ -0,0 +1,212 @@ +import { fromJS, List, Map } from 'immutable' + +import generateBatchRequests from 'src/logic/contracts/generateBatchRequests' +import { TOKEN_REDUCER_ID } from 'src/logic/tokens/store/reducer/tokens' +import { web3ReadOnly } from 'src/logic/wallets/getWeb3' +import { PROVIDER_REDUCER_ID } from 'src/logic/wallets/store/reducer/provider' +import { buildTx, isCancelTransaction } from 'src/routes/safe/store/actions/transactions/utils/transactionHelpers' +import { SAFE_REDUCER_ID } from 'src/routes/safe/store/reducer/safe' +import { store } from 'src/store' +import { DecodedMethods } from 'src/logic/contracts/methodIds' +import fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions/fetchTransactions' +import { TransactionTypes } from 'src/routes/safe/store/models/types/transaction' + +export type ConfirmationServiceModel = { + owner: string + submissionDate: Date + signature: string + transactionHash: string +} + +export type TxServiceModel = { + baseGas: number + blockNumber?: number | null + confirmations: ConfirmationServiceModel[] + creationTx?: boolean | null + data?: string | null + dataDecoded?: DecodedMethods + executionDate?: string | null + executor: string + gasPrice: number + gasToken: string + isExecuted: boolean + isSuccessful: boolean + nonce?: number | null + operation: number + origin?: string | null + refundReceiver: string + safeTxGas: number + safeTxHash: string + submissionDate?: string | null + to: string + transactionHash?: string | null + value: number +} + +export type SafeTransactionsType = { + cancel: any + outgoing: any +} + +export type OutgoingTxs = { + cancellationTxs: any + outgoingTxs: any +} + +export type BatchProcessTxsProps = OutgoingTxs & { + currentUser?: string + knownTokens: any + safe: any +} + +/** + * Differentiates outgoing transactions from its cancel ones and returns a split map + * @param {string} safeAddress - safe's Ethereum Address + * @param {TxServiceModel[]} outgoingTxs - collection of transactions (usually, returned by the /transactions service) + * @returns {any|{cancellationTxs: {}, outgoingTxs: []}} + */ +const extractCancelAndOutgoingTxs = (safeAddress: string, outgoingTxs: TxServiceModel[]): OutgoingTxs => { + return outgoingTxs.reduce( + (acc, transaction) => { + if (isCancelTransaction(transaction, safeAddress)) { + if (!isNaN(Number(transaction.nonce))) { + acc.cancellationTxs[transaction.nonce] = transaction + } + } else { + acc.outgoingTxs = [...acc.outgoingTxs, transaction] + } + return acc + }, + { + cancellationTxs: {}, + outgoingTxs: [], + }, + ) +} + +/** + * Requests Contract's code for all the Contracts the Safe has interacted with + * @param transactions + * @returns {Promise<[Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>]>} + */ +const batchRequestContractCode = (transactions: any[]): Promise => { + if (!transactions || !Array.isArray(transactions)) { + throw new Error('`transactions` must be provided in order to lookup information') + } + + const batch = new web3ReadOnly.BatchRequest() + + const whenTxsValues = transactions.map((tx) => { + return generateBatchRequests({ + abi: [], + address: tx.to, + batch, + context: tx, + methods: [{ method: 'getCode', type: 'eth', args: [tx.to] }], + }) + }) + + batch.execute() + + return Promise.all(whenTxsValues) +} + +/** + * Receives a list of outgoing and its cancellation transactions and builds the tx object that will be store + * @param cancellationTxs + * @param currentUser + * @param knownTokens + * @param outgoingTxs + * @param safe + * @returns {Promise<{cancel: {}, outgoing: []}>} + */ +const batchProcessOutgoingTransactions = async ({ + cancellationTxs, + currentUser, + knownTokens, + outgoingTxs, + safe, +}: BatchProcessTxsProps): Promise<{ + cancel: any + outgoing: any +}> => { + // cancellation transactions + const cancelTxsValues = Object.values(cancellationTxs) + const cancellationTxsWithData = cancelTxsValues.length ? await batchRequestContractCode(cancelTxsValues) : [] + + const cancel = {} + for (const [tx, txCode] of cancellationTxsWithData) { + cancel[`${tx.nonce}`] = await buildTx({ + cancellationTxs, + currentUser, + knownTokens, + outgoingTxs, + safe, + tx, + txCode, + }) + } + + // outgoing transactions + const outgoingTxsWithData = outgoingTxs.length ? await batchRequestContractCode(outgoingTxs) : [] + + const outgoing = [] + for (const [tx, txCode] of outgoingTxsWithData) { + outgoing.push( + await buildTx({ + cancellationTxs, + currentUser, + knownTokens, + outgoingTxs, + safe, + tx, + txCode, + }), + ) + } + + return { cancel, outgoing } +} + +let previousETag = null +export const loadOutgoingTransactions = async (safeAddress: string): Promise => { + const defaultResponse = { + cancel: Map(), + outgoing: List(), + } + const state = store.getState() + + if (!safeAddress) { + return defaultResponse + } + + const knownTokens = state[TOKEN_REDUCER_ID] + const currentUser = state[PROVIDER_REDUCER_ID].get('account') + const safe = state[SAFE_REDUCER_ID].getIn([SAFE_REDUCER_ID, safeAddress]) + + if (!safe) { + return defaultResponse + } + + const { eTag, results }: { eTag: string | null; results: TxServiceModel[] } = await fetchTransactions( + TransactionTypes.OUTGOING, + safeAddress, + previousETag, + ) + previousETag = eTag + const { cancellationTxs, outgoingTxs } = extractCancelAndOutgoingTxs(safeAddress, results) + + // this should be only used for the initial load or when paginating + const { cancel, outgoing } = await batchProcessOutgoingTransactions({ + cancellationTxs, + currentUser, + knownTokens, + outgoingTxs, + safe, + }) + + return { + cancel: fromJS(cancel), + outgoing: fromJS(outgoing), + } +} diff --git a/src/routes/safe/store/actions/transactions/removeCancellationTransaction.ts b/src/routes/safe/store/actions/transactions/removeCancellationTransaction.ts new file mode 100644 index 00000000..985f313a --- /dev/null +++ b/src/routes/safe/store/actions/transactions/removeCancellationTransaction.ts @@ -0,0 +1,5 @@ +import { createAction } from 'redux-actions' + +export const REMOVE_CANCELLATION_TRANSACTION = 'REMOVE_CANCELLATION_TRANSACTION' + +export const removeCancellationTransaction = createAction(REMOVE_CANCELLATION_TRANSACTION) diff --git a/src/routes/safe/store/actions/transactions/removeTransaction.ts b/src/routes/safe/store/actions/transactions/removeTransaction.ts new file mode 100644 index 00000000..3a84f3d4 --- /dev/null +++ b/src/routes/safe/store/actions/transactions/removeTransaction.ts @@ -0,0 +1,5 @@ +import { createAction } from 'redux-actions' + +export const REMOVE_TRANSACTION = 'REMOVE_TRANSACTION' + +export const removeTransaction = createAction(REMOVE_TRANSACTION) diff --git a/src/routes/safe/store/actions/transactions/utils/addMockSafeCreationTx.ts b/src/routes/safe/store/actions/transactions/utils/addMockSafeCreationTx.ts new file mode 100644 index 00000000..8543f5cb --- /dev/null +++ b/src/routes/safe/store/actions/transactions/utils/addMockSafeCreationTx.ts @@ -0,0 +1,27 @@ +const addMockSafeCreationTx = (safeAddress: string) => [ + { + blockNumber: null, + baseGas: 0, + confirmations: [], + data: null, + executionDate: null, + gasPrice: 0, + gasToken: '0x0000000000000000000000000000000000000000', + isExecuted: true, + nonce: null, + operation: 0, + refundReceiver: '0x0000000000000000000000000000000000000000', + safe: safeAddress, + safeTxGas: 0, + safeTxHash: '', + signatures: null, + submissionDate: null, + executor: '', + to: '', + transactionHash: null, + value: 0, + creationTx: true, + }, +] + +export default addMockSafeCreationTx diff --git a/src/routes/safe/store/actions/transactions/utils/transactionHelpers.ts b/src/routes/safe/store/actions/transactions/utils/transactionHelpers.ts new file mode 100644 index 00000000..2f514329 --- /dev/null +++ b/src/routes/safe/store/actions/transactions/utils/transactionHelpers.ts @@ -0,0 +1,377 @@ +import { List, Map } from 'immutable' + +import { DecodedMethods, decodeMethods } from 'src/logic/contracts/methodIds' +import { TOKEN_REDUCER_ID } from 'src/logic/tokens/store/reducer/tokens' +import { + getERC20DecimalsAndSymbol, + isSendERC20Transaction, + isSendERC721Transaction, +} from 'src/logic/tokens/utils/tokenHelpers' +import { sameAddress, ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses' +import { EMPTY_DATA } from 'src/logic/wallets/ethTransactions' +import { makeConfirmation } from 'src/routes/safe/store/models/confirmation' +import { Confirmation } from 'src/routes/safe/store/models/types/confirmation' +import { makeTransaction } from 'src/routes/safe/store/models/transaction' +import { + Transaction, + TransactionStatus, + TransactionStatusValues, + TransactionTypes, + TransactionTypeValues, + TxArgs, +} from 'src/routes/safe/store/models/types/transaction' +import { CANCELLATION_TRANSACTIONS_REDUCER_ID } from 'src/routes/safe/store/reducer/cancellationTransactions' +import { SAFE_REDUCER_ID } from 'src/routes/safe/store/reducer/safe' +import { TRANSACTIONS_REDUCER_ID } from 'src/routes/safe/store/reducer/transactions' +import { store } from 'src/store' +import { safeSelector, safeTransactionsSelector } from 'src/routes/safe/store/selectors' +import { addOrUpdateTransactions } from 'src/routes/safe/store/actions/transactions/addOrUpdateTransactions' +import { TxServiceModel } from 'src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions' +import { TypedDataUtils } from 'eth-sig-util' + +export const isEmptyData = (data?: string | null): boolean => { + return !data || data === EMPTY_DATA +} + +export const isInnerTransaction = (tx: TxServiceModel, safeAddress: string): boolean => { + return sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 +} + +export const isCancelTransaction = (tx: TxServiceModel, safeAddress: string): boolean => { + return isInnerTransaction(tx, safeAddress) && isEmptyData(tx.data) +} + +export const isPendingTransaction = (tx: Transaction, cancelTx: Transaction): boolean => { + return (!!cancelTx && cancelTx.status === 'pending') || tx.status === 'pending' +} + +export const isModifySettingsTransaction = (tx: TxServiceModel, safeAddress: string): boolean => { + return isInnerTransaction(tx, safeAddress) && !isEmptyData(tx.data) +} + +export const isMultiSendTransaction = (tx: TxServiceModel): boolean => { + return !isEmptyData(tx.data) && tx.data.substring(0, 10) === '0x8d80ff0a' && Number(tx.value) === 0 +} + +export const isUpgradeTransaction = (tx: TxServiceModel): boolean => { + return ( + !isEmptyData(tx.data) && + isMultiSendTransaction(tx) && + tx.data.substr(308, 8) === '7de7edef' && // 7de7edef - changeMasterCopy (308, 8) + tx.data.substr(550, 8) === 'f08a0323' // f08a0323 - setFallbackHandler (550, 8) + ) +} + +export const isOutgoingTransaction = (tx: TxServiceModel, safeAddress: string): boolean => { + return !sameAddress(tx.to, safeAddress) && !isEmptyData(tx.data) +} + +export const isCustomTransaction = async ( + tx: TxServiceModel, + txCode: string, + safeAddress: string, + knownTokens: any, +): Promise => { + return ( + isOutgoingTransaction(tx, safeAddress) && + !(await isSendERC20Transaction(tx, txCode, knownTokens)) && + !isUpgradeTransaction(tx) && + !isSendERC721Transaction(tx, txCode, knownTokens) + ) +} + +export const getRefundParams = async ( + tx: any, + tokenInfo: (string) => Promise<{ decimals: number; symbol: string } | null>, +): Promise => { + let refundParams = null + + if (tx.gasPrice > 0) { + let refundSymbol = 'ETH' + let refundDecimals = 18 + + if (tx.gasToken !== ZERO_ADDRESS) { + const gasToken = await tokenInfo(tx.gasToken) + + if (gasToken !== null) { + refundSymbol = gasToken.symbol + refundDecimals = gasToken.decimals + } + } + + const feeString = (tx.gasPrice * (tx.baseGas + tx.safeTxGas)).toString().padStart(refundDecimals, '0') + const whole = feeString.slice(0, feeString.length - refundDecimals) || '0' + const fraction = feeString.slice(feeString.length - refundDecimals) + + refundParams = { + fee: `${whole}.${fraction}`, + symbol: refundSymbol, + } + } + + return refundParams +} + +export const getDecodedParams = (tx: TxServiceModel): DecodedMethods => { + if (tx.dataDecoded) { + return Object.keys(tx.dataDecoded).reduce((acc, key) => { + acc[key] = { + ...tx.dataDecoded[key].reduce( + (acc, param) => ({ + ...acc, + [param.name]: param.value, + }), + {}, + ), + } + return acc + }, {}) + } + return null +} + +export const getConfirmations = (tx: TxServiceModel): List => { + return List( + tx.confirmations.map((conf) => + makeConfirmation({ + owner: conf.owner, + hash: conf.transactionHash, + signature: conf.signature, + }), + ), + ) +} + +export const isTransactionCancelled = ( + tx: TxServiceModel, + outgoingTxs: Array, + cancellationTxs: { number: TxServiceModel }, +): boolean => { + return ( + // not executed + !tx.isExecuted && + // there's an executed cancel tx, with same nonce + ((tx.nonce && !!cancellationTxs[tx.nonce] && cancellationTxs[tx.nonce].isExecuted) || + // there's an executed tx, with same nonce + outgoingTxs.some((outgoingTx) => tx.nonce === outgoingTx.nonce && outgoingTx.isExecuted)) + ) +} + +export const calculateTransactionStatus = ( + tx: Transaction, + { owners, threshold }: any, + currentUser?: string | null, +): TransactionStatusValues => { + let txStatus + + if (tx.isExecuted && tx.isSuccessful) { + txStatus = TransactionStatus.SUCCESS + } else if (tx.cancelled) { + txStatus = TransactionStatus.CANCELLED + } else if (tx.confirmations.size === threshold) { + txStatus = TransactionStatus.AWAITING_EXECUTION + } else if (tx.creationTx) { + txStatus = TransactionStatus.SUCCESS + } else if (!tx.confirmations.size || !!tx.isPending) { + txStatus = TransactionStatus.PENDING + } else { + const userConfirmed = tx.confirmations.filter((conf) => conf.owner === currentUser).size === 1 + const userIsSafeOwner = owners.filter((owner) => owner.address === currentUser).size === 1 + txStatus = + !userConfirmed && userIsSafeOwner + ? TransactionStatus.AWAITING_YOUR_CONFIRMATION + : TransactionStatus.AWAITING_CONFIRMATIONS + } + + if (tx.isSuccessful === false) { + txStatus = TransactionStatus.FAILED + } + + return txStatus +} + +export const calculateTransactionType = (tx: Transaction): TransactionTypeValues => { + let txType = TransactionTypes.OUTGOING + + if (tx.isTokenTransfer) { + txType = TransactionTypes.TOKEN + } else if (tx.isCollectibleTransfer) { + txType = TransactionTypes.COLLECTIBLE + } else if (tx.modifySettingsTx) { + txType = TransactionTypes.SETTINGS + } else if (tx.isCancellationTx) { + txType = TransactionTypes.CANCELLATION + } else if (tx.customTx) { + txType = TransactionTypes.CUSTOM + } else if (tx.creationTx) { + txType = TransactionTypes.CREATION + } else if (tx.upgradeTx) { + txType = TransactionTypes.UPGRADE + } + + return txType +} + +export const buildTx = async ({ + cancellationTxs, + currentUser, + knownTokens, + outgoingTxs, + safe, + tx, + txCode, +}): Promise => { + const safeAddress = safe.address + const isModifySettingsTx = isModifySettingsTransaction(tx, safeAddress) + const isTxCancelled = isTransactionCancelled(tx, outgoingTxs, cancellationTxs) + const isSendERC721Tx = isSendERC721Transaction(tx, txCode, knownTokens) + const isSendERC20Tx = await isSendERC20Transaction(tx, txCode, knownTokens) + const isMultiSendTx = isMultiSendTransaction(tx) + const isUpgradeTx = isUpgradeTransaction(tx) + const isCustomTx = await isCustomTransaction(tx, txCode, safeAddress, knownTokens) + const isCancellationTx = isCancelTransaction(tx, safeAddress) + const refundParams = await getRefundParams(tx, getERC20DecimalsAndSymbol) + const decodedParams = getDecodedParams(tx) + const confirmations = getConfirmations(tx) + const { decimals = 18, symbol = 'ETH' } = isSendERC20Tx ? await getERC20DecimalsAndSymbol(tx.to) : {} + + const txToStore: Transaction = makeTransaction({ + baseGas: tx.baseGas, + blockNumber: tx.blockNumber, + cancelled: isTxCancelled, + confirmations, + creationTx: tx.creationTx, + customTx: isCustomTx, + data: tx.data ? tx.data : EMPTY_DATA, + decimals, + decodedParams, + executionDate: tx.executionDate, + executionTxHash: tx.transactionHash, + executor: tx.executor, + gasPrice: tx.gasPrice, + gasToken: tx.gasToken || ZERO_ADDRESS, + isCancellationTx, + isCollectibleTransfer: isSendERC721Tx, + isExecuted: tx.isExecuted, + isSuccessful: tx.isSuccessful, + isTokenTransfer: isSendERC20Tx, + modifySettingsTx: isModifySettingsTx, + multiSendTx: isMultiSendTx, + nonce: tx.nonce, + operation: tx.operation, + origin: tx.origin, + recipient: tx.to, + refundParams, + refundReceiver: tx.refundReceiver || ZERO_ADDRESS, + safeTxGas: tx.safeTxGas, + safeTxHash: tx.safeTxHash, + submissionDate: tx.submissionDate, + symbol, + upgradeTx: isUpgradeTx, + value: tx.value.toString(), + }) + + return txToStore + .set('status', calculateTransactionStatus(txToStore, safe, currentUser)) + .set('type', calculateTransactionType(txToStore)) +} + +export const mockTransaction = (tx, safeAddress: string, state): Promise => { + const submissionDate = new Date().toISOString() + + const transactionStructure: TxServiceModel = { + blockNumber: null, + confirmationsRequired: null, + dataDecoded: decodeMethods(tx.data), + ethGasPrice: null, + executionDate: null, + executor: null, + fee: null, + gasUsed: null, + isExecuted: false, + isSuccessful: null, + modified: submissionDate, + origin: null, + safe: safeAddress, + safeTxHash: null, + signatures: null, + submissionDate, + transactionHash: null, + confirmations: [], + ...tx, + } + + const knownTokens = state[TOKEN_REDUCER_ID] + const safe = state[SAFE_REDUCER_ID].getIn([SAFE_REDUCER_ID, safeAddress]) + const cancellationTxs = state[CANCELLATION_TRANSACTIONS_REDUCER_ID].get(safeAddress) || Map() + const outgoingTxs = state[TRANSACTIONS_REDUCER_ID].get(safeAddress) || List() + + return buildTx({ + cancellationTxs, + currentUser: null, + knownTokens, + outgoingTxs, + safe, + tx: transactionStructure, + txCode: EMPTY_DATA, + }) +} + +export const updateStoredTransactionsStatus = (dispatch, walletRecord): void => { + const state = store.getState() + const safe = safeSelector(state) + + if (safe) { + const safeAddress = safe.address + const transactions = safeTransactionsSelector(state) + dispatch( + addOrUpdateTransactions({ + safeAddress, + transactions: transactions.withMutations((list) => + list.map((tx) => tx.set('status', calculateTransactionStatus(tx, safe, walletRecord.account))), + ), + }), + ) + } +} + +export function generateSafeTxHash(safeAddress: string, txArgs: TxArgs): string { + const messageTypes = { + EIP712Domain: [{ type: 'address', name: 'verifyingContract' }], + SafeTx: [ + { type: 'address', name: 'to' }, + { type: 'uint256', name: 'value' }, + { type: 'bytes', name: 'data' }, + { type: 'uint8', name: 'operation' }, + { type: 'uint256', name: 'safeTxGas' }, + { type: 'uint256', name: 'baseGas' }, + { type: 'uint256', name: 'gasPrice' }, + { type: 'address', name: 'gasToken' }, + { type: 'address', name: 'refundReceiver' }, + { type: 'uint256', name: 'nonce' }, + ], + } + const primaryType: 'SafeTx' = 'SafeTx' + + const typedData = { + types: messageTypes, + domain: { + verifyingContract: safeAddress, + }, + primaryType, + message: { + to: txArgs.to, + value: txArgs.valueInWei, + data: txArgs.data, + operation: txArgs.operation, + safeTxGas: txArgs.safeTxGas, + baseGas: txArgs.baseGas, + gasPrice: txArgs.gasPrice, + gasToken: txArgs.gasToken, + refundReceiver: txArgs.refundReceiver, + nonce: txArgs.nonce, + }, + } + + return `0x${TypedDataUtils.sign(typedData).toString('hex')}` +} diff --git a/src/routes/safe/store/middleware/notificationsMiddleware.ts b/src/routes/safe/store/middleware/notificationsMiddleware.ts index 90cc66bc..a28d76c7 100644 --- a/src/routes/safe/store/middleware/notificationsMiddleware.ts +++ b/src/routes/safe/store/middleware/notificationsMiddleware.ts @@ -1,5 +1,4 @@ import { push } from 'connected-react-router' -import { List, Map } from 'immutable' import { NOTIFICATIONS, enhanceSnackbarForAction } from 'src/logic/notifications' import closeSnackbarAction from 'src/logic/notifications/store/actions/closeSnackbar' @@ -12,11 +11,15 @@ import { getIncomingTxAmount } from 'src/routes/safe/components/Transactions/Txs import { grantedSelector } from 'src/routes/safe/container/selector' import { ADD_INCOMING_TRANSACTIONS } from 'src/routes/safe/store/actions/addIncomingTransactions' import { ADD_SAFE } from 'src/routes/safe/store/actions/addSafe' +import { ADD_OR_UPDATE_TRANSACTIONS } from 'src/routes/safe/store/actions/transactions/addOrUpdateTransactions' import updateSafe from 'src/routes/safe/store/actions/updateSafe' -import { safeParamAddressFromStateSelector, safesMapSelector } from 'src/routes/safe/store/selectors' +import { + safeParamAddressFromStateSelector, + safesMapSelector, + safeCancellationTransactionsSelector, +} from 'src/routes/safe/store/selectors' import { loadFromStorage, saveToStorage } from 'src/utils/storage' -import { ADD_OR_UPDATE_TRANSACTIONS } from '../actions/transactions/addOrUpdateTransactions' const watchedActions = [ADD_OR_UPDATE_TRANSACTIONS, ADD_INCOMING_TRANSACTIONS, ADD_SAFE] @@ -59,48 +62,41 @@ const sendAwaitingTransactionNotification = async ( await saveToStorage(LAST_TIME_USED_LOGGED_IN_ID, lastTimeUserLoggedInForSafes) } +const onNotificationClicked = (dispatch, notificationKey, safeAddress) => () => { + dispatch(closeSnackbarAction({ key: notificationKey })) + dispatch(push(`/safes/${safeAddress}/transactions`)) +} + const notificationsMiddleware = (store) => (next) => async (action) => { const handledAction = next(action) const { dispatch } = store if (watchedActions.includes(action.type)) { const state = store.getState() + switch (action.type) { case ADD_OR_UPDATE_TRANSACTIONS: { const { safeAddress, transactions } = action.payload const userAddress: string = userAccountSelector(state) - const cancellationTransactions = state.cancellationTransactions.get(safeAddress) - const cancellationTransactionsByNonce = cancellationTransactions - ? cancellationTransactions.reduce((acc, tx) => acc.set(tx.nonce, tx), Map()) - : Map() - - const awaitingTransactions = getAwaitingTransactions( - Map().set(safeAddress, transactions), - cancellationTransactionsByNonce, - userAddress, - ) - const awaitingTxsSubmissionDateList = awaitingTransactions - .get(safeAddress, List([])) - .map((tx) => tx.submissionDate) + const cancellationTransactions = safeCancellationTransactionsSelector(state) + const awaitingTransactions = getAwaitingTransactions(transactions, cancellationTransactions, userAddress) + const awaitingTxsSubmissionDateList = awaitingTransactions.map((tx) => tx.submissionDate) const safes = safesMapSelector(state) const currentSafe = safes.get(safeAddress) - if (!isUserOwner(currentSafe, userAddress) || awaitingTxsSubmissionDateList.size === 0) { + if (!isUserOwner(currentSafe, userAddress) || awaitingTransactions.size === 0) { break } + const notificationKey = `${safeAddress}-awaiting` - const onNotificationClicked = () => { - dispatch(closeSnackbarAction({ key: notificationKey })) - dispatch(push(`/safes/${safeAddress}/transactions`)) - } await sendAwaitingTransactionNotification( dispatch, safeAddress, awaitingTxsSubmissionDateList, notificationKey, - onNotificationClicked, + onNotificationClicked(dispatch, notificationKey, safeAddress), ) break diff --git a/src/routes/safe/store/models/confirmation.ts b/src/routes/safe/store/models/confirmation.ts index 6e66a003..81b63552 100644 --- a/src/routes/safe/store/models/confirmation.ts +++ b/src/routes/safe/store/models/confirmation.ts @@ -1,6 +1,7 @@ import { Record } from 'immutable' +import { ConfirmationProps } from './types/confirmation' -export const makeConfirmation = Record({ +export const makeConfirmation = Record({ owner: '', type: 'initialised', hash: '', diff --git a/src/routes/safe/store/models/transaction.ts b/src/routes/safe/store/models/transaction.ts index 577a6322..214fd111 100644 --- a/src/routes/safe/store/models/transaction.ts +++ b/src/routes/safe/store/models/transaction.ts @@ -1,48 +1,54 @@ -import { List, Record } from 'immutable' +import { List, Map, Record } from 'immutable' import { ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses' +import { + TransactionProps, + PendingActionType, + TransactionStatus, + TransactionTypes, +} from 'src/routes/safe/store/models/types/transaction' -export const OUTGOING_TX_TYPE = 'outgoing' - -export const makeTransaction = Record({ - nonce: 0, - blockNumber: 0, - value: 0, - confirmations: List([]), - recipient: '', - data: null, - operation: 0, - safeTxGas: 0, +export const makeTransaction = Record({ baseGas: 0, - gasPrice: 0, - gasToken: ZERO_ADDRESS, - refundReceiver: ZERO_ADDRESS, - isExecuted: false, - isSuccessful: true, - submissionDate: '', - executor: '', - executionDate: '', - symbol: '', - executionTxHash: undefined, - safeTxHash: '', + blockNumber: 0, cancelled: false, - modifySettingsTx: false, - cancellationTx: false, - customTx: false, - creationTx: false, - multiSendTx: false, - upgradeTx: false, - status: 'awaiting', - decimals: 18, - isTokenTransfer: false, - decodedParams: {}, - refundParams: null, - type: 'outgoing', - origin: null, + confirmations: List([]), created: false, creator: '', + creationTx: false, + customTx: false, + data: null, + decimals: 18, + decodedParams: {}, + executionDate: '', + executionTxHash: undefined, + executor: '', factoryAddress: '', + gasPrice: 0, + gasToken: ZERO_ADDRESS, + isCancellationTx: false, + isCollectibleTransfer: false, + isExecuted: false, + isSuccessful: true, + isTokenTransfer: false, masterCopy: '', + modifySettingsTx: false, + multiSendTx: false, + nonce: 0, + operation: 0, + origin: null, + ownersWithPendingActions: Map({ [PendingActionType.CONFIRM]: List([]), [PendingActionType.REJECT]: List([]) }), + recipient: '', + refundParams: null, + refundReceiver: ZERO_ADDRESS, + safeTxGas: 0, + safeTxHash: '', setupData: '', + status: TransactionStatus.PENDING, + submissionDate: '', + symbol: '', transactionHash: '', + type: TransactionTypes.OUTGOING, + upgradeTx: false, + value: '0', }) diff --git a/src/routes/safe/store/models/types/confirmation.d.ts b/src/routes/safe/store/models/types/confirmation.d.ts new file mode 100644 index 00000000..65e879c9 --- /dev/null +++ b/src/routes/safe/store/models/types/confirmation.d.ts @@ -0,0 +1,10 @@ +import { RecordOf } from 'immutable' + +export type ConfirmationProps = { + owner: string + type: string + hash: string + signature: string | null +} + +export type Confirmation = RecordOf diff --git a/src/routes/safe/store/models/types/transaction.ts b/src/routes/safe/store/models/types/transaction.ts new file mode 100644 index 00000000..83c7bd9c --- /dev/null +++ b/src/routes/safe/store/models/types/transaction.ts @@ -0,0 +1,93 @@ +export enum TransactionTypes { + INCOMING = 'incoming', + OUTGOING = 'outgoing', + SETTINGS = 'settings', + CUSTOM = 'custom', + CREATION = 'creation', + CANCELLATION = 'cancellation', + UPGRADE = 'upgrade', + TOKEN = 'token', + COLLECTIBLE = 'collectible', +} +export type TransactionTypeValues = typeof TransactionTypes[keyof typeof TransactionTypes] + +export enum TransactionStatus { + AWAITING_YOUR_CONFIRMATION = 'awaiting_your_confirmation', + AWAITING_CONFIRMATIONS = 'awaiting_confirmations', + SUCCESS = 'success', + FAILED = 'failed', + CANCELLED = 'cancelled', + AWAITING_EXECUTION = 'awaiting_execution', + PENDING = 'pending', +} +export type TransactionStatusValues = typeof TransactionStatus[keyof typeof TransactionStatus] + +export enum PendingActionType { + CONFIRM = 'confirm', + REJECT = 'reject', +} +export type PendingActionValues = PendingActionType[keyof PendingActionType] + +export type TransactionProps = { + baseGas: number + blockNumber?: number | null + cancelled?: boolean + confirmations: import('immutable').List + created: boolean + creator: string + creationTx: boolean + customTx: boolean + data?: string | null + decimals?: (number | string) | null + decodedParams: import('src/logic/contracts/methodIds').DecodedMethods + executionDate?: string | null + executionTxHash?: string | null + executor: string + factoryAddress: string + gasPrice: number + gasToken: string + isCancellationTx: boolean + isCollectibleTransfer: boolean + isExecuted: boolean + isPending?: boolean + isSuccessful: boolean + isTokenTransfer: boolean + masterCopy: string + modifySettingsTx: boolean + multiSendTx: boolean + nonce?: number | null + operation: number + origin: string | null + ownersWithPendingActions: import('immutable').Map> + recipient: string + refundParams: any + refundReceiver: string + safeTxGas: number + safeTxHash: string + setupData: string + status?: TransactionStatus + submissionDate?: string | null + symbol?: string | null + transactionHash: string + type: TransactionTypes + upgradeTx: boolean + value: string +} + +export type Transaction = import('immutable').RecordOf + +export type TxArgs = { + data: any + baseGas: number + gasToken: string + safeInstance: any + nonce: number + valueInWei: any + safeTxGas: number + refundReceiver: string + sender: any + sigs: string + to: any + operation: any + gasPrice: number +} diff --git a/src/routes/safe/store/reducer/cancellationTransactions.ts b/src/routes/safe/store/reducer/cancellationTransactions.ts index 13be3cac..29059573 100644 --- a/src/routes/safe/store/reducer/cancellationTransactions.ts +++ b/src/routes/safe/store/reducer/cancellationTransactions.ts @@ -1,13 +1,57 @@ import { Map } from 'immutable' import { handleActions } from 'redux-actions' -import { ADD_CANCELLATION_TRANSACTIONS } from 'src/routes/safe/store/actions/addCancellationTransactions' +import { ADD_OR_UPDATE_CANCELLATION_TRANSACTIONS } from 'src/routes/safe/store/actions/transactions/addOrUpdateCancellationTransactions' +import { REMOVE_CANCELLATION_TRANSACTION } from 'src/routes/safe/store/actions/transactions/removeCancellationTransaction' export const CANCELLATION_TRANSACTIONS_REDUCER_ID = 'cancellationTransactions' export default handleActions( { - [ADD_CANCELLATION_TRANSACTIONS]: (state, action) => action.payload, + [ADD_OR_UPDATE_CANCELLATION_TRANSACTIONS]: (state, action) => { + const { safeAddress, transactions } = action.payload + + if (!safeAddress || !transactions || !transactions.size) { + return state + } + + return state.withMutations((map) => { + const stateTransactionsMap = map.get(safeAddress) + + if (stateTransactionsMap) { + transactions.forEach((updateTx) => { + const keyPath = [safeAddress, `${updateTx.nonce}`] + + if (updateTx.confirmations.size) { + // if there are confirmations then we replace what's stored with the new tx + // as we assume that this is the newest tx returned by the server + map.setIn(keyPath, updateTx) + } else { + // if there are no confirmations, we assume this is a mocked tx + // as txs without confirmation are not being returned by the server (?has_confirmations=true) + map.mergeDeepIn(keyPath, updateTx) + } + }) + } else { + map.set(safeAddress, transactions) + } + }) + }, + [REMOVE_CANCELLATION_TRANSACTION]: (state, action) => { + const { safeAddress, transaction } = action.payload + + if (!safeAddress || !transaction) { + return state + } + + return state.withMutations((map) => { + const stateTransactionsMap = map.get(safeAddress) + + if (stateTransactionsMap) { + map.deleteIn([safeAddress, `${transaction.nonce}`]) + } + }) + }, }, Map(), ) diff --git a/src/routes/safe/store/reducer/transactions.ts b/src/routes/safe/store/reducer/transactions.ts index 7b2472e8..7dde6ae4 100644 --- a/src/routes/safe/store/reducer/transactions.ts +++ b/src/routes/safe/store/reducer/transactions.ts @@ -1,42 +1,69 @@ -import { List, Map } from 'immutable' +import { Map } from 'immutable' import { handleActions } from 'redux-actions' -import { ADD_TRANSACTIONS } from 'src/routes/safe/store/actions/addTransactions' -import { ADD_OR_UPDATE_TRANSACTIONS } from '../actions/transactions/addOrUpdateTransactions' +import { ADD_OR_UPDATE_TRANSACTIONS } from 'src/routes/safe/store/actions/transactions/addOrUpdateTransactions' +import { REMOVE_TRANSACTION } from 'src/routes/safe/store/actions/transactions/removeTransaction' export const TRANSACTIONS_REDUCER_ID = 'transactions' export default handleActions( { - [ADD_TRANSACTIONS]: (state, action) => action.payload, [ADD_OR_UPDATE_TRANSACTIONS]: (state, action) => { const { safeAddress, transactions } = action.payload - if (!safeAddress || !transactions) { + if (!safeAddress || !transactions || !transactions.size) { return state } - const newState = state.withMutations((map) => { + return state.withMutations((map) => { const stateTransactionsList = map.get(safeAddress) + if (stateTransactionsList) { - let newTxList - transactions.forEach((updateTx) => { - const txIndex = stateTransactionsList.findIndex((txIterator) => txIterator.nonce === updateTx.nonce) - if (txIndex !== -1) { - // Update - newTxList = stateTransactionsList.update(txIndex, (oldTx) => oldTx.merge(updateTx)) - map.set(safeAddress, newTxList) - } else { - // Add new - map.update(safeAddress, (oldTxList) => oldTxList.merge(List([updateTx]))) - } + const txsToStore = stateTransactionsList.withMutations((txsList) => { + transactions.forEach((updateTx) => { + const storedTxIndex = txsList.findIndex((txIterator) => txIterator.nonce === updateTx.nonce) + + if (storedTxIndex !== -1) { + // Update + if (updateTx.confirmations.size) { + // if there are confirmations then we replace what's stored with the new tx + // as we assume that this is the newest tx returned by the server + txsList.update(storedTxIndex, () => updateTx) + } else { + // if there are no confirmations, we assume this is a mocked tx + // as txs without confirmation are not being returned by the server (?has_confirmations=true) + txsList.update(storedTxIndex, (storedTx) => storedTx.mergeDeep(updateTx)) + } + } else { + // Add new + txsList.unshift(updateTx) + } + }) }) + map.set(safeAddress, txsToStore) } else { map.set(safeAddress, transactions) } }) + }, + [REMOVE_TRANSACTION]: (state, action) => { + const { safeAddress, transaction } = action.payload - return newState + if (!safeAddress || !transaction) { + return state + } + + return state.withMutations((map) => { + const stateTransactionsList = map.get(safeAddress) + + if (stateTransactionsList) { + const storedTxIndex = stateTransactionsList.findIndex((storedTx) => storedTx.equals(transaction)) + + if (storedTxIndex !== -1) { + map.deleteIn([safeAddress, storedTxIndex]) + } + } + }) }, }, Map(), diff --git a/src/routes/safe/store/selectors/index.ts b/src/routes/safe/store/selectors/index.ts index f167c2e4..ed26ffe0 100644 --- a/src/routes/safe/store/selectors/index.ts +++ b/src/routes/safe/store/selectors/index.ts @@ -1,4 +1,4 @@ -import { List, Set } from 'immutable' +import { List, Map, Set } from 'immutable' import { matchPath } from 'react-router-dom' import { createSelector } from 'reselect' @@ -60,7 +60,7 @@ export const safeTransactionsSelector = createSelector( return List([]) } - return transactions.get(address) || List([]) + return transactions.get(address, List([])) }, ) @@ -79,14 +79,14 @@ export const safeCancellationTransactionsSelector = createSelector( safeParamAddressFromStateSelector, (cancellationTransactions, address) => { if (!cancellationTransactions) { - return List([]) + return Map() } if (!address) { - return List([]) + return Map() } - return cancellationTransactions.get(address) || List([]) + return cancellationTransactions.get(address, Map({})) }, ) @@ -102,7 +102,7 @@ export const safeIncomingTransactionsSelector = createSelector( return List([]) } - return incomingTransactions.get(address) || List([]) + return incomingTransactions.get(address, List([])) }, ) @@ -167,37 +167,23 @@ export const safeBalancesSelector = createSelector(safeSelector, (safe) => { return safe.balances }) -export const safeNameSelector = createSelector(safeSelector, (safe) => { - return safe ? safe.name : undefined -}) +export const safeFieldSelector = (field) => (safe) => safe?.[field] -export const safeEthBalanceSelector = createSelector(safeSelector, (safe) => { - return safe ? safe.ethBalance : undefined -}) +export const safeNameSelector = createSelector(safeSelector, safeFieldSelector('name')) -export const safeNeedsUpdateSelector = createSelector(safeSelector, (safe) => { - return safe ? safe.needsUpdate : undefined -}) +export const safeEthBalanceSelector = createSelector(safeSelector, safeFieldSelector('ethBalance')) -export const safeCurrentVersionSelector = createSelector(safeSelector, (safe) => { - return safe ? safe.currentVersion : undefined -}) +export const safeNeedsUpdateSelector = createSelector(safeSelector, safeFieldSelector('needsUpdate')) -export const safeThresholdSelector = createSelector(safeSelector, (safe) => { - return safe ? safe.threshold : undefined -}) +export const safeCurrentVersionSelector = createSelector(safeSelector, safeFieldSelector('currentVersion')) -export const safeNonceSelector = createSelector(safeSelector, (safe) => { - return safe ? safe.nonce : undefined -}) +export const safeThresholdSelector = createSelector(safeSelector, safeFieldSelector('threshold')) -export const safeOwnersSelector = createSelector(safeSelector, (safe) => { - return safe ? safe.owners : undefined -}) +export const safeNonceSelector = createSelector(safeSelector, safeFieldSelector('nonce')) -export const safeFeaturesEnabledSelector = createSelector(safeSelector, (safe) => { - return safe ? safe.featuresEnabled : undefined -}) +export const safeOwnersSelector = createSelector(safeSelector, safeFieldSelector('owners')) + +export const safeFeaturesEnabledSelector = createSelector(safeSelector, safeFieldSelector('featuresEnabled')) export const getActiveTokensAddressesForAllSafes = createSelector(safesListSelector, (safes) => { const addresses = Set().withMutations((set) => { diff --git a/src/routes/safe/store/selectors/transactions.ts b/src/routes/safe/store/selectors/transactions.ts new file mode 100644 index 00000000..da4b7782 --- /dev/null +++ b/src/routes/safe/store/selectors/transactions.ts @@ -0,0 +1,10 @@ +import { List } from 'immutable' +import { createSelector } from 'reselect' + +import { safeIncomingTransactionsSelector, safeTransactionsSelector } from 'src/routes/safe/store/selectors' + +export const extendedTransactionsSelector = createSelector( + safeTransactionsSelector, + safeIncomingTransactionsSelector, + (transactions, incomingTransactions) => List([...transactions, ...incomingTransactions]), +) diff --git a/src/store/index.ts b/src/store/index.ts index 9f92024c..8574ecc4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -38,9 +38,9 @@ const finalCreateStore = composeEnhancers( applyMiddleware( thunk, routerMiddleware(history), + notificationsMiddleware, safeStorage, providerWatcher, - notificationsMiddleware, addressBookMiddleware, currencyValuesStorageMiddleware, ), diff --git a/src/test/builder/safe.dom.utils.tsx b/src/test/builder/safe.dom.utils.tsx index f7b57ac0..f90b72f5 100644 --- a/src/test/builder/safe.dom.utils.tsx +++ b/src/test/builder/safe.dom.utils.tsx @@ -1,13 +1,11 @@ -// import * as React from 'react' import TestUtils from 'react-dom/test-utils' -import { } from 'redux' import { Provider } from 'react-redux' import { render } from '@testing-library/react' import { ConnectedRouter } from 'connected-react-router' import PageFrame from 'src/components/layout/PageFrame' import ListItemText from 'src/components/List/ListItemText/index' -import fetchTransactions from 'src/routes/safe/store/actions/fetchTransactions' +// import fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions' import { sleep } from 'src/utils/timer' import { history, } from 'src/store' import AppRoutes from 'src/routes' @@ -15,13 +13,13 @@ import { SAFELIST_ADDRESS } from 'src/routes/routes' import { EMPTY_DATA } from 'src/logic/wallets/ethTransactions' import { wrapInSuspense } from 'src/utils/wrapInSuspense' -export const EXPAND_BALANCE_INDEX = 0 -export const EXPAND_OWNERS_INDEX = 1 -export const ADD_OWNERS_INDEX = 2 -export const EDIT_THRESHOLD_INDEX = 3 -export const EDIT_INDEX = 4 -export const WITHDRAW_INDEX = 5 -export const LIST_TXS_INDEX = 6 +// export const EXPAND_BALANCE_INDEX = 0 +// export const EXPAND_OWNERS_INDEX = 1 +// export const ADD_OWNERS_INDEX = 2 +// export const EDIT_THRESHOLD_INDEX = 3 +// export const EDIT_INDEX = 4 +// export const WITHDRAW_INDEX = 5 +// export const LIST_TXS_INDEX = 6 export const checkMinedTx = (Transaction, name) => { const paragraphs = TestUtils.scryRenderedDOMComponentsWithTag(Transaction, 'p') @@ -80,10 +78,10 @@ export const checkPendingTx = async ( } } -export const refreshTransactions = async (store, safeAddress) => { - await store.dispatch(fetchTransactions(safeAddress)) - await sleep(1500) -} +// export const refreshTransactions = async (store, safeAddress) => { +// await store.dispatch(fetchTransactions(safeAddress)) +// await sleep(1500) +// } const renderApp = (store) => ({ ...render( diff --git a/src/test/safe.dom.settings.name.test.ts b/src/test/safe.dom.settings.name.test.ts index f49053c7..a733d80d 100644 --- a/src/test/safe.dom.settings.name.test.ts +++ b/src/test/safe.dom.settings.name.test.ts @@ -1,11 +1,10 @@ -// import { fireEvent } from '@testing-library/react' import { aNewStore } from 'src/store' import { aMinedSafe } from 'src/test/builder/safe.redux.builder' import { renderSafeView } from 'src/test/builder/safe.dom.utils' import { sleep } from 'src/utils/timer' import '@testing-library/jest-dom/extend-expect' -import { SETTINGS_TAB_BTN_TEST_ID, SAFE_VIEW_NAME_HEADING_TEST_ID } from 'src/routes/safe/components/Layout/index' +import { SETTINGS_TAB_BTN_TEST_ID, SAFE_VIEW_NAME_HEADING_TEST_ID } from 'src/routes/safe/components/Layout' import { SAFE_NAME_INPUT_TEST_ID, SAFE_NAME_SUBMIT_BTN_TEST_ID } from 'src/routes/safe/components/Settings/SafeDetails' describe('DOM > Feature > Settings - Name', () => { diff --git a/truffle.js b/truffle.js index 82184f5b..3c7cf333 100644 --- a/truffle.js +++ b/truffle.js @@ -1,5 +1,3 @@ -// @flow - module.exports = { migrations_directory: './migrations', networks: { diff --git a/yarn.lock b/yarn.lock index 0818da89..071db573 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4140,7 +4140,7 @@ bn.js@4.11.8: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.0, bn.js@^4.11.1, bn.js@^4.11.6, bn.js@^4.11.8, bn.js@^4.4.0, bn.js@^4.8.0: +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.10.0, bn.js@^4.11.0, bn.js@^4.11.1, bn.js@^4.11.6, bn.js@^4.11.8, bn.js@^4.4.0, bn.js@^4.8.0: version "4.11.9" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== @@ -7112,6 +7112,18 @@ eth-sig-util@^1.4.2: ethereumjs-abi "git+https://github.com/ethereumjs/ethereumjs-abi.git" ethereumjs-util "^5.1.1" +eth-sig-util@^2.5.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/eth-sig-util/-/eth-sig-util-2.5.3.tgz#6938308b38226e0b3085435474900b03036abcbe" + integrity sha512-KpXbCKmmBUNUTGh9MRKmNkIPietfhzBqqYqysDavLseIiMUGl95k6UcPEkALAZlj41e9E6yioYXc1PC333RKqw== + dependencies: + buffer "^5.2.1" + elliptic "^6.4.0" + ethereumjs-abi "0.6.5" + ethereumjs-util "^5.1.1" + tweetnacl "^1.0.0" + tweetnacl-util "^0.15.0" + eth-tx-summary@^3.1.2: version "3.2.4" resolved "https://registry.yarnpkg.com/eth-tx-summary/-/eth-tx-summary-3.2.4.tgz#e10eb95eb57cdfe549bf29f97f1e4f1db679035c" @@ -7190,6 +7202,14 @@ ethereum-public-key-to-address@0.0.1: meow "^5.0.0" secp256k1 "^3.7.1" +ethereumjs-abi@0.6.5: + version "0.6.5" + resolved "https://registry.yarnpkg.com/ethereumjs-abi/-/ethereumjs-abi-0.6.5.tgz#5a637ef16ab43473fa72a29ad90871405b3f5241" + integrity sha1-WmN+8Wq0NHP6cqKa2QhxQFs/UkE= + dependencies: + bn.js "^4.10.0" + ethereumjs-util "^4.3.0" + ethereumjs-abi@0.6.8, "ethereumjs-abi@git+https://github.com/ethereumjs/ethereumjs-abi.git": version "0.6.8" resolved "git+https://github.com/ethereumjs/ethereumjs-abi.git#1cfbb13862f90f0b391d8a699544d5fe4dfb8c7b" @@ -7260,7 +7280,7 @@ ethereumjs-tx@^2.1.1, ethereumjs-tx@^2.1.2: ethereumjs-common "^1.5.0" ethereumjs-util "^6.0.0" -ethereumjs-util@4.5.0: +ethereumjs-util@4.5.0, ethereumjs-util@^4.3.0: version "4.5.0" resolved "https://registry.yarnpkg.com/ethereumjs-util/-/ethereumjs-util-4.5.0.tgz#3e9428b317eebda3d7260d854fddda954b1f1bc6" integrity sha1-PpQosxfuvaPXJg2FT93alUsfG8Y= @@ -16685,11 +16705,21 @@ tunnel@^0.0.6: resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== +tweetnacl-util@^0.15.0: + version "0.15.1" + resolved "https://registry.yarnpkg.com/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz#b80fcdb5c97bcc508be18c44a4be50f022eea00b" + integrity sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw== + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= +tweetnacl@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596" + integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw== + type-check@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"