Merge branch 'development' of https://github.com/gnosis/safe-react into 957-null-balances

# Conflicts:
#	package.json
This commit is contained in:
Agustin Pane 2020-06-05 09:05:01 -03:00
commit 6c857ee02a
84 changed files with 3456 additions and 1783 deletions

1
.gitignore vendored
View File

@ -1,7 +1,6 @@
node_modules/
build/
.DS_Store
build/
yarn-error.log
.env*
.idea/

View File

@ -21,6 +21,8 @@ matrix:
- env:
- REACT_APP_NETWORK='rinkeby'
- REACT_APP_GNOSIS_APPS_URL=${REACT_APP_GNOSIS_APPS_URL_STAGING}
cache:
yarn: true
before_install:
# Needed to deploy pull request and releases
- sudo apt-get update

View File

@ -1,6 +1,5 @@
// @flow
class LocalStorageMock {
store: Object
store
constructor() {
this.store = {}

View File

@ -1,4 +1,3 @@
// @flow
import Web3 from 'web3'
const window = global.window || {}

View File

@ -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

View File

@ -1,4 +1,3 @@
// @flow
const path = require('path')
// This is a custom Jest transformer turning file imports into filenames.

View File

@ -1,2 +1 @@
// @flow
jest.setTimeout(60000)

View File

@ -1,4 +1,3 @@
// @flow
const Migrations = artifacts.require('./Migrations.sol')
module.exports = deployer => deployer.deploy(Migrations)

View File

@ -1,4 +1,3 @@
// @flow
/* eslint-disable no-console */
const TokenOMG = artifacts.require('TokenOMG')
const TokenRDN = artifacts.require('TokenRDN')

View File

@ -151,25 +151,26 @@
"@gnosis.pm/safe-contracts": "1.1.1-dev.2",
"@gnosis.pm/safe-react-components": "^0.1.2",
"@gnosis.pm/util-contracts": "2.0.6",
"@ledgerhq/hw-transport-node-hid": "5.15.0",
"@material-ui/core": "4.9.14",
"@ledgerhq/hw-transport-node-hid": "5.16.0",
"@material-ui/core": "4.10.1",
"@material-ui/icons": "4.9.1",
"@material-ui/lab": "4.0.0-alpha.39",
"@openzeppelin/contracts": "3.0.1",
"async-sema": "^3.1.0",
"axios": "0.19.2",
"bignumber.js": "9.0.0",
"bnc-onboard": "1.9.1",
"bnc-onboard": "1.9.4",
"classnames": "^2.2.6",
"concurrently": "^5.2.0",
"connected-react-router": "6.8.0",
"currency-flags": "2.1.2",
"date-fns": "2.13.0",
"date-fns": "2.14.0",
"electron-is-dev": "^1.1.0",
"electron-log": "4.1.2",
"electron-log": "4.2.1",
"electron-updater": "4.3.1",
"eth-sig-util": "^2.5.3",
"express": "^4.17.1",
"final-form": "4.19.1",
"final-form": "4.20.0",
"final-form-calculate": "^1.3.1",
"history": "4.10.1",
"immortal-db": "^1.0.2",
@ -179,12 +180,12 @@
"material-ui-search-bar": "^1.0.0-beta.13",
"notistack": "https://github.com/gnosis/notistack.git#v0.9.4",
"open": "^7.0.3",
"polished": "3.6.3",
"polished": "3.6.4",
"qrcode.react": "1.0.0",
"query-string": "6.12.1",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-final-form": "6.4.0",
"react-final-form": "6.5.0",
"react-final-form-listeners": "^1.0.2",
"react-ga": "^2.7.0",
"react-hot-loader": "4.12.21",
@ -208,19 +209,19 @@
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/jest": "^25.2.1",
"@types/node": "^13.11.0",
"@types/node": "14.0.10",
"@types/react": "^16.9.32",
"@types/react-dom": "^16.9.6",
"@typescript-eslint/eslint-plugin": "^2.34.0",
"@typescript-eslint/parser": "^2.34.0",
"autoprefixer": "9.7.6",
"@typescript-eslint/eslint-plugin": "3.1.0",
"@typescript-eslint/parser": "3.1.0",
"autoprefixer": "9.8.0",
"cross-env": "^7.0.2",
"dotenv": "^8.2.0",
"dotenv-expand": "^5.1.0",
"electron": "7.1.8",
"electron-builder": "22.2.0",
"electron-builder": "22.7.0",
"electron-notarize": "^0.2.1",
"eslint": "^6.8.0",
"eslint": "6.8.0",
"eslint-config-prettier": "6.11.0",
"eslint-plugin-import": "2.20.2",
"eslint-plugin-jsx-a11y": "^6.2.3",
@ -229,12 +230,12 @@
"eslint-plugin-sort-destructure-keys": "1.3.4",
"ethereumjs-abi": "0.6.8",
"husky": "^4.2.2",
"lint-staged": "10.2.2",
"lint-staged": "10.2.8",
"node-sass": "^4.14.1",
"prettier": "2.0.5",
"react-app-rewired": "^2.1.6",
"truffle": "5.1.23",
"typescript": "~3.7.2",
"truffle": "5.1.28",
"typescript": "3.9.3",
"wait-on": "5.0.0"
}
}

View File

@ -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<any> {
@ -45,7 +47,7 @@ export default class Identicon extends React.PureComponent<any> {
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`

View File

@ -1,15 +0,0 @@
import Checkbox from '@material-ui/core/Checkbox'
import React from 'react'
class GnoCheckbox extends React.PureComponent<any> {
render() {
const {
input: { checked, name, onChange, ...restInput },
...rest
} = this.props
return <Checkbox {...rest} checked={!!checked} inputProps={restInput} name={name} onChange={onChange} />
}
}
export default GnoCheckbox

View File

@ -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'

View File

@ -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()

View File

@ -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
}
}

View File

@ -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
// 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 nonCancelledTransactions
})
return allAwaitingTransactions
}

View File

@ -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

View File

@ -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'),

View File

@ -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

View File

@ -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) => {

View File

@ -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<TokenProps>({
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<TokenProps>

View File

@ -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<boolean> => {
// 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<boolean> => {
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<boolean> => {
const ERC721Token = await getStandardTokenContract()
let isERC721 = false
try {
isERC721 = true
await ERC721Token.at(contractAddress)
} catch (error) {
console.warn('Asset not found')
}
return isERC721
}

View File

@ -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) => {

View File

@ -81,9 +81,9 @@ export const createSafe = (values, userAccount) => {
const Open = ({ addSafe, network, provider, userAccount }) => {
const [loading, setLoading] = useState(false)
const [showProgress, setShowProgress] = useState()
const [showProgress, setShowProgress] = useState(false)
const [creationTxPromise, setCreationTxPromise] = useState()
const [safeCreationPendingInfo, setSafeCreationPendingInfo] = useState()
const [safeCreationPendingInfo, setSafeCreationPendingInfo] = useState<any>()
const [safePropsFromUrl, setSafePropsFromUrl] = useState()
useEffect(() => {
@ -182,7 +182,7 @@ const Open = ({ addSafe, network, provider, userAccount }) => {
<Page>
{showProgress ? (
<Opening
creationTxHash={safeCreationPendingInfo ? safeCreationPendingInfo.txHash : undefined}
creationTxHash={safeCreationPendingInfo?.txHash}
onCancel={onCancel}
onRetry={onRetry}
onSuccess={onSafeCreated as any}

View File

@ -1,4 +1,3 @@
// @flow
import React from 'react'
import styled from 'styled-components'

View File

@ -103,7 +103,7 @@ const BackButton = styled(Button)`
const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, provider, submittedPromise }: any) => {
const [loading, setLoading] = useState(true)
const [stepIndex, setStepIndex] = useState()
const [stepIndex, setStepIndex] = useState(0)
const [safeCreationTxHash, setSafeCreationTxHash] = useState()
const [createdSafeAddress, setCreatedSafeAddress] = useState()

View File

@ -1,4 +1,3 @@
// @flow
import { ContinueFooter, GenericFooter } from './components/Footer'
export const isConfirmationStep = (stepIndex?: number) => stepIndex === 0

View File

@ -69,7 +69,7 @@ const MethodsDropdown = ({ onChange }) => {
<>
<button className={classes.button} onClick={handleClick} type="button">
<span className={classNames(classes.buttonInner, anchorEl && classes.openMenuButton)}>
{selectedMethod.name}
{(selectedMethod as Record<string, string>).name}
</span>
</button>
<Menu
@ -120,7 +120,7 @@ const MethodsDropdown = ({ onChange }) => {
>
<ListItemText primary={name} />
<ListItemIcon className={classes.iconRight}>
{signatureHash === selectedMethod.signatureHash ? (
{signatureHash === (selectedMethod as Record<string, string>).signatureHash ? (
<img alt="checked" src={CheckIcon} />
) : (
<span />

View File

@ -0,0 +1,64 @@
import React from 'react'
import Col from 'src/components/layout/Col'
import Field from 'src/components/forms/Field'
import TextField from 'src/components/forms/TextField'
import { composeValidators, mustBeEthereumAddress, required } from 'src/components/forms/validator'
import { Checkbox } from '@gnosis.pm/safe-react-components'
type Props = {
type: string
keyValue: string
placeholder: string
}
const InputComponent = ({ type, keyValue, placeholder }: Props) => {
if (!type) {
return null
}
switch (type) {
case 'bool': {
const inputProps = {
'data-testid': keyValue,
}
return (
<Col>
<Field component={Checkbox} name={keyValue} label={placeholder} type="checkbox" inputProps={inputProps} />
</Col>
)
}
case 'address': {
return (
<Col>
<Field
component={TextField}
name={keyValue}
placeholder={placeholder}
testId={keyValue}
text={placeholder}
type="text"
validate={composeValidators(required, mustBeEthereumAddress)}
/>
</Col>
)
}
default: {
return (
<Col>
<Field
component={TextField}
name={keyValue}
placeholder={placeholder}
testId={keyValue}
text={placeholder}
type="text"
validate={required}
/>
</Col>
)
}
}
}
export default InputComponent

View File

@ -1,16 +1,14 @@
import React from 'react'
import { useField } from 'react-final-form'
import Field from 'src/components/forms/Field'
import TextField from 'src/components/forms/TextField'
import { composeValidators, mustBeEthereumAddress, required } from 'src/components/forms/validator'
import Col from 'src/components/layout/Col'
import Row from 'src/components/layout/Row'
import InputComponent from './InputComponent'
const RenderInputParams = () => {
const {
meta: { valid: validABI },
} = useField('abi', { valid: true } as any)
} = useField('abi', { value: true })
const {
input: { value: method },
}: any = useField('selectedMethod', { value: true })
@ -21,21 +19,10 @@ const RenderInputParams = () => {
: method.inputs.map(({ name, type }, index) => {
const placeholder = name ? `${name} (${type})` : type
const key = `methodInput-${method.name}_${index}_${type}`
const validate = type === 'address' ? composeValidators(required, mustBeEthereumAddress) : required
return (
<Row key={key} margin="sm">
<Col>
<Field
component={TextField}
name={key}
placeholder={placeholder}
testId={key}
text={placeholder}
type="text"
validate={validate}
/>
</Col>
<InputComponent type={type} keyValue={key} placeholder={placeholder} />
</Row>
)
})

View File

@ -1,5 +1,5 @@
import { makeStyles } from '@material-ui/core/styles'
import { withSnackbar } from 'notistack'
import { useSnackbar } from 'notistack'
import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
@ -22,10 +22,33 @@ import Header from 'src/routes/safe/components/Balances/SendModal/screens/Contra
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import createTransaction from 'src/routes/safe/store/actions/createTransaction'
import { safeSelector } from 'src/routes/safe/store/selectors'
import { getValueFromTxInputs } from '../utils'
const useStyles = makeStyles(styles as any)
const useStyles = makeStyles(styles)
const ContractInteractionReview = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }: any) => {
export type TransactionReviewType = {
abi?: string
contractAddress?: string
data?: string
value?: string
selectedMethod?: {
action: string
signature: string
signatureHash: string
constant: boolean
inputs: []
name: string
}
}
type Props = {
onClose: () => void
onPrev: () => void
tx: TransactionReviewType
}
const ContractInteractionReview = ({ onClose, onPrev, tx }: Props) => {
const { enqueueSnackbar, closeSnackbar } = useSnackbar()
const classes = useStyles()
const dispatch = useDispatch()
const { address: safeAddress } = useSelector(safeSelector)
@ -118,6 +141,7 @@ const ContractInteractionReview = ({ closeSnackbar, enqueueSnackbar, onClose, on
</Row>
{tx.selectedMethod.inputs.map(({ name, type }, index) => {
const key = `methodInput-${tx.selectedMethod.name}_${index}_${type}`
const value: string = getValueFromTxInputs(key, type, tx)
return (
<React.Fragment key={key}>
@ -128,7 +152,7 @@ const ContractInteractionReview = ({ closeSnackbar, enqueueSnackbar, onClose, on
</Row>
<Row align="center" margin="md">
<Paragraph className={classes.value} noMargin size="md" style={{ margin: 0 }}>
{tx[key]}
{value}
</Paragraph>
</Row>
</React.Fragment>
@ -173,4 +197,4 @@ const ContractInteractionReview = ({ closeSnackbar, enqueueSnackbar, onClose, on
)
}
export default withSnackbar(ContractInteractionReview as any)
export default ContractInteractionReview

View File

@ -1,6 +1,7 @@
import { border, lg, md, secondaryText, sm } from 'src/theme/variables'
import { createStyles } from '@material-ui/core'
export const styles = () => ({
export const styles = createStyles({
heading: {
padding: `${md} ${lg}`,
justifyContent: 'flex-start',

View File

@ -36,7 +36,7 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
const handleSubmit = async ({ contractAddress, selectedMethod, value, ...values }) => {
if (value || (contractAddress && selectedMethod)) {
const data = await createTxObject(selectedMethod, contractAddress, values).encodeABI()
onNext({ contractAddress, data, selectedMethod, value, ...values })
onNext({ ...values, contractAddress, data, selectedMethod, value })
}
}

View File

@ -4,6 +4,7 @@ import { mustBeEthereumAddress, mustBeEthereumContractAddress } from 'src/compon
import { getNetwork } from 'src/config'
import { getConfiguredSource } from 'src/logic/contractInteraction/sources'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { TransactionReviewType } from '../Review'
export const NO_CONTRACT = 'no contract'
@ -56,3 +57,11 @@ export const createTxObject = (method, contractAddress, values) => {
return contract.methods[name](...args)
}
export const getValueFromTxInputs = (key: string, type: string, tx: TransactionReviewType): string => {
let value = tx[key]
if (type === 'bool') {
value = tx[key] ? String(tx[key]) : 'false'
}
return value
}

View File

@ -6,7 +6,6 @@ import { useSelector } from 'react-redux'
import { styles } from './style'
import { getSymbolAndDecimalsFromContract } from './utils'
import Checkbox from 'src/components/forms/Checkbox'
import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField'
@ -25,6 +24,7 @@ import {
doesntExistInAssetsList,
} from 'src/routes/safe/components/Balances/Tokens/screens/AddCustomAsset/validators'
import TokenPlaceholder from 'src/routes/safe/components/Balances/assets/token_placeholder.svg'
import { Checkbox } from '@gnosis.pm/safe-react-components'
export const ADD_CUSTOM_ASSET_ADDRESS_INPUT_TEST_ID = 'add-custom-asset-address-input'
export const ADD_CUSTOM_ASSET_SYMBOLS_INPUT_TEST_ID = 'add-custom-asset-symbols-input'
@ -140,11 +140,14 @@ const AddCustomAsset = (props) => {
text="Token decimals*"
type="text"
/>
<Block justify="left">
<Field className={classes.checkbox} component={Checkbox} name="showForAllSafes" type="checkbox" />
<Paragraph className={classes.checkboxLabel} size="md" weight="bolder">
Activate assets for all Safes
</Paragraph>
<Block justify="center">
<Field
className={classes.checkbox}
component={Checkbox}
name="showForAllSafes"
type="checkbox"
label="Activate assets for all Safes"
/>
</Block>
</Col>
<Col align="center" layout="column" xs={6}>

View File

@ -6,7 +6,6 @@ import { styles } from './style'
import { getSymbolAndDecimalsFromContract } from './utils'
import { addressIsTokenContract, doesntExistInTokenList } from './validators'
import Checkbox from 'src/components/forms/Checkbox'
import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField'
@ -21,6 +20,7 @@ import Row from 'src/components/layout/Row'
import TokenPlaceholder from 'src/routes/safe/components/Balances/assets/token_placeholder.svg'
import { checksumAddress } from 'src/utils/checksumAddress'
import { Checkbox } from '@gnosis.pm/safe-react-components'
export const ADD_CUSTOM_TOKEN_ADDRESS_INPUT_TEST_ID = 'add-custom-token-address-input'
export const ADD_CUSTOM_TOKEN_SYMBOLS_INPUT_TEST_ID = 'add-custom-token-symbols-input'
@ -161,11 +161,14 @@ const AddCustomToken = (props) => {
text="Token decimals*"
type="text"
/>
<Block justify="left">
<Field className={classes.checkbox} component={Checkbox} name="showForAllSafes" type="checkbox" />
<Paragraph className={classes.checkboxLabel} size="md" weight="bolder">
Activate token for all Safes
</Paragraph>
<Block justify="center">
<Field
className={classes.checkbox}
component={Checkbox}
name="showForAllSafes"
type="checkbox"
label="Activate token for all Safes"
/>
</Block>
</Col>
<Col align="center" layout="column" xs={6}>

View File

@ -51,7 +51,7 @@ const LayoutHeader = (props) => {
className={classes.send}
color="primary"
disabled={!granted}
onClick={() => showSendFunds('Ether')}
onClick={() => showSendFunds('')}
size="small"
variant="contained"
>

View File

@ -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 ? (
<>
<Paragraph noMargin>
<Bold>Created: </Bold>
@ -48,5 +50,5 @@ export const CreationTx = (props) => {
{tx.masterCopy ? <EtherscanLink cut={8} type="address" value={tx.masterCopy} /> : 'n/a'}
</Block>
</>
)
) : null
}

View File

@ -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 : (
<>
return isIncomingTx ? (
<Paragraph noMargin>
<Bold>Created: </Bold>
{formatDate(tx.executionDate)}
</Paragraph>
</>
)
) : null
}

View File

@ -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 ? (
<>
<Paragraph noMargin>
<Bold>Created: </Bold>
@ -36,5 +48,5 @@ export const OutgoingTx = (props) => {
</Paragraph>
)}
</>
)
) : null
}

View File

@ -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,57 +46,39 @@ 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 <Block className={classes.executor}>Pending</Block>
}
return (
<Block className={classes.container}>
<div className={cn(classes.verticalLine, (confirmed || thresholdReached || executor) && getTimelineLine())} />
<div className={classes.circleState}>
<Img alt="" src={imgCircle} />
</div>
<Identicon address={owner} className={classes.icon} diameter={32} />
<Block>
<Paragraph className={classes.name} noMargin>
{nameInAdbk}
</Paragraph>
<EtherscanLink className={classes.address} cut={4} type="address" value={owner} />
</Block>
<Block className={classes.spacer} />
{owner === userAddress && (
<Block>
{isCancelTx ? (
<>
{showRejectBtn && (
<Button
className={cn(classes.button, classes.lastButton)}
color="secondary"
onClick={onTxReject}
testId={REJECT_TX_BTN_TEST_ID}
variant="contained"
>
Reject
</Button>
)}
{showExecuteRejectBtn && (
<Button
className={cn(classes.button, classes.lastButton)}
color="secondary"
onClick={onTxReject}
testId={EXECUTE_REJECT_TX_BTN_TEST_ID}
variant="contained"
>
Execute
</Button>
)}
</>
) : (
<>
{showConfirmBtn && (
<Button
@ -118,9 +103,65 @@ const OwnerComponent = ({
</Button>
)}
</>
)
}
const rejectButton = () => {
if (pendingRejectAction) {
return <Block className={classes.executor}>Pending</Block>
}
if (pendingAcceptAction) {
return null
}
return (
<>
{showRejectBtn && (
<Button
className={cn(classes.button, classes.lastButton)}
color="secondary"
onClick={onTxReject}
testId={REJECT_TX_BTN_TEST_ID}
variant="contained"
>
Reject
</Button>
)}
{showExecuteRejectBtn && (
<Button
className={cn(classes.button, classes.lastButton)}
color="secondary"
onClick={onTxReject}
testId={EXECUTE_REJECT_TX_BTN_TEST_ID}
variant="contained"
>
Execute
</Button>
)}
</>
)
}
return (
<Block className={classes.container}>
<div
className={cn(
classes.verticalLine,
(confirmed || thresholdReached || executor || pendingAcceptAction || pendingRejectAction) &&
getTimelineLine(),
)}
/>
<div className={classes.circleState}>
<Img alt="" src={imgCircle} />
</div>
<Identicon address={owner} className={classes.icon} diameter={32} />
<Block>
<Paragraph className={classes.name} noMargin>
{nameInAdbk}
</Paragraph>
<EtherscanLink className={classes.address} cut={4} type="address" value={owner} />
</Block>
)}
<Block className={classes.spacer} />
{owner === userAddress && <Block>{isCancelTx ? rejectButton() : confirmButton()}</Block>}
{owner === executor && <Block className={classes.executor}>Executor</Block>}
</Block>
)

View File

@ -38,7 +38,7 @@ const OwnersList = ({
userAddress={userAddress}
/>
))}
{ownersUnconfirmed.map((owner) => (
{ownersUnconfirmed.map(({ hasPendingAcceptActions, hasPendingRejectActions, owner }) => (
<OwnerComponent
classes={classes}
executor={executor}
@ -48,6 +48,8 @@ const OwnersList = ({
onTxExecute={onTxExecute}
onTxReject={onTxReject}
owner={owner}
pendingAcceptAction={hasPendingAcceptActions}
pendingRejectAction={hasPendingRejectActions}
showConfirmBtn={showConfirmBtn}
showExecuteBtn={showExecuteBtn}
showExecuteRejectBtn={showExecuteRejectBtn}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
<circle cx="5" cy="5" r="5" fill="#C97C05" fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 161 B

View File

@ -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)
}
})
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 &&

View File

@ -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,
},

View File

@ -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

View File

@ -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

View File

@ -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',

View File

@ -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]: <TxType origin={tx.origin} txType={txType} />,
[TX_TABLE_TYPE_ID]: <TxType origin={tx.origin} txType={tx.type} />,
[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}`))
})
}

View File

@ -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,14 +101,11 @@ const TxsTable = ({ classes }) => {
</Row>
</TableCell>
<TableCell className={classes.expandCellStyle}>
{!row.tx.creationTx && (
<IconButton disableRipple>
{expandedTx === row.safeTxHash ? <ExpandLess /> : <ExpandMore />}
{expandedTx === row.tx.safeTxHash ? <ExpandLess /> : <ExpandMore />}
</IconButton>
)}
</TableCell>
</TableRow>
{!row.tx.creationTx && (
<TableRow>
<TableCell
className={classes.extendedTxContainer}
@ -125,25 +122,6 @@ const TxsTable = ({ classes }) => {
/>
</TableCell>
</TableRow>
)}
{row.tx.creationTx && (
<TableRow>
<TableCell
className={classes.extendedTxContainer}
colSpan={6}
style={{ paddingBottom: 0, paddingTop: 0 }}
>
<Collapse
component={() => (
<ExpandedTxComponent cancelTx={row[TX_TABLE_RAW_CANCEL_TX_ID]} tx={row[TX_TABLE_RAW_TX_ID]} />
)}
in={expandedTx === row.tx.safeTxHash}
timeout="auto"
unmountOnExit
/>
</TableCell>
</TableRow>
)}
</React.Fragment>
))
}

View File

@ -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'

View File

@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { batch, useDispatch, useSelector } from 'react-redux'
import { useLocation } from 'react-router-dom'
import fetchCollectibles from 'src/logic/collectibles/store/actions/fetchCollectibles'
import { fetchCurrencyValues } from 'src/logic/currencyValues/store/actions/fetchCurrencyValues'
@ -8,13 +9,14 @@ import fetchSafeTokens from 'src/logic/tokens/store/actions/fetchSafeTokens'
import { fetchTokens } from 'src/logic/tokens/store/actions/fetchTokens'
import { COINS_LOCATION_REGEX, COLLECTIBLES_LOCATION_REGEX } from 'src/routes/safe/components/Balances'
import { safeParamAddressFromStateSelector } from 'src/routes/safe/store/selectors'
import { history } from 'src/store'
export const useFetchTokens = () => {
export const useFetchTokens = (): void => {
const dispatch = useDispatch()
const address = useSelector(safeParamAddressFromStateSelector)
const address: string | null = useSelector(safeParamAddressFromStateSelector)
const location = useLocation()
useMemo(() => {
if (COINS_LOCATION_REGEX.test(history.location.pathname)) {
if (COINS_LOCATION_REGEX.test(location.pathname)) {
batch(() => {
// fetch tokens there to get symbols for tokens in TXs list
dispatch(fetchTokens())
@ -23,12 +25,12 @@ export const useFetchTokens = () => {
})
}
if (COLLECTIBLES_LOCATION_REGEX.test(history.location.pathname)) {
if (COLLECTIBLES_LOCATION_REGEX.test(location.pathname)) {
batch(() => {
dispatch(fetchCollectibles()).then(() => {
dispatch(activateAssetsByBalance(address))
})
})
}
}, [address, dispatch])
}, [address, dispatch, location])
}

View File

@ -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()

View File

@ -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])
},
)

View File

@ -1,5 +0,0 @@
import { createAction } from 'redux-actions'
export const ADD_CANCELLATION_TRANSACTIONS = 'ADD_CANCELLATION_TRANSACTIONS'
export const addCancellationTransactions = createAction(ADD_CANCELLATION_TRANSACTIONS)

View File

@ -1,5 +0,0 @@
import { createAction } from 'redux-actions'
export const ADD_TRANSACTIONS = 'ADD_TRANSACTIONS'
export const addTransactions = createAction(ADD_TRANSACTIONS)

View File

@ -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) => {
try {
txHash = hash
closeSnackbar(beforeExecutionKey)
pendingExecutionKey = showSnackbar(notificationsQueue.pendingExecution, enqueueSnackbar, closeSnackbar)
try {
await saveTxToHistory({
...txArgs,
txHash,
origin,
} as any)
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) => {
.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)
if (pendingExecutionKey) {
closeSnackbar(pendingExecutionKey)
}
showSnackbar(notificationsQueue.afterExecutionError, enqueueSnackbar, closeSnackbar)
const executeDataUsedSignatures = safeInstance.contract.methods

View File

@ -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,
})

View File

@ -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))
}
}

View File

@ -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) => {
.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,14 +194,21 @@ const processTransaction = ({
})
} catch (err) {
console.error(err)
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
}

View File

@ -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)

View File

@ -1,4 +1,3 @@
// @flow
import { createAction } from 'redux-actions'
export const ADD_OR_UPDATE_TRANSACTIONS = 'ADD_OR_UPDATE_TRANSACTIONS'

View File

@ -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

View File

@ -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))
}
}

View File

@ -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) })
}

View File

@ -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<any[]> => {
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<SafeTransactionsType> => {
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),
}
}

View File

@ -0,0 +1,5 @@
import { createAction } from 'redux-actions'
export const REMOVE_CANCELLATION_TRANSACTION = 'REMOVE_CANCELLATION_TRANSACTION'
export const removeCancellationTransaction = createAction(REMOVE_CANCELLATION_TRANSACTION)

View File

@ -0,0 +1,5 @@
import { createAction } from 'redux-actions'
export const REMOVE_TRANSACTION = 'REMOVE_TRANSACTION'
export const removeTransaction = createAction(REMOVE_TRANSACTION)

View File

@ -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

View File

@ -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<boolean> => {
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<any> => {
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<Confirmation> => {
return List(
tx.confirmations.map((conf) =>
makeConfirmation({
owner: conf.owner,
hash: conf.transactionHash,
signature: conf.signature,
}),
),
)
}
export const isTransactionCancelled = (
tx: TxServiceModel,
outgoingTxs: Array<TxServiceModel>,
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<Transaction> => {
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<any> => {
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<typeof messageTypes>(typedData).toString('hex')}`
}

View File

@ -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

View File

@ -1,6 +1,7 @@
import { Record } from 'immutable'
import { ConfirmationProps } from './types/confirmation'
export const makeConfirmation = Record({
export const makeConfirmation = Record<ConfirmationProps>({
owner: '',
type: 'initialised',
hash: '',

View File

@ -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<TransactionProps>({
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',
})

View File

@ -0,0 +1,10 @@
import { RecordOf } from 'immutable'
export type ConfirmationProps = {
owner: string
type: string
hash: string
signature: string | null
}
export type Confirmation = RecordOf<ConfirmationProps>

View File

@ -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<any>
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<PendingActionValues, import('immutable').List<any>>
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<TransactionProps>
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
}

View File

@ -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(),
)

View File

@ -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
const txsToStore = stateTransactionsList.withMutations((txsList) => {
transactions.forEach((updateTx) => {
const txIndex = stateTransactionsList.findIndex((txIterator) => txIterator.nonce === updateTx.nonce)
if (txIndex !== -1) {
const storedTxIndex = txsList.findIndex((txIterator) => txIterator.nonce === updateTx.nonce)
if (storedTxIndex !== -1) {
// Update
newTxList = stateTransactionsList.update(txIndex, (oldTx) => oldTx.merge(updateTx))
map.set(safeAddress, newTxList)
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
map.update(safeAddress, (oldTxList) => oldTxList.merge(List([updateTx])))
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(),

View File

@ -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'
@ -32,7 +32,7 @@ const cancellationTransactionsSelector = (state) => state[CANCELLATION_TRANSACTI
const incomingTransactionsSelector = (state) => state[INCOMING_TRANSACTIONS_REDUCER_ID]
export const safeParamAddressFromStateSelector = (state) => {
export const safeParamAddressFromStateSelector = (state): string | null => {
const match = matchPath(state.router.location.pathname, { path: `${SAFELIST_ADDRESS}/:safeAddress` })
if (match) {
@ -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) => {

View File

@ -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]),
)

View File

@ -38,9 +38,9 @@ const finalCreateStore = composeEnhancers(
applyMiddleware(
thunk,
routerMiddleware(history),
notificationsMiddleware,
safeStorage,
providerWatcher,
notificationsMiddleware,
addressBookMiddleware,
currencyValuesStorageMiddleware,
),

View File

@ -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(

View File

@ -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', () => {

View File

@ -1,5 +1,3 @@
// @flow
module.exports = {
migrations_directory: './migrations',
networks: {

2162
yarn.lock

File diff suppressed because it is too large Load Diff