Merge branch 'development' into issue-1144

This commit is contained in:
nicosampler 2020-08-06 08:22:19 -03:00
commit 18dd6b7e97
59 changed files with 2528 additions and 830 deletions

26
.github/workflows/cla.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: 'CLA Assistant'
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: 'CLA Assistant'
if: (github.event.comment.body == 'recheckcla' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Alpha Release
uses: gnosis/github-action@master
# GitHub token, automatically provided to the action
# (No need to define this secret in the repo settings)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
path-to-signatures: 'signatures/version1/cla.json'
path-to-cla-document: 'https://github.com/gnosis/safe-react/blob/master/GNOSISCLA.md'
branch: 'cla-signatures'
allowlist: lukasschor,mikheevm,rmeissner,germartinez,fernandomg,Agupane,nicosampler,matextrem,gabitoesmiapodo,davidalbela,alongoni,Uxio0,dasanra,miguelmota,francovenica,tschubotz,luarx,giacomolicari,gnosis-info,bot*
empty-commit-flag: false
blockchain-storage-flag: false

View File

@ -106,7 +106,7 @@ Add additional notes about how to deploy this on a live system
* [Truffle React Box](https://github.com/truffle-box/react-box) - The web framework used
* [Ganache](https://github.com/trufflesuite/ganache-cli) - Fast Ethereum RPC client
* [React](https://reactjs.org/) - A JS library for building user interfaces
* [Material UI 1.X](https://material-ui-next.com/) - React components that implement Google's Material Design
* [Material UI 4.X](https://material-ui.com/) - React components that implement Google's Material Design
* [redux, immutable, reselect, final-form](https://redux.js.org/) - React ecosystem libraries
* [Flow](https://flow.org/) - Static Type Checker

View File

@ -7,14 +7,29 @@ import styled from 'styled-components'
const Wrapper = styled.div``
const HeaderWrapper = styled.div``
const TitleWrapper = styled.div``
const Header = styled.div`
display: flex;
align-items: center;
`
const Title = styled.div``
interface Collapse {
title: React.ReactElement | string
description?: React.ReactElement | string
collapseClassName?: string
headerWrapperClassName?: string
}
const Collapse = ({ children, description, title }: any) => {
const Collapse: React.FC<Collapse> = ({
children,
description = null,
title,
collapseClassName,
headerWrapperClassName,
}): React.ReactElement => {
const [open, setOpen] = React.useState(false)
const handleClick = () => {
@ -23,15 +38,17 @@ const Collapse = ({ children, description, title }: any) => {
return (
<Wrapper>
<Title>{title}</Title>
<Header>
<IconButton disableRipple onClick={handleClick} size="small">
{open ? <ExpandLess /> : <ExpandMore />}
</IconButton>
{description}
</Header>
<HeaderWrapper className={headerWrapperClassName} onClick={handleClick}>
<TitleWrapper>{title}</TitleWrapper>
<Header>
<IconButton disableRipple size="small">
{open ? <ExpandLess /> : <ExpandMore />}
</IconButton>
{description}
</Header>
</HeaderWrapper>
<CollapseMUI in={open} timeout="auto" unmountOnExit>
<CollapseMUI in={open} timeout="auto" unmountOnExit className={collapseClassName}>
{children}
</CollapseMUI>
</Wrapper>

View File

@ -27,7 +27,13 @@ const useStyles = makeStyles({
},
})
const CopyBtn = ({ className = '', content, increaseZindex = false }) => {
interface CopyBtnProps {
className?: string
content: string
increaseZindex?: boolean
}
const CopyBtn = ({ className = '', content, increaseZindex = false }: CopyBtnProps): React.ReactElement => {
const [clicked, setClicked] = useState(false)
const classes = useStyles()
const customClasses = increaseZindex ? { popper: classes.increasedPopperZindex } : {}
@ -51,7 +57,8 @@ const CopyBtn = ({ className = '', content, increaseZindex = false }) => {
<Img
alt="Copy to clipboard"
height={20}
onClick={() => {
onClick={(event) => {
event.stopPropagation()
copyToClipboard(content)
setClicked(true)
}}

View File

@ -27,7 +27,19 @@ const useStyles = makeStyles({
},
})
const EtherscanBtn = ({ className = '', increaseZindex = false, type, value }) => {
interface EtherscanBtnProps {
className?: string
increaseZindex?: boolean
type: 'tx' | 'address'
value: string
}
const EtherscanBtn = ({
className = '',
increaseZindex = false,
type,
value,
}: EtherscanBtnProps): React.ReactElement => {
const classes = useStyles()
const customClasses = increaseZindex ? { popper: classes.increasedPopperZindex } : {}
@ -36,6 +48,7 @@ const EtherscanBtn = ({ className = '', increaseZindex = false, type, value }) =
<a
aria-label="Show details on Etherscan"
className={cn(classes.container, className)}
onClick={(event) => event.stopPropagation()}
href={getEtherScanLink(type, value)}
rel="noopener noreferrer"
target="_blank"

View File

@ -1,4 +1,4 @@
import { withStyles } from '@material-ui/core/styles'
import { makeStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import React from 'react'
@ -11,15 +11,29 @@ import Span from 'src/components/layout/Span'
import { shortVersionOf } from 'src/logic/wallets/ethAddresses'
import EllipsisTransactionDetails from 'src/routes/safe/components/AddressBook/EllipsisTransactionDetails'
const EtherscanLink = ({ classes, cut, knownAddress, type, value }: any) => (
<Block className={classes.etherscanLink}>
<Span className={cn(knownAddress && classes.addressParagraph, classes.address)} size="md">
{cut ? shortVersionOf(value, cut) : value}
</Span>
<CopyBtn className={cn(classes.button, classes.firstButton)} content={value} />
<EtherscanBtn className={classes.button} type={type} value={value} />
{knownAddress !== undefined ? <EllipsisTransactionDetails address={value} knownAddress={knownAddress} /> : null}
</Block>
)
const useStyles = makeStyles(styles)
export default withStyles(styles as any)(EtherscanLink)
interface EtherscanLinkProps {
className?: string
cut?: number
knownAddress?: boolean
type?: 'tx' | 'address'
value: string
}
const EtherscanLink = ({ className, cut, knownAddress, type, value }: EtherscanLinkProps): React.ReactElement => {
const classes = useStyles()
return (
<Block className={cn(classes.etherscanLink, className)}>
<Span className={cn(knownAddress && classes.addressParagraph, classes.address)} size="md">
{cut ? shortVersionOf(value, cut) : value}
</Span>
<CopyBtn className={cn(classes.button, classes.firstButton)} content={value} />
<EtherscanBtn className={classes.button} type={type} value={value} />
{knownAddress !== undefined ? <EllipsisTransactionDetails address={value} knownAddress={knownAddress} /> : null}
</Block>
)
}
export default EtherscanLink

View File

@ -1,6 +1,7 @@
import { createStyles } from '@material-ui/core/styles'
import { secondaryText } from 'src/theme/variables'
export const styles = () => ({
export const styles = createStyles({
etherscanLink: {
display: 'flex',
alignItems: 'center',
@ -11,7 +12,7 @@ export const styles = () => ({
},
address: {
display: 'block',
flexShrink: '1',
flexShrink: 1,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},

View File

@ -4,7 +4,7 @@ export const FIXED = 'fixed'
export const buildOrderFieldFrom = (attr: string): string => `${attr}Order`
const desc = (a, b, orderBy, orderProp) => {
const desc = (a: string, b: string, orderBy: string, orderProp: boolean): number => {
const order = orderProp ? buildOrderFieldFrom(orderBy) : orderBy
if (b[order] < a[order]) {
@ -39,9 +39,8 @@ export const stableSort = (dataArray, cmp, fixed) => {
}
export const getSorting = (
order: string,
orderBy?: string,
orderProp?: boolean,
): ((a: unknown, b: unknown) => number) => {
return order === 'desc' ? (a, b) => desc(a, b, orderBy, orderProp) : (a, b) => -desc(a, b, orderBy, orderProp)
}
order: 'desc' | 'asc',
orderBy: string,
orderProp: boolean,
): ((a: string, b: string) => number) =>
order === 'desc' ? (a, b) => desc(a, b, orderBy, orderProp) : (a, b) => -desc(a, b, orderBy, orderProp)

View File

@ -13,3 +13,5 @@ export const makeAddressBookEntry = Record<AddressBookEntryProps>({
name: '',
isOwner: false,
})
export type AddressBookEntry = RecordOf<AddressBookEntryProps>

View File

@ -1,7 +1,7 @@
import { List, Map } from 'immutable'
import { handleActions } from 'redux-actions'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { AddressBookEntry, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { ADD_ADDRESS_BOOK } from 'src/logic/addressBook/store/actions/addAddressBook'
import { ADD_ENTRY } from 'src/logic/addressBook/store/actions/addAddressBookEntry'
import { ADD_OR_UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
@ -14,6 +14,9 @@ import { checksumAddress } from 'src/utils/checksumAddress'
export const ADDRESS_BOOK_REDUCER_ID = 'addressBook'
export type AddressBookCollection = List<AddressBookEntry>
export type AddressBookState = Map<string, Map<string, AddressBookCollection>>
export const buildAddressBook = (storedAdbk) => {
let addressBookBuilt = Map([])
Object.entries(storedAdbk).forEach((adbkProps: any) => {

View File

@ -15,7 +15,7 @@ interface AddressBookReducerStateSerialized extends AddressBookReducerState {
export interface AddressBookMap extends Map<string> {
toJS(): AddressBookMapSerialized
get(key: string): List<AddressBookEntryRecord>
get(key: string, notSetValue: unknown): List<AddressBookEntryRecord>
}
export interface AddressBookReducerMap extends Map<string, any> {

View File

@ -15,7 +15,7 @@ export const getAddressBook = createSelector(
(addressBook, safeAddress) => {
let result = List([])
if (addressBook) {
result = addressBook.get(safeAddress)
result = addressBook.get(safeAddress, List())
}
return result
},

View File

@ -1,84 +1,5 @@
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
// https://github.com/gnosis/safe-contracts/blob/development/contracts/GnosisSafe.sol
// [
// { name: "addOwnerWithThreshold", id: "0x0d582f13" },
// { name: "DOMAIN_SEPARATOR_TYPEHASH", id: "0x1db61b54" },
// { name: "isOwner", id: "0x2f54bf6e" },
// { name: "execTransactionFromModule", id: "0x468721a7" },
// { name: "signedMessages", id: "0x5ae6bd37" },
// { name: "enableModule", id: "0x610b5925" },
// { name: "changeThreshold", id: "0x694e80c3" },
// { name: "approvedHashes", id: "0x7d832974" },
// { name: "changeMasterCopy", id: "0x7de7edef" },
// { name: "SENTINEL_MODULES", id: "0x85e332cd" },
// { name: "SENTINEL_OWNERS", id: "0x8cff6355" },
// { name: "getOwners", id: "0xa0e67e2b" },
// { name: "NAME", id: "0xa3f4df7e" },
// { name: "nonce", id: "0xaffed0e0" },
// { name: "getModules", id: "0xb2494df3" },
// { name: "SAFE_MSG_TYPEHASH", id: "0xc0856ffc" },
// { name: "SAFE_TX_TYPEHASH", id: "0xccafc387" },
// { name: "disableModule", id: "0xe009cfde" },
// { name: "swapOwner", id: "0xe318b52b" },
// { name: "getThreshold", id: "0xe75235b8" },
// { name: "domainSeparator", id: "0xf698da25" },
// { name: "removeOwner", id: "0xf8dc5dd9" },
// { name: "VERSION", id: "0xffa1ad74" },
// { name: "setup", id: "0xa97ab18a" },
// { name: "execTransaction", id: "0x6a761202" },
// { name: "requiredTxGas", id: "0xc4ca3a9c" },
// { name: "approveHash", id: "0xd4d9bdcd" },
// { name: "signMessage", id: "0x85a5affe" },
// { name: "isValidSignature", id: "0x20c13b0b" },
// { name: "getMessageHash", id: "0x0a1028c4" },
// { name: "encodeTransactionData", id: "0xe86637db" },
// { name: "getTransactionHash", id: "0xd8d11f78" }
// ]
export const SAFE_METHODS_NAMES = {
ADD_OWNER_WITH_THRESHOLD: 'addOwnerWithThreshold',
CHANGE_THRESHOLD: 'changeThreshold',
REMOVE_OWNER: 'removeOwner',
SWAP_OWNER: 'swapOwner',
ENABLE_MODULE: 'enableModule',
DISABLE_MODULE: 'disableModule',
}
const METHOD_TO_ID = {
'0xe318b52b': SAFE_METHODS_NAMES.SWAP_OWNER,
'0x0d582f13': SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD,
'0xf8dc5dd9': SAFE_METHODS_NAMES.REMOVE_OWNER,
'0x694e80c3': SAFE_METHODS_NAMES.CHANGE_THRESHOLD,
'0x610b5925': SAFE_METHODS_NAMES.ENABLE_MODULE,
'0xe009cfde': SAFE_METHODS_NAMES.DISABLE_MODULE,
}
export type SafeMethods = typeof SAFE_METHODS_NAMES[keyof typeof SAFE_METHODS_NAMES]
type TokenMethods = 'transfer' | 'transferFrom' | 'safeTransferFrom'
type DecodedValues = Array<{
name: string
type?: string
value: string
}>
type SafeDecodedParams = {
[key in SafeMethods]?: DecodedValues
}
type TokenDecodedParams = {
[key in TokenMethods]?: DecodedValues
}
export type DecodedMethods = SafeDecodedParams | TokenDecodedParams | null
export interface DataDecoded {
method: SafeMethods | TokenMethods
parameters: DecodedValues
}
import { DataDecoded, METHOD_TO_ID } from 'src/routes/safe/store/models/types/transactions.d'
export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null => {
const [methodId, params] = [data.slice(0, 10) as keyof typeof METHOD_TO_ID | string, data.slice(10)]
@ -86,12 +7,12 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
switch (methodId) {
// swapOwner
case '0xe318b52b': {
const decodedParameters = web3.eth.abi.decodeParameters(['uint', 'address', 'address'], params)
const decodedParameters = web3.eth.abi.decodeParameters(['uint', 'address', 'address'], params) as string[]
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'oldOwner', value: decodedParameters[1] },
{ name: 'newOwner', value: decodedParameters[2] },
{ name: 'oldOwner', type: 'address', value: decodedParameters[1] },
{ name: 'newOwner', type: 'address', value: decodedParameters[2] },
],
}
}
@ -102,8 +23,8 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'owner', value: decodedParameters[0] },
{ name: '_threshold', value: decodedParameters[1] },
{ name: 'owner', type: 'address', value: decodedParameters[0] },
{ name: '_threshold', type: 'uint', value: decodedParameters[1] },
],
}
}
@ -114,8 +35,8 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'oldOwner', value: decodedParameters[1] },
{ name: '_threshold', value: decodedParameters[2] },
{ name: 'oldOwner', type: 'address', value: decodedParameters[1] },
{ name: '_threshold', type: 'uint', value: decodedParameters[2] },
],
}
}
@ -126,7 +47,7 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: '_threshold', value: decodedParameters[0] },
{ name: '_threshold', type: 'uint', value: decodedParameters[0] },
],
}
}
@ -137,7 +58,7 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'module', type: '', value: decodedParameters[0] },
{ name: 'module', type: 'address', value: decodedParameters[0] },
],
}
}
@ -148,8 +69,8 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'prevModule', type: '', value: decodedParameters[0] },
{ name: 'module', type: '', value: decodedParameters[1] },
{ name: 'prevModule', type: 'address', value: decodedParameters[0] },
{ name: 'module', type: 'address', value: decodedParameters[1] },
],
}
}
@ -177,8 +98,8 @@ export const decodeMethods = (data: string): DataDecoded | null => {
return {
method: 'transfer',
parameters: [
{ name: 'to', value: decodeParameters[0] },
{ name: 'value', value: decodeParameters[1] },
{ name: 'to', type: '', value: decodeParameters[0] },
{ name: 'value', type: '', value: decodeParameters[1] },
],
}
}
@ -189,9 +110,9 @@ export const decodeMethods = (data: string): DataDecoded | null => {
return {
method: 'transferFrom',
parameters: [
{ name: 'from', value: decodeParameters[0] },
{ name: 'to', value: decodeParameters[1] },
{ name: 'value', value: decodeParameters[2] },
{ name: 'from', type: '', value: decodeParameters[0] },
{ name: 'to', type: '', value: decodeParameters[1] },
{ name: 'value', type: '', value: decodeParameters[2] },
],
}
}
@ -202,9 +123,9 @@ export const decodeMethods = (data: string): DataDecoded | null => {
return {
method: 'safeTransferFrom',
parameters: [
{ name: 'from', value: decodedParameters[0] },
{ name: 'to', value: decodedParameters[1] },
{ name: 'value', value: decodedParameters[2] },
{ name: 'from', type: '', value: decodedParameters[0] },
{ name: 'to', type: '', value: decodedParameters[1] },
{ name: 'value', type: '', value: decodedParameters[2] },
],
}
}

View File

@ -1,9 +1,9 @@
import axios, { AxiosResponse } from 'axios'
import { getTxServiceHost } from 'src/config'
import { TokenProps } from '../../tokens/store/model/token'
import { TokenProps } from 'src/logic/tokens/store/model/token'
type BalanceEndpoint = {
export type BalanceEndpoint = {
balance: string
balanceUsd: string
tokenAddress?: string

View File

@ -44,17 +44,19 @@ export type BalanceCurrencyRecord = {
balanceInSelectedCurrency: string
}
export type CurrencyRateValue = {
currencyRate?: number
selectedCurrency?: AVAILABLE_CURRENCIES
currencyBalances?: List<BalanceCurrencyRecord>
}
export type CurrencyRateValueRecord = RecordOf<CurrencyRateValue>
export const makeBalanceCurrency = Record({
export const makeBalanceCurrency = Record<BalanceCurrencyRecord>({
currencyName: '',
tokenAddress: '',
balanceInBaseCurrency: '',
balanceInSelectedCurrency: '',
})
export type CurrencyRateValueRecord = RecordOf<BalanceCurrencyRecord>
export type BalanceCurrencyList = List<CurrencyRateValueRecord>
export interface CurrencyRateValue {
currencyRate?: number
selectedCurrency?: AVAILABLE_CURRENCIES
currencyBalances?: BalanceCurrencyList
}

View File

@ -4,22 +4,30 @@ import { handleActions } from 'redux-actions'
import { SET_CURRENCY_BALANCES } from 'src/logic/currencyValues/store/actions/setCurrencyBalances'
import { SET_CURRENCY_RATE } from 'src/logic/currencyValues/store/actions/setCurrencyRate'
import { SET_CURRENT_CURRENCY } from 'src/logic/currencyValues/store/actions/setSelectedCurrency'
import { CurrencyRateValue } from 'src/logic/currencyValues/store/model/currencyValues'
export const CURRENCY_VALUES_KEY = 'currencyValues'
export interface CurrencyReducerMap extends Map<string, any> {
get<K extends keyof CurrencyRateValue>(key: K, notSetValue?: unknown): CurrencyRateValue[K]
setIn<K extends keyof CurrencyRateValue>(keys: [string, K], value: CurrencyRateValue[K]): this
}
export type CurrencyValuesState = Map<string, CurrencyReducerMap>
export default handleActions(
{
[SET_CURRENCY_RATE]: (state, action) => {
[SET_CURRENCY_RATE]: (state: CurrencyReducerMap, action) => {
const { currencyRate, safeAddress } = action.payload
return state.setIn([safeAddress, 'currencyRate'], currencyRate)
},
[SET_CURRENCY_BALANCES]: (state, action) => {
[SET_CURRENCY_BALANCES]: (state: CurrencyReducerMap, action) => {
const { currencyBalances, safeAddress } = action.payload
return state.setIn([safeAddress, 'currencyBalances'], currencyBalances)
},
[SET_CURRENT_CURRENCY]: (state, action) => {
[SET_CURRENT_CURRENCY]: (state: CurrencyReducerMap, action) => {
const { safeAddress, selectedCurrency } = action.payload
return state.setIn([safeAddress, 'selectedCurrency'], selectedCurrency)

View File

@ -1,46 +1,41 @@
import { List, Map, RecordOf } from 'immutable'
import { createSelector } from 'reselect'
import { CURRENCY_VALUES_KEY } from 'src/logic/currencyValues/store/reducer/currencyValues'
import { safeParamAddressFromStateSelector } from 'src/routes/safe/store/selectors'
import { AppReduxState } from 'src/store/index'
import {
AVAILABLE_CURRENCIES,
BalanceCurrencyRecord,
CurrencyRateValue,
CurrencyRateValueRecord,
} from 'src/logic/currencyValues/store/model/currencyValues'
CURRENCY_VALUES_KEY,
CurrencyReducerMap,
CurrencyValuesState,
} from 'src/logic/currencyValues/store/reducer/currencyValues'
import { safeParamAddressFromStateSelector } from 'src/routes/safe/store/selectors'
import { AppReduxState } from 'src/store'
import { CurrencyRateValue } from 'src/logic/currencyValues/store/model/currencyValues'
import { BigNumber } from 'bignumber.js'
export const currencyValuesSelector = (state: AppReduxState): Map<string, RecordOf<CurrencyRateValue>> =>
state[CURRENCY_VALUES_KEY]
export const currencyValuesSelector = (state: AppReduxState): CurrencyValuesState => state[CURRENCY_VALUES_KEY]
export const safeFiatBalancesSelector = createSelector(
currencyValuesSelector,
safeParamAddressFromStateSelector,
(currencyValues, safeAddress): CurrencyRateValueRecord => {
(currencyValues, safeAddress): CurrencyReducerMap | undefined => {
if (!currencyValues) return
return currencyValues.get(safeAddress)
},
)
const currencyValueSelector = <K extends keyof CurrencyRateValue>(key: K) => (
currencyValuesMap?: CurrencyReducerMap,
): CurrencyRateValue[K] => currencyValuesMap?.get(key)
export const safeFiatBalancesListSelector = createSelector(
safeFiatBalancesSelector,
(currencyValuesMap): List<BalanceCurrencyRecord> => {
if (!currencyValuesMap) return
return currencyValuesMap.get('currencyBalances') ? currencyValuesMap.get('currencyBalances') : List([])
},
currencyValueSelector('currencyBalances'),
)
export const currentCurrencySelector = createSelector(
safeFiatBalancesSelector,
(currencyValuesMap): AVAILABLE_CURRENCIES | null =>
currencyValuesMap ? currencyValuesMap.get('selectedCurrency') : null,
currencyValueSelector('selectedCurrency'),
)
export const currencyRateSelector = createSelector(safeFiatBalancesSelector, (currencyValuesMap): number | null =>
currencyValuesMap ? currencyValuesMap.get('currencyRate') : null,
)
export const currencyRateSelector = createSelector(safeFiatBalancesSelector, currencyValueSelector('currencyRate'))
export const safeFiatBalancesTotalSelector = createSelector(
safeFiatBalancesListSelector,

View File

@ -1,23 +1,71 @@
import { BigNumber } from 'bignumber.js'
import { backOff } from 'exponential-backoff'
import { List, Map } from 'immutable'
import { batch } from 'react-redux'
import fetchTokenCurrenciesBalances from 'src/logic/currencyValues/api/fetchTokenCurrenciesBalances'
import { setCurrencyBalances } from 'src/logic/currencyValues/store/actions/setCurrencyBalances'
import { AVAILABLE_CURRENCIES, makeBalanceCurrency } from 'src/logic/currencyValues/store/model/currencyValues'
import { CURRENCY_VALUES_KEY } from 'src/logic/currencyValues/store/reducer/currencyValues'
import addTokens from 'src/logic/tokens/store/actions/saveTokens'
import { makeToken } from 'src/logic/tokens/store/model/token'
import { TOKEN_REDUCER_ID } from 'src/logic/tokens/store/reducer/tokens'
import updateSafe from 'src/routes/safe/store/actions/updateSafe'
import { SAFE_REDUCER_ID } from 'src/routes/safe/store/reducer/safe'
import { Dispatch } from 'redux'
import { backOff } from 'exponential-backoff'
import { AppReduxState } from 'src/store'
const humanReadableBalance = (balance, decimals) => new BigNumber(balance).times(`1e-${decimals}`).toFixed()
const noFunc = () => {}
const updateSafeValue = (address) => (valueToUpdate) => updateSafe({ address, ...valueToUpdate })
import fetchTokenCurrenciesBalances, {
BalanceEndpoint,
} from 'src/logic/currencyValues/api/fetchTokenCurrenciesBalances'
import { setCurrencyBalances } from 'src/logic/currencyValues/store/actions/setCurrencyBalances'
import {
AVAILABLE_CURRENCIES,
CurrencyRateValueRecord,
makeBalanceCurrency,
} from 'src/logic/currencyValues/store/model/currencyValues'
import addTokens from 'src/logic/tokens/store/actions/saveTokens'
import { makeToken, Token } from 'src/logic/tokens/store/model/token'
import { TokenState } from 'src/logic/tokens/store/reducer/tokens'
import updateSafe from 'src/routes/safe/store/actions/updateSafe'
import { AppReduxState } from 'src/store'
import { humanReadableValue } from 'src/logic/tokens/utils/humanReadableValue'
import { SafeRecordProps } from 'src/routes/safe/store/models/safe'
import {
safeActiveTokensSelector,
safeBalancesSelector,
safeBlacklistedTokensSelector,
safeEthBalanceSelector,
safeSelector,
} from 'src/routes/safe/store/selectors'
import { tokensSelector } from 'src/logic/tokens/store/selectors'
import { currencyValuesSelector } from 'src/logic/currencyValues/store/selectors'
const noFunc = (): void => {}
const updateSafeValue = (address: string) => (valueToUpdate: Partial<SafeRecordProps>) =>
updateSafe({ address, ...valueToUpdate })
interface ExtractedData {
balances: Map<string, string>
currencyList: List<CurrencyRateValueRecord>
ethBalance: string
tokens: List<Token>
}
const extractDataFromResult = (currentTokens: TokenState) => (
acc: ExtractedData,
{ balance, balanceUsd, token, tokenAddress }: BalanceEndpoint,
): ExtractedData => {
if (tokenAddress === null) {
acc.ethBalance = humanReadableValue(balance, 18)
} else {
acc.balances = acc.balances.merge({ [tokenAddress]: humanReadableValue(balance, Number(token.decimals)) })
if (currentTokens && !currentTokens.get(tokenAddress)) {
acc.tokens = acc.tokens.push(makeToken({ address: tokenAddress, ...token }))
}
}
acc.currencyList = acc.currencyList.push(
makeBalanceCurrency({
currencyName: balanceUsd ? AVAILABLE_CURRENCIES.USD : null,
tokenAddress,
balanceInBaseCurrency: balanceUsd,
balanceInSelectedCurrency: balanceUsd,
}),
)
return acc
}
const fetchSafeTokens = (safeAddress: string) => async (
dispatch: Dispatch,
@ -25,43 +73,22 @@ const fetchSafeTokens = (safeAddress: string) => async (
): Promise<void> => {
try {
const state = getState()
const safe = state[SAFE_REDUCER_ID].getIn(['safes', safeAddress])
const currentTokens = state[TOKEN_REDUCER_ID]
const safe = safeSelector(state)
const currentTokens = tokensSelector(state)
if (!safe) {
return
}
const result = await backOff(() => fetchTokenCurrenciesBalances(safeAddress))
const currentEthBalance = safe.get('ethBalance')
const safeBalances = safe.get('balances')
const alreadyActiveTokens = safe.get('activeTokens')
const blacklistedTokens = safe.get('blacklistedTokens')
const currencyValues = state[CURRENCY_VALUES_KEY]
const currentEthBalance = safeEthBalanceSelector(state)
const safeBalances = safeBalancesSelector(state)
const alreadyActiveTokens = safeActiveTokensSelector(state)
const blacklistedTokens = safeBlacklistedTokensSelector(state)
const currencyValues = currencyValuesSelector(state)
const { balances, currencyList, ethBalance, tokens } = result.data.reduce(
(acc, { balance, balanceUsd, token, tokenAddress }) => {
if (tokenAddress === null) {
acc.ethBalance = humanReadableBalance(balance, 18)
} else {
acc.balances = acc.balances.merge({ [tokenAddress]: humanReadableBalance(balance, token.decimals) })
if (currentTokens && !currentTokens.get(tokenAddress)) {
acc.tokens = acc.tokens.push(makeToken({ address: tokenAddress, ...token }))
}
}
acc.currencyList = acc.currencyList.push(
makeBalanceCurrency({
currencyName: balanceUsd ? AVAILABLE_CURRENCIES.USD : null,
tokenAddress,
balanceInBaseCurrency: balanceUsd,
balanceInSelectedCurrency: balanceUsd,
}),
)
return acc
},
const { balances, currencyList, ethBalance, tokens } = result.data.reduce<ExtractedData>(
extractDataFromResult(currentTokens),
{
balances: Map(),
currencyList: List(),
@ -71,7 +98,7 @@ const fetchSafeTokens = (safeAddress: string) => async (
)
// need to persist those already active tokens, despite its balances
const activeTokens = alreadyActiveTokens.toSet().union(
const activeTokens = alreadyActiveTokens.union(
// active tokens by balance, excluding those already blacklisted and the `null` address
balances.keySeq().toSet().subtract(blacklistedTokens),
)
@ -80,10 +107,7 @@ const fetchSafeTokens = (safeAddress: string) => async (
const updateActiveTokens = activeTokens.equals(alreadyActiveTokens) ? noFunc : update({ activeTokens })
const updateBalances = balances.equals(safeBalances) ? noFunc : update({ balances })
const updateEthBalance = ethBalance === currentEthBalance ? noFunc : update({ ethBalance })
const storedCurrencyBalances =
currencyValues && currencyValues.get(safeAddress)
? currencyValues.get(safeAddress).get('currencyBalances')
: undefined
const storedCurrencyBalances = currencyValues?.get(safeAddress)?.get('currencyBalances')
const updateCurrencies = currencyList.equals(storedCurrencyBalances)
? noFunc

View File

@ -0,0 +1,5 @@
import { BigNumber } from 'bignumber.js'
export const humanReadableValue = (value: number | string, decimals = 18): string => {
return new BigNumber(value).times(`1e-${decimals}`).toFixed()
}

View File

@ -66,7 +66,7 @@ const Load = ({ addSafe, network, provider, userAddress }: LoadProps): React.Rea
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const ownerAddresses = await gnosisSafe.methods.getOwners().call()
const owners = getOwnersFrom(ownerNames, ownerAddresses.sort())
const owners = getOwnersFrom(ownerNames, ownerAddresses.slice().sort())
await loadSafe(safeName, safeAddress, owners, addSafe)

View File

@ -132,7 +132,7 @@ const AddressBookTable = ({ classes }) => {
defaultRowsPerPage={25}
disableLoadingOnEmptyTable
label="Owners"
size={addressBook.size}
size={addressBook?.size || 0}
>
{(sortedData) =>
sortedData.map((row, index) => {

View File

@ -0,0 +1,34 @@
import React from 'react'
import { FixedDialog, Text } from '@gnosis.pm/safe-react-components'
interface OwnProps {
onCancel: () => void
onConfirm: () => void
}
const LegalDisclaimer = ({ onCancel, onConfirm }: OwnProps): JSX.Element => (
<FixedDialog
body={
<>
<Text size="md">
You are now accessing third-party apps, which we do not own, control, maintain or audit. We are not liable for
any loss you may suffer in connection with interacting with the apps, which is at your own risk. You must read
our Terms, which contain more detailed provisions binding on you relating to the apps.
</Text>
<br />
<Text size="md">
I have read and understood the{' '}
<a href="https://gnosis-safe.io/terms" rel="noopener noreferrer" target="_blank">
Terms
</a>{' '}
and this Disclaimer, and agree to be bound by them.
</Text>
</>
}
onCancel={onCancel}
onConfirm={onConfirm}
title="Disclaimer"
/>
)
export default LegalDisclaimer

View File

@ -2,8 +2,8 @@ import { ButtonLink, ManageListModal } from '@gnosis.pm/safe-react-components'
import React, { useState } from 'react'
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
import AddAppForm from './AddAppForm'
import { SafeApp } from './types'
import AddAppForm from '../AddAppForm'
import { SafeApp } from '../types'
const FORM_ID = 'add-apps-form'

View File

@ -1,5 +1,4 @@
import { Icon, ModalFooterConfirmation, Text, Title } from '@gnosis.pm/safe-react-components'
import { BigNumber } from 'bignumber.js'
import React, { ReactElement } from 'react'
import styled from 'styled-components'
@ -13,6 +12,7 @@ import Bold from 'src/components/layout/Bold'
import Heading from 'src/components/layout/Heading'
import Img from 'src/components/layout/Img'
import { getEthAsToken } from 'src/logic/tokens/utils/tokenHelpers'
import { humanReadableValue } from 'src/logic/tokens/utils/humanReadableValue'
export type SafeAppTx = {
to: string
@ -28,8 +28,6 @@ type GenericModalProps = {
onClose: () => void
}
const humanReadableBalance = (balance, decimals) => new BigNumber(balance).times(`1e-${decimals}`).toFixed()
const Wrapper = styled.div`
margin-bottom: 15px;
`
@ -77,7 +75,7 @@ const confirmTransactions = (
ethBalance: string,
nameApp: string,
iconApp: string,
txs: Array<SafeAppTx>,
txs: SafeAppTx[],
openModal: (modalInfo: GenericModalProps) => void,
closeModal: () => void,
onConfirm: () => void,
@ -110,7 +108,7 @@ const confirmTransactions = (
<Heading tag="h3">Value</Heading>
<div className="value-section">
<Img alt="Ether" height={40} src={getEthAsToken('0').logoUri} />
<Bold>{humanReadableBalance(tx.value, 18)} ETH</Bold>
<Bold>{humanReadableValue(tx.value, 18)} ETH</Bold>
</div>
</div>
<div className="section">

View File

@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from 'react'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
import { getAppInfoFromUrl, staticAppsList } from '../utils'
import { SafeApp, StoredSafeApp } from '../types'
const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
type onAppToggleHandler = (appId: string, enabled: boolean) => Promise<void>
type onAppAddedHandler = (app: SafeApp) => void
type UseAppListReturnType = {
appList: SafeApp[]
loadingAppList: boolean
onAppToggle: onAppToggleHandler
onAppAdded: onAppAddedHandler
}
const useAppList = (): UseAppListReturnType => {
const [appList, setAppList] = useState<SafeApp[]>([])
const [loadingAppList, setLoadingAppList] = useState<boolean>(true)
// Load apps list
useEffect(() => {
const loadApps = async () => {
// recover apps from storage:
// * third-party apps added by the user
// * disabled status for both static and third-party apps
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
const list = [...persistedAppList]
staticAppsList.forEach((staticApp) => {
if (!list.some((persistedApp) => persistedApp.url === staticApp.url)) {
list.push(staticApp)
}
})
const apps = []
// using the appURL to recover app info
for (let index = 0; index < list.length; index++) {
try {
const currentApp = list[index]
const appInfo: any = await getAppInfoFromUrl(currentApp.url)
if (appInfo.error) {
throw Error(`There was a problem trying to load app ${currentApp.url}`)
}
appInfo.disabled = currentApp.disabled === undefined ? false : currentApp.disabled
apps.push(appInfo)
} catch (error) {
console.error(error)
}
}
setAppList(apps)
setLoadingAppList(false)
}
loadApps()
}, [])
const onAppToggle: onAppToggleHandler = useCallback(
async (appId, enabled) => {
// update in-memory list
const appListCopy = [...appList]
const app = appListCopy.find((a) => a.id === appId)
if (!app) {
return
}
app.disabled = !enabled
setAppList(appListCopy)
// update storage list
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled }))
saveToStorage(APPS_STORAGE_KEY, listToPersist)
},
[appList],
)
const onAppAdded: onAppAddedHandler = useCallback(
(app) => {
const newAppList = [
{ url: app.url, disabled: false },
...appList.map((a) => ({
url: a.url,
disabled: a.disabled,
})),
]
saveToStorage(APPS_STORAGE_KEY, newAppList)
setAppList([...appList, { ...app, disabled: false }])
},
[appList],
)
return {
appList,
loadingAppList,
onAppToggle,
onAppAdded,
}
}
export { useAppList }

View File

@ -0,0 +1,52 @@
import { useEffect } from 'react'
const useIframeMessageHandler = (): void => {
useEffect(() => {
// const handleIframeMessage = (data) => {
// if (!data || !data.messageId) {
// console.error('ThirdPartyApp: A message was received without message id.')
// return
// }
// switch (data.messageId) {
// case operations.SEND_TRANSACTIONS: {
// const onConfirm = async () => {
// closeModal()
// await sendTransactions(dispatch, safeAddress, data.data, enqueueSnackbar, closeSnackbar, selectedApp.id)
// }
// confirmTransactions(
// safeAddress,
// safeName,
// ethBalance,
// selectedApp.name,
// selectedApp.iconUrl,
// data.data,
// openModal,
// closeModal,
// onConfirm,
// )
// break
// }
// default: {
// console.error(`ThirdPartyApp: A message was received with an unknown message id ${data.messageId}.`)
// break
// }
// }
// }
// const onIframeMessage = async ({ data, origin }) => {
// if (origin === window.origin) {
// return
// }
// if (!selectedApp.url.includes(origin)) {
// console.error(`ThirdPartyApp: A message was received from an unknown origin ${origin}`)
// return
// }
// handleIframeMessage(data)
// }
// window.addEventListener('message', onIframeMessage)
// return () => {
// window.removeEventListener('message', onIframeMessage)
// }
}, [])
}
export { useIframeMessageHandler }

View File

@ -0,0 +1,29 @@
import { useState, useEffect, useCallback } from 'react'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
const APPS_LEGAL_CONSENT_RECEIVED = 'APPS_LEGAL_CONSENT_RECEIVED'
const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () => void } => {
const [consentReceived, setConsentReceived] = useState<boolean>(false)
useEffect(() => {
const checkLegalDisclaimer = async () => {
const storedConsentReceived = await loadFromStorage(APPS_LEGAL_CONSENT_RECEIVED)
if (storedConsentReceived) {
setConsentReceived(true)
}
}
checkLegalDisclaimer()
}, [])
const onConsentReceipt = useCallback((): void => {
setConsentReceived(true)
saveToStorage(APPS_LEGAL_CONSENT_RECEIVED, true)
}, [])
return { consentReceived, onConsentReceipt }
}
export { useLegalConsent }

View File

@ -1,14 +1,16 @@
import { Card, FixedDialog, FixedIcon, IconText, Loader, Menu, Text, Title } from '@gnosis.pm/safe-react-components'
import { Card, FixedIcon, IconText, Loader, Menu, Title } from '@gnosis.pm/safe-react-components'
import { withSnackbar } from 'notistack'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory } from 'react-router-dom'
import styled from 'styled-components'
import ManageApps from './ManageApps'
import ManageApps from './components/ManageApps'
import confirmTransactions from './confirmTransactions'
import sendTransactions from './sendTransactions'
import { getAppInfoFromUrl, staticAppsList } from './utils'
import LegalDisclaimer from './components/LegalDisclaimer'
import { useLegalConsent } from './hooks/useLegalConsent'
import { useAppList } from './hooks/useAppList'
import LCL from 'src/components/ListContentLayout'
import { networkSelector } from 'src/logic/wallets/store/selectors'
@ -19,12 +21,7 @@ import {
safeNameSelector,
safeParamAddressFromStateSelector,
} from 'src/routes/safe/store/selectors'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
import { isSameHref } from 'src/utils/url'
import { SafeApp, StoredSafeApp } from './types'
const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
const APPS_LEGAL_DISCLAIMER_STORAGE_KEY = 'APPS_LEGAL_DISCLAIMER_STORAGE_KEY'
const StyledCard = styled(Card)`
margin-bottom: 24px;
@ -66,11 +63,10 @@ const operations = {
}
function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
const [appList, setAppList] = useState<Array<SafeApp>>([])
const [legalDisclaimerAccepted, setLegalDisclaimerAccepted] = useState(false)
const [selectedApp, setSelectedApp] = useState<string>()
const [loading, setLoading] = useState(true)
const [appIsLoading, setAppIsLoading] = useState(true)
const { appList, loadingAppList, onAppToggle, onAppAdded } = useAppList()
const [appIsLoading, setAppIsLoading] = useState<boolean>(true)
const [selectedAppId, setSelectedAppId] = useState<string>()
const [iframeEl, setIframeEl] = useState<HTMLIFrameElement | null>(null)
const history = useHistory()
@ -80,8 +76,36 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
const network = useSelector(networkSelector)
const ethBalance = useSelector(safeEthBalanceSelector)
const dispatch = useDispatch()
const { consentReceived, onConsentReceipt } = useLegalConsent()
const getSelectedApp = useCallback(() => appList.find((e) => e.id === selectedApp), [appList, selectedApp])
const selectedApp = useMemo(() => appList.find((app) => app.id === selectedAppId), [appList, selectedAppId])
const onSelectApp = useCallback(
(appId) => {
if (selectedAppId === appId) {
return
}
setAppIsLoading(true)
setSelectedAppId(appId)
},
[selectedAppId],
)
useEffect(() => {
const selectFirstEnabledApp = () => {
const firstEnabledApp = appList.find((a) => !a.disabled)
if (firstEnabledApp) {
setSelectedAppId(firstEnabledApp.id)
}
}
const initialSelect = appList.length && !selectedAppId
const currentAppWasDisabled = selectedApp?.disabled
if (initialSelect || currentAppWasDisabled) {
selectFirstEnabledApp()
}
}, [appList, selectedApp, selectedAppId])
const iframeRef = useCallback((node) => {
if (node !== null) {
@ -89,58 +113,15 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
}
}, [])
const onSelectApp = useCallback(
(appId) => {
const selectedApp = getSelectedApp()
if (selectedApp && selectedApp.id === appId) {
return
}
setAppIsLoading(true)
setSelectedApp(appId)
},
[getSelectedApp],
)
const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`)
const onAcceptLegalDisclaimer = () => {
setLegalDisclaimerAccepted(true)
saveToStorage(APPS_LEGAL_DISCLAIMER_STORAGE_KEY, true)
}
const getContent = () => {
if (!selectedApp) {
return null
}
if (!legalDisclaimerAccepted) {
return (
<FixedDialog
body={
<>
<Text size="md">
You are now accessing third-party apps, which we do not own, control, maintain or audit. We are not
liable for any loss you may suffer in connection with interacting with the apps, which is at your own
risk. You must read our Terms, which contain more detailed provisions binding on you relating to the
apps.
</Text>
<br />
<Text size="md">
I have read and understood the{' '}
<a href="https://gnosis-safe.io/terms" rel="noopener noreferrer" target="_blank">
Terms
</a>{' '}
and this Disclaimer, and agree to be bound by them.
</Text>
</>
}
onCancel={redirectToBalance}
onConfirm={onAcceptLegalDisclaimer}
title="Disclaimer"
/>
)
if (!consentReceived) {
return <LegalDisclaimer onCancel={redirectToBalance} onConfirm={onConsentReceipt} />
}
if (network === 'UNKNOWN' || !granted) {
@ -152,8 +133,6 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
)
}
const app = getSelectedApp()
return (
<IframeWrapper>
{appIsLoading && (
@ -161,76 +140,26 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
<Loader size="md" />
</LoadingContainer>
)}
<StyledIframe frameBorder="0" id={`iframe-${app.name}`} ref={iframeRef} src={app.url} title={app.name} />
<StyledIframe
frameBorder="0"
id={`iframe-${selectedApp.name}`}
ref={iframeRef}
src={selectedApp.url}
title={selectedApp.name}
/>
</IframeWrapper>
)
}
const onAppAdded = (app: SafeApp) => {
const newAppList = [
{ url: app.url, disabled: false },
...appList.map((a) => ({
url: a.url,
disabled: a.disabled,
})),
]
saveToStorage(APPS_STORAGE_KEY, newAppList)
setAppList([...appList, { ...app, disabled: false }])
}
const selectFirstApp = useCallback(
(apps) => {
const firstEnabledApp = apps.find((a) => !a.disabled)
if (firstEnabledApp) {
onSelectApp(firstEnabledApp.id)
}
},
[onSelectApp],
)
const onAppToggle = async (appId, enabled) => {
// update in-memory list
const copyAppList = [...appList]
const app = copyAppList.find((a) => a.id === appId)
if (!app) {
return
}
app.disabled = !enabled
setAppList(copyAppList)
// update storage list
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
let storageApp = persistedAppList.find((a) => a.url === app.url)
if (!storageApp) {
storageApp = { url: app.url }
storageApp.disabled = !enabled
persistedAppList.push(storageApp)
} else {
storageApp.disabled = !enabled
}
saveToStorage(APPS_STORAGE_KEY, persistedAppList)
// select app
const selectedApp = getSelectedApp()
if (!selectedApp || (selectedApp && selectedApp.id === appId)) {
setSelectedApp(undefined)
selectFirstApp(copyAppList)
}
}
const getEnabledApps = () => appList.filter((a) => !a.disabled)
const enabledApps = useMemo(() => appList.filter((a) => !a.disabled), [appList])
const sendMessageToIframe = useCallback(
(messageId, data) => {
const app = getSelectedApp()
iframeEl?.contentWindow.postMessage({ messageId, data }, app.url)
if (iframeEl && selectedApp) {
iframeEl.contentWindow.postMessage({ messageId, data }, selectedApp.url)
}
},
[getSelectedApp, iframeEl],
[iframeEl, selectedApp],
)
// handle messages from iframe
@ -245,22 +174,16 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
case operations.SEND_TRANSACTIONS: {
const onConfirm = async () => {
closeModal()
await sendTransactions(
dispatch,
safeAddress,
data.data,
enqueueSnackbar,
closeSnackbar,
getSelectedApp().id,
)
await sendTransactions(dispatch, safeAddress, data.data, enqueueSnackbar, closeSnackbar, selectedApp.id)
}
confirmTransactions(
safeAddress,
safeName,
ethBalance,
getSelectedApp().name,
getSelectedApp().iconUrl,
selectedApp.name,
selectedApp.iconUrl,
data.data,
openModal,
closeModal,
@ -290,8 +213,7 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
return
}
const app = getSelectedApp()
if (!app.url.includes(origin)) {
if (!selectedApp.url.includes(origin)) {
console.error(`ThirdPartyApp: A message was received from an unknown origin ${origin}`)
return
}
@ -306,65 +228,11 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
}
})
// load legalDisclaimer
useEffect(() => {
const checkLegalDisclaimer = async () => {
const legalDisclaimer = await loadFromStorage(APPS_LEGAL_DISCLAIMER_STORAGE_KEY)
if (legalDisclaimer) {
setLegalDisclaimerAccepted(true)
}
}
checkLegalDisclaimer()
}, [])
// Load apps list
useEffect(() => {
const loadApps = async () => {
// recover apps from storage:
// * third-party apps added by the user
// * disabled status for both static and third-party apps
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
const list = [...persistedAppList]
staticAppsList.forEach((staticApp) => {
if (!list.some((persistedApp) => persistedApp.url === staticApp.url)) {
list.push(staticApp)
}
})
const apps = []
// using the appURL to recover app info
for (let index = 0; index < list.length; index++) {
try {
const currentApp = list[index]
const appInfo: SafeApp = await getAppInfoFromUrl(currentApp.url)
if (appInfo.error) {
throw Error(`There was a problem trying to load app ${currentApp.url}`)
}
appInfo.disabled = currentApp.disabled === undefined ? false : currentApp.disabled
apps.push(appInfo)
} catch (error) {
console.error(error)
}
}
setAppList(apps)
setLoading(false)
selectFirstApp(apps)
}
if (!appList.length) {
loadApps()
}
}, [appList.length, selectFirstApp])
// on iframe change
useEffect(() => {
const sendMessageToIframe = (messageId, data) => {
iframeEl.contentWindow.postMessage({ messageId, data }, selectedApp.url)
}
const onIframeLoaded = () => {
setAppIsLoading(false)
sendMessageToIframe(operations.ON_SAFE_INFO, {
@ -374,8 +242,7 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
})
}
const app = getSelectedApp()
if (!iframeEl || !selectedApp || !isSameHref(iframeEl.src, app.url)) {
if (!iframeEl || !selectedApp || !isSameHref(iframeEl.src, selectedApp.url)) {
return
}
@ -384,9 +251,9 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
return () => {
iframeEl.removeEventListener('load', onIframeLoaded)
}
}, [ethBalance, getSelectedApp, iframeEl, network, safeAddress, selectedApp, sendMessageToIframe])
}, [ethBalance, iframeEl, network, safeAddress, selectedApp])
if (loading || !appList.length) {
if (loadingAppList || !appList.length) {
return (
<LoadingContainer>
<Loader size="md" />
@ -399,26 +266,12 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
<Menu>
<ManageApps appList={appList} onAppAdded={onAppAdded} onAppToggle={onAppToggle} />
</Menu>
{getEnabledApps().length ? (
{enabledApps.length ? (
<LCL.Wrapper>
<LCL.Menu>
<LCL.List activeItem={selectedApp} items={getEnabledApps()} onItemClick={onSelectApp} />
<LCL.List activeItem={selectedAppId} items={enabledApps} onItemClick={onSelectApp} />
</LCL.Menu>
<LCL.Content>{getContent()}</LCL.Content>
{/* <LCL.Footer>
{getSelectedApp() && getSelectedApp().providedBy && (
<>
<p>This App is provided by </p>
<ButtonLink
onClick={() => window.open(getSelectedApp().providedBy.url, '_blank')}
size="lg"
testId="manage-tokens-btn"
>
{selectedApp && getSelectedApp().providedBy.name}
</ButtonLink>
</>
)}
</LCL.Footer> */}
</LCL.Wrapper>
) : (
<StyledCard>

View File

@ -25,6 +25,7 @@ import {
BALANCE_TABLE_VALUE_ID,
generateColumns,
getBalanceData,
BalanceData,
} from 'src/routes/safe/components/Balances/dataFetcher'
import { extendedSafeTokensSelector, grantedSelector } from 'src/routes/safe/container/selector'
import { Skeleton } from '@material-ui/lab'
@ -59,7 +60,7 @@ const Coins = (props: Props): React.ReactElement => {
const activeTokens = useSelector(extendedSafeTokensSelector)
const currencyValues = useSelector(safeFiatBalancesListSelector)
const granted = useSelector(grantedSelector)
const [filteredData, setFilteredData] = React.useState<BalanceDataRow>(List())
const [filteredData, setFilteredData] = React.useState<List<BalanceData>>(List())
React.useMemo(() => {
setFilteredData(getBalanceData(activeTokens, selectedCurrency, currencyValues, currencyRate))

View File

@ -5,9 +5,8 @@ import { FIXED } from 'src/components/Table/sorting'
import { formatAmountInUsFormat } from 'src/logic/tokens/utils/formatAmount'
import { ETH_ADDRESS } from 'src/logic/tokens/utils/tokenHelpers'
import { TableColumn } from 'src/components/Table/types'
import { AVAILABLE_CURRENCIES, BalanceCurrencyRecord } from 'src/logic/currencyValues/store/model/currencyValues'
import { AVAILABLE_CURRENCIES, BalanceCurrencyList } from 'src/logic/currencyValues/store/model/currencyValues'
import { Token } from 'src/logic/tokens/store/model/token'
import { BalanceDataRow } from './Coins'
export const BALANCE_TABLE_ASSET_ID = 'asset'
export const BALANCE_TABLE_BALANCE_ID = 'balance'
@ -15,15 +14,15 @@ export const BALANCE_TABLE_VALUE_ID = 'value'
const getTokenPriceInCurrency = (
token: Token,
currencySelected: AVAILABLE_CURRENCIES,
currencyValues: List<BalanceCurrencyRecord>,
currencyRate: number | null,
currencySelected?: AVAILABLE_CURRENCIES,
currencyValues?: BalanceCurrencyList,
currencyRate?: number,
): string => {
if (!currencySelected) {
return ''
}
const currencyValue = currencyValues.find(({ tokenAddress }) => {
const currencyValue = currencyValues?.find(({ tokenAddress }) => {
if (token.address === ETH_ADDRESS && !tokenAddress) {
return true
}
@ -31,7 +30,7 @@ const getTokenPriceInCurrency = (
return token.address === tokenAddress
})
if (!currencyValue) {
if (!currencyValue || !currencyRate) {
return ''
}
@ -41,13 +40,20 @@ const getTokenPriceInCurrency = (
return `${formatAmountInUsFormat(balance)} ${currencySelected}`
}
export interface BalanceData {
asset: { name: string; logoUri: string; address: string; symbol: string }
balance: string
fixed: boolean
value: string
}
export const getBalanceData = (
activeTokens: List<Token>,
currencySelected: AVAILABLE_CURRENCIES,
currencyValues: List<BalanceCurrencyRecord>,
currencyRate: number,
): BalanceDataRow => {
return activeTokens.map((token) => ({
currencySelected?: AVAILABLE_CURRENCIES,
currencyValues?: BalanceCurrencyList,
currencyRate?: number,
): List<BalanceData> =>
activeTokens.map((token) => ({
[BALANCE_TABLE_ASSET_ID]: {
name: token.name,
logoUri: token.logoUri,
@ -60,7 +66,6 @@ export const getBalanceData = (
[FIXED]: token.symbol === 'ETH',
[BALANCE_TABLE_VALUE_ID]: getTokenPriceInCurrency(token, currencySelected, currencyValues, currencyRate),
}))
}
export const generateColumns = (): List<TableColumn> => {
const assetColumn: TableColumn = {

View File

@ -4,11 +4,22 @@ import EtherScanLink from 'src/components/EtherscanLink'
import Identicon from 'src/components/Identicon'
import Block from 'src/components/layout/Block'
import Paragraph from 'src/components/layout/Paragraph'
import { useWindowDimensions } from '../../../../container/hooks/useWindowDimensions'
import { useWindowDimensions } from 'src/routes/safe/container/hooks/useWindowDimensions'
import { useEffect, useState } from 'react'
const OwnerAddressTableCell = (props) => {
const { address, knownAddress, showLinks, userName } = props
interface OwnerAddressTableCellProps {
address: string
knownAddress?: boolean
showLinks: boolean
userName?: string
}
const OwnerAddressTableCell = ({
address,
knownAddress,
showLinks,
userName,
}: OwnerAddressTableCellProps): React.ReactElement => {
const [cut, setCut] = useState(undefined)
const { width } = useWindowDimensions()

View File

@ -0,0 +1,180 @@
import { IconText, Text } from '@gnosis.pm/safe-react-components'
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import styled from 'styled-components'
import { styles } from './styles'
import Value from './Value'
import Block from 'src/components/layout/Block'
import {
extractMultiSendDecodedData,
MultiSendDetails,
} from 'src/routes/safe/store/actions/transactions/utils/multiSendDecodedDetails'
import Bold from 'src/components/layout/Bold'
import OwnerAddressTableCell from 'src/routes/safe/components/Settings/ManageOwners/OwnerAddressTableCell'
import EtherscanLink from 'src/components/EtherscanLink'
import { humanReadableValue } from 'src/logic/tokens/utils/humanReadableValue'
import Collapse from 'src/components/Collapse'
import { useSelector } from 'react-redux'
import { getNameFromAddressBook } from 'src/logic/addressBook/store/selectors'
import Paragraph from 'src/components/layout/Paragraph'
import LinkWithRef from 'src/components/layout/Link'
import { shortVersionOf } from 'src/logic/wallets/ethAddresses'
import { Transaction } from 'src/routes/safe/store/models/types/transaction'
export const TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID = 'tx-description-custom-value'
export const TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID = 'tx-description-custom-data'
const useStyles = makeStyles(styles)
const TxDetailsMethodName = styled(Text)`
text-indent: 4px;
`
const TxDetailsMethodParam = styled.div`
text-indent: 8px;
`
const InlineText = styled(Text)`
display: inline-flex;
`
const TxDetailsContent = styled.div`
padding: 8px 8px 8px 16px;
`
const TxInfo = styled.div`
padding: 8px 8px 8px 16px;
`
const MultiSendCustomData = ({ tx, order }: { tx: MultiSendDetails; order: number }): React.ReactElement => {
const classes = useStyles()
const methodName = tx.data?.method ? ` (${tx.data.method})` : ''
return (
<>
<Collapse
collapseClassName={classes.collapse}
headerWrapperClassName={classes.collapseHeaderWrapper}
title={<IconText iconSize="sm" iconType="code" text={`Action ${order + 1}${methodName}`} textSize="lg" />}
>
<TxDetailsContent>
<TxInfo>
<Bold>Send {humanReadableValue(tx.value)} ETH to:</Bold>
<OwnerAddressTableCell address={tx.to} showLinks />
</TxInfo>
{tx.data && (
<TxInfo>
<TxDetailsMethodName size="lg">
<strong>{tx.data.method}</strong>
</TxDetailsMethodName>
{tx.data?.parameters.map((param, index) => (
<TxDetailsMethodParam key={`${tx.operation}_${tx.to}_${tx.data.method}_param-${index}`}>
<InlineText size="lg">
<strong>
{param.name}({param.type}):
</strong>
</InlineText>
<Value method={methodName} type={param.type} value={param.value} />
</TxDetailsMethodParam>
))}
</TxInfo>
)}
</TxDetailsContent>
</Collapse>
</>
)
}
const TxData = ({ data }: { data: string }): React.ReactElement => {
const classes = useStyles()
const [showTxData, setShowTxData] = React.useState(false)
const showExpandBtn = data.length > 20
return (
<Paragraph className={classes.txDataParagraph} noMargin size="md">
{showExpandBtn ? (
<>
{showTxData ? (
<>
{data}{' '}
<LinkWithRef
aria-label="Hide details of the transaction"
className={classes.linkTxData}
onClick={() => setShowTxData(false)}
rel="noopener noreferrer"
target="_blank"
>
Show Less
</LinkWithRef>
</>
) : (
<>
{shortVersionOf(data, 20)}{' '}
<LinkWithRef
aria-label="Show details of the transaction"
className={classes.linkTxData}
onClick={() => setShowTxData(true)}
rel="noopener noreferrer"
target="_blank"
>
Show More
</LinkWithRef>
</>
)}
</>
) : (
data
)}
</Paragraph>
)
}
interface GenericCustomDataProps {
amount?: string
data: string
recipient: string
}
const GenericCustomData = ({ amount = '0', data, recipient }: GenericCustomDataProps): React.ReactElement => {
const classes = useStyles()
const recipientName = useSelector((state) => getNameFromAddressBook(state, recipient))
return (
<Block>
<Block data-testid={TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID}>
<Bold>Send {amount} to:</Bold>
{recipientName ? (
<OwnerAddressTableCell address={recipient} knownAddress showLinks userName={recipientName} />
) : (
<EtherscanLink knownAddress={false} type="address" value={recipient} />
)}
</Block>
<Block className={classes.txData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}>
<Bold>Data (hex encoded):</Bold>
<TxData data={data} />
</Block>
</Block>
)
}
interface CustomDescriptionProps {
amount?: string
data: string
recipient: string
storedTx: Transaction
}
const CustomDescription = ({ amount, data, recipient, storedTx }: CustomDescriptionProps): React.ReactElement => {
const classes = useStyles()
return storedTx.multiSendTx ? (
<Block className={classes.multiSendTxData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}>
{extractMultiSendDecodedData(storedTx).txDetails?.map((tx, index) => (
<MultiSendCustomData key={`${tx.to}-row-${index}`} tx={tx} order={index} />
))}
</Block>
) : (
<GenericCustomData amount={amount} data={data} recipient={recipient} />
)
}
export default CustomDescription

View File

@ -0,0 +1,153 @@
import { useSelector } from 'react-redux'
import React from 'react'
import { getNameFromAddressBook } from 'src/logic/addressBook/store/selectors'
import Block from 'src/components/layout/Block'
import Bold from 'src/components/layout/Bold'
import OwnerAddressTableCell from 'src/routes/safe/components/Settings/ManageOwners/OwnerAddressTableCell'
import EtherscanLink from 'src/components/EtherscanLink'
import Paragraph from 'src/components/layout/Paragraph'
import { SAFE_METHODS_NAMES, SafeMethods } from 'src/routes/safe/store/models/types/transactions.d'
export const TRANSACTIONS_DESC_ADD_OWNER_TEST_ID = 'tx-description-add-owner'
export const TRANSACTIONS_DESC_REMOVE_OWNER_TEST_ID = 'tx-description-remove-owner'
export const TRANSACTIONS_DESC_CHANGE_THRESHOLD_TEST_ID = 'tx-description-change-threshold'
export const TRANSACTIONS_DESC_ADD_MODULE_TEST_ID = 'tx-description-add-module'
export const TRANSACTIONS_DESC_REMOVE_MODULE_TEST_ID = 'tx-description-remove-module'
export const TRANSACTIONS_DESC_NO_DATA = 'tx-description-no-data'
interface RemovedOwnerProps {
removedOwner: string
}
const RemovedOwner = ({ removedOwner }: RemovedOwnerProps): React.ReactElement => {
const ownerChangedName = useSelector((state) => getNameFromAddressBook(state, removedOwner))
return (
<Block data-testid={TRANSACTIONS_DESC_REMOVE_OWNER_TEST_ID}>
<Bold>Remove owner:</Bold>
{ownerChangedName ? (
<OwnerAddressTableCell address={removedOwner} knownAddress showLinks userName={ownerChangedName} />
) : (
<EtherscanLink knownAddress={false} type="address" value={removedOwner} />
)}
</Block>
)
}
interface AddedOwnerProps {
addedOwner: string
}
const AddedOwner = ({ addedOwner }: AddedOwnerProps): React.ReactElement => {
const ownerChangedName = useSelector((state) => getNameFromAddressBook(state, addedOwner))
return (
<Block data-testid={TRANSACTIONS_DESC_ADD_OWNER_TEST_ID}>
<Bold>Add owner:</Bold>
{ownerChangedName ? (
<OwnerAddressTableCell address={addedOwner} knownAddress showLinks userName={ownerChangedName} />
) : (
<EtherscanLink knownAddress={false} type="address" value={addedOwner} />
)}
</Block>
)
}
interface NewThresholdProps {
newThreshold: string
}
const NewThreshold = ({ newThreshold }: NewThresholdProps): React.ReactElement => (
<Block data-testid={TRANSACTIONS_DESC_CHANGE_THRESHOLD_TEST_ID}>
<Bold>Change required confirmations:</Bold>
<Paragraph noMargin size="md">
{newThreshold}
</Paragraph>
</Block>
)
interface AddModuleProps {
module: string
}
const AddModule = ({ module }: AddModuleProps): React.ReactElement => (
<Block data-testid={TRANSACTIONS_DESC_ADD_MODULE_TEST_ID}>
<Bold>Add module:</Bold>
<EtherscanLink value={module} knownAddress={false} type="address" />
</Block>
)
interface RemoveModuleProps {
module: string
}
const RemoveModule = ({ module }: RemoveModuleProps): React.ReactElement => (
<Block data-testid={TRANSACTIONS_DESC_REMOVE_MODULE_TEST_ID}>
<Bold>Remove module:</Bold>
<EtherscanLink value={module} knownAddress={false} type="address" />
</Block>
)
interface SettingsDescriptionProps {
action: SafeMethods
addedOwner?: string
newThreshold?: string
removedOwner?: string
module?: string
}
const SettingsDescription = ({
action,
addedOwner,
newThreshold,
removedOwner,
module,
}: SettingsDescriptionProps): React.ReactElement => {
if (action === SAFE_METHODS_NAMES.REMOVE_OWNER && removedOwner && newThreshold) {
return (
<>
<RemovedOwner removedOwner={removedOwner} />
<NewThreshold newThreshold={newThreshold} />
</>
)
}
if (action === SAFE_METHODS_NAMES.CHANGE_THRESHOLD && newThreshold) {
return <NewThreshold newThreshold={newThreshold} />
}
if (action === SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD && addedOwner && newThreshold) {
return (
<>
<AddedOwner addedOwner={addedOwner} />
<NewThreshold newThreshold={newThreshold} />
</>
)
}
if (action === SAFE_METHODS_NAMES.SWAP_OWNER && removedOwner && addedOwner) {
return (
<>
<RemovedOwner removedOwner={removedOwner} />
<AddedOwner addedOwner={addedOwner} />
</>
)
}
if (action === SAFE_METHODS_NAMES.ENABLE_MODULE && module) {
return <AddModule module={module} />
}
if (action === SAFE_METHODS_NAMES.DISABLE_MODULE && module) {
return <RemoveModule module={module} />
}
return (
<Block data-testid={TRANSACTIONS_DESC_NO_DATA}>
<Bold>No data available for current transaction</Bold>
</Block>
)
}
export default SettingsDescription

View File

@ -0,0 +1,30 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { TRANSACTIONS_DESC_SEND_TEST_ID } from './index'
import { getNameFromAddressBook } from 'src/logic/addressBook/store/selectors'
import Block from 'src/components/layout/Block'
import Bold from 'src/components/layout/Bold'
import OwnerAddressTableCell from 'src/routes/safe/components/Settings/ManageOwners/OwnerAddressTableCell'
import EtherscanLink from 'src/components/EtherscanLink'
interface TransferDescriptionProps {
amount: string
recipient: string
}
const TransferDescription = ({ amount = '', recipient }: TransferDescriptionProps): React.ReactElement => {
const recipientName = useSelector((state) => getNameFromAddressBook(state, recipient))
return (
<Block data-testid={TRANSACTIONS_DESC_SEND_TEST_ID}>
<Bold>Send {amount} to:</Bold>
{recipientName ? (
<OwnerAddressTableCell address={recipient} knownAddress showLinks userName={recipientName} />
) : (
<EtherscanLink knownAddress={false} type="address" value={recipient} />
)}
</Block>
)
}
export default TransferDescription

View File

@ -0,0 +1,83 @@
import { Text } from '@gnosis.pm/safe-react-components'
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import styled from 'styled-components'
import { styles } from './styles'
import {
isAddress,
isArrayParameter,
} from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
import { useWindowDimensions } from 'src/routes/safe/container/hooks/useWindowDimensions'
import SafeEtherscanLink from 'src/components/EtherscanLink'
const useStyles = makeStyles(styles)
const InlineText = styled(Text)`
display: inline-flex;
`
const NestedWrapper = styled.div`
text-indent: 24px;
`
interface RenderValueProps {
method: string
type: string
value: string | string[]
}
const EtherscanLink = ({ method, type, value }: RenderValueProps): React.ReactElement => {
const classes = useStyles()
const [cut, setCut] = React.useState(undefined)
const { width } = useWindowDimensions()
React.useEffect(() => {
if (width <= 900) {
setCut(4)
} else if (width <= 1024) {
setCut(8)
} else {
setCut(12)
}
}, [width])
if (isArrayParameter(type)) {
return (
<NestedWrapper>
{(value as string[]).map((value, index) => (
<SafeEtherscanLink key={`${method}-value-${index}`} cut={cut} value={value} />
))}
</NestedWrapper>
)
}
return <SafeEtherscanLink className={classes.address} cut={cut} value={value as string} />
}
const GenericValue = ({ method, type, value }: RenderValueProps): React.ReactElement => {
if (isArrayParameter(type)) {
return (
<NestedWrapper>
{(value as string[]).map((value, index) => (
<Text key={`${method}-value-${index}`} size="lg">
{value}
</Text>
))}
</NestedWrapper>
)
}
return <InlineText size="lg">{value as string}</InlineText>
}
const Value = ({ type, ...props }: RenderValueProps): React.ReactElement => {
if (isAddress(type)) {
return <EtherscanLink type={type} {...props} />
}
return <GenericValue type={type} {...props} />
}
export default Value

View File

@ -1,267 +1,22 @@
import { withStyles } from '@material-ui/core/styles'
import React, { useState } from 'react'
import { useSelector } from 'react-redux'
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import { styles } from './styles'
import { getTxData } from './utils'
import SettingsDescription from './SettingsDescription'
import CustomDescription from './CustomDescription'
import TransferDescription from './TransferDescription'
import EtherscanLink from 'src/components/EtherscanLink'
import Block from 'src/components/layout/Block'
import Bold from 'src/components/layout/Bold'
import LinkWithRef from 'src/components/layout/Link'
import Paragraph from 'src/components/layout/Paragraph'
import { getNameFromAddressBook } from 'src/logic/addressBook/store/selectors'
import { SAFE_METHODS_NAMES, SafeMethods } from 'src/logic/contracts/methodIds'
import { shortVersionOf } from 'src/logic/wallets/ethAddresses'
import OwnerAddressTableCell from 'src/routes/safe/components/Settings/ManageOwners/OwnerAddressTableCell'
import { getTxAmount } from 'src/routes/safe/components/Transactions/TxsTable/columns'
import Block from 'src/components/layout/Block'
import { Transaction } from 'src/routes/safe/store/models/types/transaction'
import { lg, md } from 'src/theme/variables'
export const TRANSACTIONS_DESC_ADD_OWNER_TEST_ID = 'tx-description-add-owner'
export const TRANSACTIONS_DESC_REMOVE_OWNER_TEST_ID = 'tx-description-remove-owner'
export const TRANSACTIONS_DESC_CHANGE_THRESHOLD_TEST_ID = 'tx-description-change-threshold'
export const TRANSACTIONS_DESC_SEND_TEST_ID = 'tx-description-send'
export const TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID = 'tx-description-custom-value'
export const TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID = 'tx-description-custom-data'
export const TRANSACTIONS_DESC_ADD_MODULE_TEST_ID = 'tx-description-add-module'
export const TRANSACTIONS_DESC_REMOVE_MODULE_TEST_ID = 'tx-description-remove-module'
export const TRANSACTIONS_DESC_NO_DATA = 'tx-description-no-data'
export const styles = () => ({
txDataContainer: {
paddingTop: lg,
paddingLeft: md,
paddingBottom: md,
},
txData: {
wordBreak: 'break-all',
},
txDataParagraph: {
whiteSpace: 'normal',
},
linkTxData: {
textDecoration: 'underline',
cursor: 'pointer',
},
})
const useStyles = makeStyles(styles)
interface TransferDescriptionProps {
amount: string
recipient: string
}
const TransferDescription = ({ amount = '', recipient }: TransferDescriptionProps): React.ReactElement => {
const recipientName = useSelector((state) => getNameFromAddressBook(state, recipient))
return (
<Block data-testid={TRANSACTIONS_DESC_SEND_TEST_ID}>
<Bold>Send {amount} to:</Bold>
{recipientName ? (
<OwnerAddressTableCell address={recipient} knownAddress showLinks userName={recipientName} />
) : (
<EtherscanLink knownAddress={false} type="address" value={recipient} />
)}
</Block>
)
}
interface RemovedOwnerProps {
removedOwner: string
}
const RemovedOwner = ({ removedOwner }: RemovedOwnerProps): React.ReactElement => {
const ownerChangedName = useSelector((state) => getNameFromAddressBook(state, removedOwner))
return (
<Block data-testid={TRANSACTIONS_DESC_REMOVE_OWNER_TEST_ID}>
<Bold>Remove owner:</Bold>
{ownerChangedName ? (
<OwnerAddressTableCell address={removedOwner} knownAddress showLinks userName={ownerChangedName} />
) : (
<EtherscanLink knownAddress={false} type="address" value={removedOwner} />
)}
</Block>
)
}
interface AddedOwnerProps {
addedOwner: string
}
const AddedOwner = ({ addedOwner }: AddedOwnerProps): React.ReactElement => {
const ownerChangedName = useSelector((state) => getNameFromAddressBook(state, addedOwner))
return (
<Block data-testid={TRANSACTIONS_DESC_ADD_OWNER_TEST_ID}>
<Bold>Add owner:</Bold>
{ownerChangedName ? (
<OwnerAddressTableCell address={addedOwner} knownAddress showLinks userName={ownerChangedName} />
) : (
<EtherscanLink knownAddress={false} type="address" value={addedOwner} />
)}
</Block>
)
}
interface NewThresholdProps {
newThreshold: string
}
const NewThreshold = ({ newThreshold }: NewThresholdProps): React.ReactElement => (
<Block data-testid={TRANSACTIONS_DESC_CHANGE_THRESHOLD_TEST_ID}>
<Bold>Change required confirmations:</Bold>
<Paragraph noMargin size="md">
{newThreshold}
</Paragraph>
</Block>
)
interface AddModuleProps {
module: string
}
const AddModule = ({ module }: AddModuleProps): React.ReactElement => (
<Block data-testid={TRANSACTIONS_DESC_ADD_MODULE_TEST_ID}>
<Bold>Add module:</Bold>
<EtherscanLink value={module} knownAddress={false} type="address" />
</Block>
)
interface RemoveModuleProps {
module: string
}
const RemoveModule = ({ module }: RemoveModuleProps): React.ReactElement => (
<Block data-testid={TRANSACTIONS_DESC_REMOVE_MODULE_TEST_ID}>
<Bold>Remove module:</Bold>
<EtherscanLink value={module} knownAddress={false} type="address" />
</Block>
)
interface SettingsDescriptionProps {
action: SafeMethods
addedOwner?: string
newThreshold?: string
removedOwner?: string
module?: string
}
const SettingsDescription = ({
action,
addedOwner,
newThreshold,
removedOwner,
module,
}: SettingsDescriptionProps): React.ReactElement => {
if (action === SAFE_METHODS_NAMES.REMOVE_OWNER && removedOwner && newThreshold) {
return (
<>
<RemovedOwner removedOwner={removedOwner} />
<NewThreshold newThreshold={newThreshold} />
</>
)
}
if (action === SAFE_METHODS_NAMES.CHANGE_THRESHOLD && newThreshold) {
return <NewThreshold newThreshold={newThreshold} />
}
if (action === SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD && addedOwner && newThreshold) {
return (
<>
<AddedOwner addedOwner={addedOwner} />
<NewThreshold newThreshold={newThreshold} />
</>
)
}
if (action === SAFE_METHODS_NAMES.SWAP_OWNER && removedOwner && addedOwner) {
return (
<>
<RemovedOwner removedOwner={removedOwner} />
<AddedOwner addedOwner={addedOwner} />
</>
)
}
if (action === SAFE_METHODS_NAMES.ENABLE_MODULE && module) {
return <AddModule module={module} />
}
if (action === SAFE_METHODS_NAMES.DISABLE_MODULE && module) {
return <RemoveModule module={module} />
}
return (
<Block data-testid={TRANSACTIONS_DESC_NO_DATA}>
<Bold>No data available for current transaction</Bold>
</Block>
)
}
const TxData = (props) => {
const { classes, data } = props
const [showTxData, setShowTxData] = useState(false)
const showExpandBtn = data.length > 20
return (
<Paragraph className={classes.txDataParagraph} noMargin size="md">
{showExpandBtn ? (
<>
{showTxData ? (
<>
{data}{' '}
<LinkWithRef
aria-label="Hide details of the transaction"
className={classes.linkTxData}
onClick={() => setShowTxData(false)}
rel="noopener noreferrer"
target="_blank"
>
Show Less
</LinkWithRef>
</>
) : (
<>
{shortVersionOf(data, 20)}{' '}
<LinkWithRef
aria-label="Show details of the transaction"
className={classes.linkTxData}
onClick={() => setShowTxData(true)}
rel="noopener noreferrer"
target="_blank"
>
Show More
</LinkWithRef>
</>
)}
</>
) : (
data
)}
</Paragraph>
)
}
const CustomDescription = ({ amount = 0, classes, data, recipient }: any) => {
const recipientName = useSelector((state) => getNameFromAddressBook(state, recipient))
return (
<>
<Block data-testid={TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID}>
<Bold>Send {amount} to:</Bold>
{recipientName ? (
<OwnerAddressTableCell address={recipient} knownAddress showLinks userName={recipientName} />
) : (
<EtherscanLink knownAddress={false} type="address" value={recipient} />
)}
</Block>
<Block className={classes.txData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}>
<Bold>Data (hex encoded):</Bold>
<TxData classes={classes} data={data} />
</Block>
</>
)
}
const TxDescription = ({ classes, tx }) => {
const TxDescription = ({ tx }: { tx: Transaction }): React.ReactElement => {
const classes = useStyles()
const {
action,
addedOwner,
@ -288,9 +43,7 @@ const TxDescription = ({ classes, tx }) => {
module={module}
/>
)}
{!upgradeTx && customTx && (
<CustomDescription amount={amount} classes={classes} data={data} recipient={recipient} />
)}
{!upgradeTx && customTx && <CustomDescription amount={amount} data={data} recipient={recipient} storedTx={tx} />}
{upgradeTx && <div>{data}</div>}
{!cancellationTx && !modifySettingsTx && !customTx && !creationTx && !upgradeTx && (
<TransferDescription amount={amount} recipient={recipient} />
@ -299,4 +52,4 @@ const TxDescription = ({ classes, tx }) => {
)
}
export default withStyles(styles as any)(TxDescription)
export default TxDescription

View File

@ -0,0 +1,43 @@
import { createStyles } from '@material-ui/core/styles'
import { lg, md } from 'src/theme/variables'
export const styles = createStyles({
txDataContainer: {
paddingTop: lg,
paddingLeft: md,
paddingBottom: md,
},
txData: {
wordBreak: 'break-all',
},
txDataParagraph: {
whiteSpace: 'normal',
},
linkTxData: {
textDecoration: 'underline',
cursor: 'pointer',
},
multiSendTxData: {
marginTop: `-${lg}`,
marginLeft: `-${md}`,
},
collapse: {
borderBottom: `2px solid rgb(232, 231, 230)`,
},
collapseHeaderWrapper: {
display: 'flex',
flexDirection: 'row',
alignContent: 'center',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 8px 8px 16px',
borderBottom: '2px solid rgb(232, 231, 230)',
'&:hover': {
cursor: 'pointer',
},
},
address: {
display: 'inline-flex',
},
})

View File

@ -1,4 +1,5 @@
import { SAFE_METHODS_NAMES } from 'src/logic/contracts/methodIds'
import { Transaction } from 'src/routes/safe/store/models/types/transaction'
import { SAFE_METHODS_NAMES } from 'src/routes/safe/store/models/types/transactions.d'
const getSafeVersion = (data) => {
const contractAddress = data.substr(340, 40).toLowerCase()
@ -10,8 +11,26 @@ const getSafeVersion = (data) => {
)
}
export const getTxData = (tx) => {
const txData: any = {}
interface TxData {
data?: string
recipient?: string
module?: string
action?: string
addedOwner?: string
removedOwner?: string
newThreshold?: string
tokenId?: string
isTokenTransfer?: boolean
isCollectibleTransfer?: boolean
modifySettingsTx?: boolean
customTx?: boolean
cancellationTx?: boolean
creationTx?: boolean
upgradeTx?: boolean
}
export const getTxData = (tx: Transaction): TxData => {
const txData: TxData = {}
if (tx.decodedParams) {
if (tx.isTokenTransfer) {
@ -62,6 +81,8 @@ export const getTxData = (tx) => {
txData.customTx = true
} else {
txData.recipient = tx.recipient
txData.data = tx.data
txData.customTx = true
}
} else if (tx.customTx) {
txData.recipient = tx.recipient

View File

@ -8,9 +8,11 @@ import ApproveTxModal from './ApproveTxModal'
import OwnersColumn from './OwnersColumn'
import RejectTxModal from './RejectTxModal'
import TxDescription from './TxDescription'
import { IncomingTx } from './IncomingTx'
import { CreationTx } from './CreationTx'
import { OutgoingTx } from './OutgoingTx'
import { styles } from './style'
import { Transaction } from 'src/routes/safe/store/models/types/transaction'
import { getNetwork } from 'src/config'
import Block from 'src/components/layout/Block'
import Bold from 'src/components/layout/Bold'
@ -21,21 +23,17 @@ import Row from 'src/components/layout/Row'
import Span from 'src/components/layout/Span'
import IncomingTxDescription from 'src/routes/safe/components/Transactions/TxsTable/ExpandedTx/IncomingTxDescription'
import { INCOMING_TX_TYPES } from 'src/routes/safe/store/models/incomingTransaction'
import { safeNonceSelector, safeThresholdSelector } from 'src/routes/safe/store/selectors'
import { IncomingTx } from './IncomingTx'
import { CreationTx } from './CreationTx'
import { OutgoingTx } from './OutgoingTx'
import { TransactionTypes } from 'src/routes/safe/store/models/types/transaction'
import { Transaction, TransactionTypes } from 'src/routes/safe/store/models/types/transaction'
const useStyles = makeStyles(styles as any)
type Props = {
interface ExpandedTxProps {
cancelTx: Transaction
tx: Transaction
}
const ExpandedTx = ({ cancelTx, tx }: Props): React.ReactElement => {
const ExpandedTx = ({ cancelTx, tx }: ExpandedTxProps): React.ReactElement => {
const classes = useStyles()
const nonce = useSelector(safeNonceSelector)
const threshold = useSelector(safeThresholdSelector)

View File

@ -8,8 +8,11 @@ import React from 'react'
import TxType from './TxType'
import { buildOrderFieldFrom } from 'src/components/Table/sorting'
import { TableColumn } from 'src/components/Table/types'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { INCOMING_TX_TYPES } from 'src/routes/safe/store/models/incomingTransaction'
import { Transaction } from 'src/routes/safe/store/models/types/transaction'
import { CancellationTransactions } from 'src/routes/safe/store/reducer/cancellationTransactions'
export const TX_TABLE_ID = 'id'
export const TX_TABLE_TYPE_ID = 'type'
@ -20,11 +23,20 @@ export const TX_TABLE_RAW_TX_ID = 'tx'
export const TX_TABLE_RAW_CANCEL_TX_ID = 'cancelTx'
export const TX_TABLE_EXPAND_ICON = 'expand'
export const formatDate = (date) => format(parseISO(date), 'MMM d, yyyy - HH:mm:ss')
export const formatDate = (date: string): string => format(parseISO(date), 'MMM d, yyyy - HH:mm:ss')
const NOT_AVAILABLE = 'n/a'
const getAmountWithSymbol = ({ decimals = 0, symbol = NOT_AVAILABLE, value }, formatted = false) => {
interface AmountData {
decimals?: number | string
symbol?: string
value: number | string
}
const getAmountWithSymbol = (
{ decimals = 0, symbol = NOT_AVAILABLE, value }: AmountData,
formatted = false,
): string => {
const nonFormattedValue = new BigNumber(value).times(`1e-${decimals}`).toFixed()
const finalValue = formatted ? formatAmount(nonFormattedValue).toString() : nonFormattedValue
const txAmount = finalValue === 'NaN' ? NOT_AVAILABLE : finalValue
@ -32,7 +44,7 @@ const getAmountWithSymbol = ({ decimals = 0, symbol = NOT_AVAILABLE, value }, fo
return `${txAmount} ${symbol}`
}
export const getIncomingTxAmount = (tx, formatted = true) => {
export const getIncomingTxAmount = (tx: Transaction, formatted = true): string => {
// simple workaround to avoid displaying unexpected values for incoming NFT transfer
if (INCOMING_TX_TYPES[tx.type] === INCOMING_TX_TYPES.ERC721_TRANSFER) {
return `1 ${tx.symbol}`
@ -41,9 +53,9 @@ export const getIncomingTxAmount = (tx, formatted = true) => {
return getAmountWithSymbol(tx, formatted)
}
export const getTxAmount = (tx, formatted = true) => {
export const getTxAmount = (tx: Transaction, formatted = true): string => {
const { decimals = 18, decodedParams, isTokenTransfer, symbol } = tx
const { value } = isTokenTransfer && !!decodedParams && !!decodedParams.transfer ? decodedParams.transfer : tx
const { value } = isTokenTransfer && !!decodedParams?.transfer ? decodedParams.transfer : tx
if (tx.isCollectibleTransfer) {
return `1 ${tx.symbol}`
@ -56,8 +68,19 @@ export const getTxAmount = (tx, formatted = true) => {
return getAmountWithSymbol({ decimals, symbol, value }, formatted)
}
const getIncomingTxTableData = (tx) => ({
[TX_TABLE_ID]: tx.blockNumber,
interface TableData {
amount: string
cancelTx?: Transaction
date: string
dateOrder?: number
id: string
status: string
tx?: Transaction
type: any
}
const getIncomingTxTableData = (tx: Transaction): TableData => ({
[TX_TABLE_ID]: tx.blockNumber?.toString() ?? '',
[TX_TABLE_TYPE_ID]: <TxType txType="incoming" />,
[TX_TABLE_DATE_ID]: formatDate(tx.executionDate),
[buildOrderFieldFrom(TX_TABLE_DATE_ID)]: getTime(parseISO(tx.executionDate)),
@ -66,11 +89,11 @@ const getIncomingTxTableData = (tx) => ({
[TX_TABLE_RAW_TX_ID]: tx,
})
const getTransactionTableData = (tx, cancelTx) => {
const getTransactionTableData = (tx: Transaction, cancelTx: Transaction): TableData => {
const txDate = tx.submissionDate
return {
[TX_TABLE_ID]: tx.blockNumber,
[TX_TABLE_ID]: tx.blockNumber?.toString() ?? '',
[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,
@ -81,7 +104,10 @@ const getTransactionTableData = (tx, cancelTx) => {
}
}
export const getTxTableData = (transactions, cancelTxs) => {
export const getTxTableData = (
transactions: List<Transaction>,
cancelTxs: CancellationTransactions,
): List<TableData> => {
return transactions.map((tx) => {
if (INCOMING_TX_TYPES[tx.type] !== undefined) {
return getIncomingTxTableData(tx)
@ -91,7 +117,7 @@ export const getTxTableData = (transactions, cancelTxs) => {
})
}
export const generateColumns = () => {
export const generateColumns = (): List<TableColumn> => {
const nonceColumn = {
id: TX_TABLE_ID,
disablePadding: false,

View File

@ -84,7 +84,7 @@ const TxsTable = ({ classes }) => {
onClick={() => handleTxExpand(row.tx.safeTxHash)}
tabIndex={-1}
>
{autoColumns.map((column: any) => (
{autoColumns.map((column) => (
<TableCell
align={column.align}
className={cn(classes.cell, ['cancelled', 'failed'].includes(row.status) && classes.cancelledRow)}

View File

@ -1,5 +1,4 @@
//
import { List } from 'immutable'
import { List, Map } from 'immutable'
import { makeTransaction } from 'src/routes/safe/store/models/transaction'
import { getTxTableData, TX_TABLE_RAW_CANCEL_TX_ID } from 'src/routes/safe/components/Transactions/TxsTable/columns'
@ -10,7 +9,7 @@ describe('TxsTable Columns > getTxTableData', () => {
const mockedCancelTransaction = makeTransaction({ nonce: 1, blockNumber: 123 })
// When
const txTableData = getTxTableData(List([mockedTransaction]), List([mockedCancelTransaction]))
const txTableData = getTxTableData(List([mockedTransaction]), Map( { '1': mockedCancelTransaction }))
const txRow = txTableData.first()
// Then
@ -23,7 +22,7 @@ describe('TxsTable Columns > getTxTableData', () => {
const mockedCancelTransaction = makeTransaction({ nonce: 2, blockNumber: 123 })
// When
const txTableData = getTxTableData(List([mockedTransaction]), List([mockedCancelTransaction]))
const txTableData = getTxTableData(List([mockedTransaction]), Map( { '2': mockedCancelTransaction }))
const txRow = txTableData.first()
// Then

View File

@ -7,11 +7,11 @@ 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 { DataDecoded } from 'src/logic/contracts/methodIds'
import fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions/fetchTransactions'
import { Transaction, TransactionTypes } from 'src/routes/safe/store/models/types/transaction'
import { Token } from 'src/logic/tokens/store/model/token'
import { SafeRecord } from 'src/routes/safe/store/models/safe'
import { DataDecoded } from 'src/routes/safe/store/models/types/transactions.d'
export type ConfirmationServiceModel = {
confirmationType: string

View File

@ -0,0 +1,61 @@
import { TransferDetails } from './transferDetails.d'
import {
DataDecoded,
Operation,
Parameter,
Transfer,
TransferType,
} from 'src/routes/safe/store/models/types/transactions.d'
import { Transaction } from 'src/routes/safe/store/models/types/transaction'
import {
extractERC20TransferDetails,
extractERC721TransferDetails,
extractETHTransferDetails,
extractUnknownTransferDetails,
} from './transferDetails'
import { isMultiSendParameter } from './newTransactionHelpers'
export type MultiSendDetails = {
operation: keyof typeof Operation
to: string
data: DataDecoded | null
value: number
}
export type MultiSendDecodedData = {
txDetails?: MultiSendDetails[]
transfersDetails?: TransferDetails[]
}
export const extractTransferDetails = (transfer: Transfer): TransferDetails => {
switch (TransferType[transfer.type]) {
case TransferType.ERC20_TRANSFER:
return extractERC20TransferDetails(transfer)
case TransferType.ERC721_TRANSFER:
return extractERC721TransferDetails(transfer)
case TransferType.ETHER_TRANSFER:
return extractETHTransferDetails(transfer)
default:
return extractUnknownTransferDetails(transfer)
}
}
export const extractMultiSendDetails = (parameter: Parameter): MultiSendDetails[] | undefined => {
if (isMultiSendParameter(parameter)) {
return parameter.decodedValue.map((decodedValue) => {
return {
operation: decodedValue.operation,
to: decodedValue.to,
value: decodedValue.value,
data: decodedValue?.decodedData ?? null,
}
})
}
}
export const extractMultiSendDecodedData = (tx: Transaction): MultiSendDecodedData => {
const transfersDetails = tx.transfers?.map(extractTransferDetails)
const txDetails = extractMultiSendDetails(tx.dataDecoded?.parameters[0])
return { txDetails, transfersDetails }
}

View File

@ -0,0 +1,25 @@
import {
EthereumTransaction,
ModuleTransaction,
MultiSendMethodParameter,
MultiSigTransaction,
Parameter,
Transaction,
TxType,
} from 'src/routes/safe/store/models/types/transactions.d'
export const isMultiSigTx = (tx: Transaction): tx is MultiSigTransaction => {
return TxType[tx.txType] === TxType.MULTISIG_TRANSACTION
}
export const isModuleTx = (tx: Transaction): tx is ModuleTransaction => {
return TxType[tx.txType] === TxType.MODULE_TRANSACTION
}
export const isEthereumTx = (tx: Transaction): tx is EthereumTransaction => {
return TxType[tx.txType] === TxType.ETHEREUM_TRANSACTION
}
export const isMultiSendParameter = (parameter: Parameter): parameter is MultiSendMethodParameter => {
return !!(parameter as MultiSendMethodParameter)?.decodedValue
}

View File

@ -1,6 +1,6 @@
import { List, Map } from 'immutable'
import { DecodedMethods, decodeMethods } from 'src/logic/contracts/methodIds'
import { decodeMethods } from 'src/logic/contracts/methodIds'
import { TOKEN_REDUCER_ID } from 'src/logic/tokens/store/reducer/tokens'
import {
getERC20DecimalsAndSymbol,
@ -24,7 +24,7 @@ import {
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 { AppReduxState, store } from 'src/store'
import { safeSelector, safeTransactionsSelector } from 'src/routes/safe/store/selectors'
import { addOrUpdateTransactions } from 'src/routes/safe/store/actions/transactions/addOrUpdateTransactions'
import {
@ -35,6 +35,7 @@ import { TypedDataUtils } from 'eth-sig-util'
import { Token } from 'src/logic/tokens/store/model/token'
import { ProviderRecord } from 'src/logic/wallets/store/model/provider'
import { SafeRecord } from 'src/routes/safe/store/models/safe'
import { DecodedParams } from 'src/routes/safe/store/models/types/transactions.d'
export const isEmptyData = (data?: string | null): boolean => {
return !data || data === EMPTY_DATA
@ -130,7 +131,7 @@ export const getRefundParams = async (
return refundParams
}
export const getDecodedParams = (tx: TxServiceModel): DecodedMethods => {
export const getDecodedParams = (tx: TxServiceModel): DecodedParams | null => {
if (tx.dataDecoded) {
return {
[tx.dataDecoded.method]: tx.dataDecoded.parameters.reduce(
@ -276,6 +277,7 @@ export const buildTx = async ({
creationTx: tx.creationTx,
customTx: isCustomTx,
data: tx.data ? tx.data : EMPTY_DATA,
dataDecoded: tx.dataDecoded,
decimals: tokenDecimals,
decodedParams,
executionDate: tx.executionDate,
@ -315,7 +317,7 @@ export type TxToMock = TxArgs & {
value: string
}
export const mockTransaction = (tx: TxToMock, safeAddress: string, state): Promise<Transaction> => {
export const mockTransaction = (tx: TxToMock, safeAddress: string, state: AppReduxState): Promise<Transaction> => {
const submissionDate = new Date().toISOString()
const transactionStructure: TxServiceModel = {

View File

@ -0,0 +1,50 @@
export interface IncomingTransferDetails {
from: string
}
export interface OutgoingTransferDetails {
to: string
}
export interface CommonERC20TransferDetails {
tokenAddress: string
value: string
name: string
txHash: string | null
}
export interface IncomingERC20TransferDetails extends CommonERC20TransferDetails, IncomingTransferDetails {}
export interface OutgoingERC20TransferDetails extends CommonERC20TransferDetails, OutgoingTransferDetails {}
export type ERC20TransferDetails = IncomingERC20TransferDetails | OutgoingERC20TransferDetails
export interface CommonERC721TransferDetails {
tokenAddress: string
tokenId: string | null
txHash: string | null
}
export interface IncomingERC721TransferDetails extends CommonERC721TransferDetails, IncomingTransferDetails {}
export interface OutgoingERC721TransferDetails extends CommonERC721TransferDetails, OutgoingTransferDetails {}
export type ERC721TransferDetails = IncomingERC721TransferDetails | OutgoingERC721TransferDetails
export interface CommonETHTransferDetails {
value: string
txHash: string | null
}
export interface IncomingETHTransferDetails extends CommonETHTransferDetails, IncomingTransferDetails {}
export interface OutgoingETHTransferDetails extends CommonETHTransferDetails, OutgoingTransferDetails {}
export type ETHTransferDetails = IncomingETHTransferDetails | OutgoingETHTransferDetails
export interface UnknownTransferDetails extends IncomingTransferDetails, OutgoingTransferDetails {
value: string
txHash: string
}
export type TransferDetails = ERC20TransferDetails | ERC721TransferDetails | ETHTransferDetails | UnknownTransferDetails

View File

@ -0,0 +1,85 @@
import { Transfer, TxConstants } from 'src/routes/safe/store/models/types/transactions.d'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { store } from 'src/store'
import { safeParamAddressFromStateSelector } from 'src/routes/safe/store/selectors'
import {
ERC20TransferDetails,
ERC721TransferDetails,
ETHTransferDetails,
UnknownTransferDetails,
} from './transferDetails.d'
import { humanReadableValue } from 'src/logic/tokens/utils/humanReadableValue'
const isIncomingTransfer = (transfer: Transfer): boolean => {
// TODO: prevent using `store` here and receive `safeAddress` as a param
const state = store.getState()
const safeAddress = safeParamAddressFromStateSelector(state)
return sameAddress(transfer.to, safeAddress)
}
export const extractERC20TransferDetails = (transfer: Transfer): ERC20TransferDetails => {
const erc20TransferDetails = {
tokenAddress: transfer.tokenInfo?.address || TxConstants.UNKNOWN,
value: humanReadableValue(transfer.value, transfer.tokenInfo?.decimals),
name: transfer.tokenInfo?.name || transfer.tokenInfo?.symbol || TxConstants.UNKNOWN,
txHash: transfer.transactionHash,
}
if (isIncomingTransfer(transfer)) {
return {
...erc20TransferDetails,
from: transfer.from,
}
}
return {
...erc20TransferDetails,
to: transfer.to,
}
}
export const extractERC721TransferDetails = (transfer: Transfer): ERC721TransferDetails => {
const erc721TransferDetails = {
tokenAddress: transfer.tokenAddress,
tokenId: transfer.tokenId,
txHash: transfer.transactionHash,
}
if (isIncomingTransfer(transfer)) {
return {
...erc721TransferDetails,
from: transfer.from,
}
}
return {
...erc721TransferDetails,
to: transfer.to,
}
}
export const extractETHTransferDetails = (transfer: Transfer): ETHTransferDetails => {
const ethTransferDetails = {
value: humanReadableValue(transfer.value),
txHash: transfer.transactionHash,
}
if (isIncomingTransfer(transfer)) {
return {
...ethTransferDetails,
from: transfer.from,
}
}
return {
...ethTransferDetails,
to: transfer.to,
}
}
export const extractUnknownTransferDetails = (transfer: Transfer): UnknownTransferDetails => {
return {
value: transfer?.value || TxConstants.UNKNOWN,
txHash: transfer?.transactionHash || TxConstants.UNKNOWN,
to: transfer?.to || TxConstants.UNKNOWN,
from: transfer?.from || TxConstants.UNKNOWN,
}
}

View File

@ -18,6 +18,7 @@ export const makeTransaction = Record<TransactionProps>({
creationTx: false,
customTx: false,
data: null,
dataDecoded: null,
decimals: 18,
decodedParams: {},
executionDate: '',

View File

@ -1,7 +1,7 @@
import { List, Map, RecordOf } from 'immutable'
import { DecodedMethods } from 'src/logic/contracts/methodIds'
import { Confirmation } from './confirmation'
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
import { DataDecoded, DecodedParams, Transfer } from './transactions'
export enum TransactionTypes {
INCOMING = 'incoming',
@ -43,13 +43,14 @@ export type TransactionProps = {
creationTx: boolean
customTx: boolean
data?: string | null
dataDecoded: DataDecoded | null
decimals?: (number | string) | null
decodedParams: DecodedMethods
decodedParams: DecodedParams | null
executionDate?: string | null
executionTxHash?: string | null
executor: string
factoryAddress: string
fee: string | null // It will be replace with the new TXs types.
fee?: string // It will be replace with the new TXs types.
gasPrice: string
gasToken: string
isCancellationTx: boolean
@ -75,6 +76,7 @@ export type TransactionProps = {
submissionDate?: string | null
symbol?: string | null
transactionHash: string | null
transfers?: Transfer[]
type: TransactionTypes
upgradeTx: boolean
value: string

View File

@ -0,0 +1,254 @@
export enum TxConstants {
MULTI_SEND = 'multiSend',
UNKNOWN = 'UNKNOWN',
}
export enum Operation {
CALL = 'CALL',
DELEGATE_CALL = 'DELEGATE_CALL',
CREATE = 'CREATE',
}
// types comes from: https://github.com/gnosis/safe-client-gateway/blob/752e76b6d1d475791dbd7917b174bb41d2d9d8be/src/utils.rs
export enum TransferMethods {
TRANSFER = 'transfer',
TRANSFER_FROM = 'transferFrom',
SAFE_TRANSFER_FROM = 'safeTransferFrom',
}
export enum SettingsChangeMethods {
SETUP = 'setup',
SET_FALLBACK_HANDLER = 'setFallbackHandler',
ADD_OWNER_WITH_THRESHOLD = 'addOwnerWithThreshold',
REMOVE_OWNER = 'removeOwner',
REMOVE_OWNER_WITH_THRESHOLD = 'removeOwnerWithThreshold',
SWAP_OWNER = 'swapOwner',
CHANGE_THRESHOLD = 'changeThreshold',
CHANGE_MASTER_COPY = 'changeMasterCopy',
ENABLE_MODULE = 'enableModule',
DISABLE_MODULE = 'disableModule',
EXEC_TRANSACTION_FROM_MODULE = 'execTransactionFromModule',
APPROVE_HASH = 'approveHash',
EXEC_TRANSACTION = 'execTransaction',
}
// note: this extends SAFE_METHODS_NAMES in /logic/contracts/methodIds.ts, we need to figure out which one we are going to use
export type DataDecodedMethod = TransferMethods | SettingsChangeMethods | string
export interface DecodedValue {
operation: Operation
to: string
value: number
data: string
decodedData: DataDecoded
}
export interface SingleTransactionMethodParameter {
name: string
type: string
value: string
}
export interface MultiSendMethodParameter extends SingleTransactionMethodParameter {
decodedValue: DecodedValue[]
}
export type Parameter = MultiSendMethodParameter | SingleTransactionMethodParameter
export interface DataDecoded {
method: DataDecodedMethod
parameters: Parameter[]
}
export enum ConfirmationType {
CONFIRMATION = 'CONFIRMATION',
EXECUTION = 'EXECUTION',
}
export enum SignatureType {
CONTRACT_SIGNATURE = 'CONTRACT_SIGNATURE',
APPROVED_HASH = 'APPROVED_HASH',
EOA = 'EOA',
ETH_SIGN = 'ETH_SIGN',
}
export interface Confirmation {
owner: string
submissionDate: string
transactionHash: string | null
confirmationType: ConfirmationType
signature: string
signatureType: SignatureType
}
export enum TokenType {
ERC20 = 'ERC20',
ERC721 = 'ERC721',
OTHER = 'OTHER',
}
export interface TokenInfo {
type: TokenType
address: string
name: string
symbol: string
decimals: number
logoUri: string
}
export enum TransferType {
ETHER_TRANSFER = 'ETHER_TRANSFER',
ERC20_TRANSFER = 'ERC20_TRANSFER',
ERC721_TRANSFER = 'ERC721_TRANSFER',
UNKNOWN = 'UNKNOWN',
}
export interface Transfer {
type: TransferType
executionDate: string
blockNumber: number
transactionHash: string | null
to: string
value: string | null
tokenId: string | null
tokenAddress: string
tokenInfo: TokenInfo | null
from: string
}
export enum TxType {
MULTISIG_TRANSACTION = 'MULTISIG_TRANSACTION',
ETHEREUM_TRANSACTION = 'ETHEREUM_TRANSACTION',
MODULE_TRANSACTION = 'MODULE_TRANSACTION',
}
export interface MultiSigTransaction {
safe: string
to: string
value: string
data: string | null
operation: number
gasToken: string
safeTxGas: number
baseGas: number
gasPrice: string
refundReceiver: string
nonce: number
executionDate: string | null
submissionDate: string
modified: string
blockNumber: number | null
transactionHash: string | null
safeTxHash: string
executor: string | null
isExecuted: boolean
isSuccessful: boolean | null
ethGasPrice: string | null
gasUsed: number | null
fee: string | null
origin: string | null
dataDecoded: DataDecoded | null
confirmationsRequired: number | null
confirmations: Confirmation[]
signatures: string | null
transfers: Transfer[]
txType: TxType.MULTISIG_TRANSACTION
}
export interface ModuleTransaction {
created: string
executionDate: string
blockNumber: number
transactionHash: string
safe: string
module: string
to: string
value: string
data: string
operation: Operation
transfers: Transfer[]
txType: TxType.MODULE_TRANSACTION
}
export interface EthereumTransaction {
executionDate: string
to: string
data: string | null
txHash: string
blockNumber: number
transfers: Transfer[]
txType: TxType.ETHEREUM_TRANSACTION
from: string
}
export type Transaction = MultiSigTransaction | ModuleTransaction | EthereumTransaction
// SAFE METHODS TO ITS ID
// https://github.com/gnosis/safe-contracts/blob/development/test/safeMethodNaming.js
// https://github.com/gnosis/safe-contracts/blob/development/contracts/GnosisSafe.sol
// [
// { name: "addOwnerWithThreshold", id: "0x0d582f13" },
// { name: "DOMAIN_SEPARATOR_TYPEHASH", id: "0x1db61b54" },
// { name: "isOwner", id: "0x2f54bf6e" },
// { name: "execTransactionFromModule", id: "0x468721a7" },
// { name: "signedMessages", id: "0x5ae6bd37" },
// { name: "enableModule", id: "0x610b5925" },
// { name: "changeThreshold", id: "0x694e80c3" },
// { name: "approvedHashes", id: "0x7d832974" },
// { name: "changeMasterCopy", id: "0x7de7edef" },
// { name: "SENTINEL_MODULES", id: "0x85e332cd" },
// { name: "SENTINEL_OWNERS", id: "0x8cff6355" },
// { name: "getOwners", id: "0xa0e67e2b" },
// { name: "NAME", id: "0xa3f4df7e" },
// { name: "nonce", id: "0xaffed0e0" },
// { name: "getModules", id: "0xb2494df3" },
// { name: "SAFE_MSG_TYPEHASH", id: "0xc0856ffc" },
// { name: "SAFE_TX_TYPEHASH", id: "0xccafc387" },
// { name: "disableModule", id: "0xe009cfde" },
// { name: "swapOwner", id: "0xe318b52b" },
// { name: "getThreshold", id: "0xe75235b8" },
// { name: "domainSeparator", id: "0xf698da25" },
// { name: "removeOwner", id: "0xf8dc5dd9" },
// { name: "VERSION", id: "0xffa1ad74" },
// { name: "setup", id: "0xa97ab18a" },
// { name: "execTransaction", id: "0x6a761202" },
// { name: "requiredTxGas", id: "0xc4ca3a9c" },
// { name: "approveHash", id: "0xd4d9bdcd" },
// { name: "signMessage", id: "0x85a5affe" },
// { name: "isValidSignature", id: "0x20c13b0b" },
// { name: "getMessageHash", id: "0x0a1028c4" },
// { name: "encodeTransactionData", id: "0xe86637db" },
// { name: "getTransactionHash", id: "0xd8d11f78" }
// ]
export const SAFE_METHODS_NAMES = {
ADD_OWNER_WITH_THRESHOLD: 'addOwnerWithThreshold',
CHANGE_THRESHOLD: 'changeThreshold',
REMOVE_OWNER: 'removeOwner',
SWAP_OWNER: 'swapOwner',
ENABLE_MODULE: 'enableModule',
DISABLE_MODULE: 'disableModule',
}
export const METHOD_TO_ID = {
'0xe318b52b': SAFE_METHODS_NAMES.SWAP_OWNER,
'0x0d582f13': SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD,
'0xf8dc5dd9': SAFE_METHODS_NAMES.REMOVE_OWNER,
'0x694e80c3': SAFE_METHODS_NAMES.CHANGE_THRESHOLD,
'0x610b5925': SAFE_METHODS_NAMES.ENABLE_MODULE,
'0xe009cfde': SAFE_METHODS_NAMES.DISABLE_MODULE,
}
export type SafeMethods = typeof SAFE_METHODS_NAMES[keyof typeof SAFE_METHODS_NAMES]
type TokenMethods = 'transfer' | 'transferFrom' | 'safeTransferFrom'
type SafeDecodedParams = {
[key in SafeMethods]?: Record<string, string>
}
type TokenDecodedParams = {
[key in TokenMethods]?: Record<string, string>
}
export type DecodedParams = SafeDecodedParams | TokenDecodedParams | null

View File

@ -3,9 +3,13 @@ import { handleActions } from 'redux-actions'
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'
import { Transaction } from 'src/routes/safe/store/models/types/transaction'
export const CANCELLATION_TRANSACTIONS_REDUCER_ID = 'cancellationTransactions'
export type CancellationTransactions = Map<string, Transaction>
export type CancellationTxState = Map<string, CancellationTransactions>
export default handleActions(
{
[ADD_OR_UPDATE_CANCELLATION_TRANSACTIONS]: (state, action) => {

View File

@ -1,4 +1,4 @@
import { SafeRecord } from 'src/routes/safe/store/models/safe'
import { SafeRecord, SafeRecordProps } from 'src/routes/safe/store/models/safe'
import { Map } from 'immutable'
export type SafesMap = Map<string, SafeRecord>

View File

@ -3,7 +3,10 @@ import { matchPath, RouteComponentProps } from 'react-router-dom'
import { createSelector } from 'reselect'
import { SAFELIST_ADDRESS, SAFE_PARAM_ADDRESS } from 'src/routes/routes'
import { CANCELLATION_TRANSACTIONS_REDUCER_ID } from 'src/routes/safe/store/reducer/cancellationTransactions'
import {
CANCELLATION_TRANSACTIONS_REDUCER_ID,
CancellationTransactions,
} from 'src/routes/safe/store/reducer/cancellationTransactions'
import { INCOMING_TRANSACTIONS_REDUCER_ID } from 'src/routes/safe/store/reducer/incomingTransactions'
import { SAFE_REDUCER_ID, SafesMap } from 'src/routes/safe/store/reducer/safe'
import { TRANSACTIONS_REDUCER_ID } from 'src/routes/safe/store/reducer/transactions'
@ -81,7 +84,7 @@ export const addressBookQueryParamsSelector = (state: AppReduxState): string | n
export const safeCancellationTransactionsSelector = createSelector(
cancellationTransactionsSelector,
safeParamAddressFromStateSelector,
(cancellationTransactions, address) => {
(cancellationTransactions, address): CancellationTransactions => {
if (!cancellationTransactions) {
return Map()
}
@ -118,9 +121,7 @@ export const safeSelector = createSelector(
return undefined
}
const checksumed = checksumAddress(address)
const safe = safes.get(checksumed)
return safe
return safes.get(checksumed)
},
)
@ -152,13 +153,16 @@ export const safeActiveAssetsListSelector = createSelector(safeActiveAssetsSelec
return Set(safeList)
})
export const safeBlacklistedTokensSelector = createSelector(safeSelector, (safe) => {
if (!safe) {
return List()
}
export const safeBlacklistedTokensSelector = createSelector(
safeSelector,
(safe): Set<string> => {
if (!safe) {
return Set()
}
return safe.blacklistedTokens
})
return safe.blacklistedTokens
},
)
export const safeBlacklistedAssetsSelector = createSelector(
safeSelector,
@ -171,23 +175,12 @@ export const safeBlacklistedAssetsSelector = createSelector(
},
)
export const safeActiveAssetsSelectorBySafe = (safeAddress: string, safes: SafesMap) =>
export const safeActiveAssetsSelectorBySafe = (safeAddress: string, safes: SafesMap): Set<string> =>
safes.get(safeAddress).get('activeAssets')
export const safeBlacklistedAssetsSelectorBySafe = (safeAddress, safes) =>
export const safeBlacklistedAssetsSelectorBySafe = (safeAddress: string, safes: SafesMap): Set<string> =>
safes.get(safeAddress).get('blacklistedAssets')
export const safeBalancesSelector = createSelector(
safeSelector,
(safe): Map<string, string> => {
if (!safe) {
return Map()
}
return safe.balances
},
)
const baseSafe = makeSafe()
export const safeFieldSelector = <K extends keyof SafeRecordProps>(field: K) => (
@ -198,6 +191,8 @@ export const safeNameSelector = createSelector(safeSelector, safeFieldSelector('
export const safeEthBalanceSelector = createSelector(safeSelector, safeFieldSelector('ethBalance'))
export const safeBalancesSelector = createSelector(safeSelector, safeFieldSelector('balances'))
export const safeNeedsUpdateSelector = createSelector(safeSelector, safeFieldSelector('needsUpdate'))
export const safeCurrentVersionSelector = createSelector(safeSelector, safeFieldSelector('currentVersion'))

View File

@ -2,9 +2,10 @@ import { List } from 'immutable'
import { createSelector } from 'reselect'
import { safeIncomingTransactionsSelector, safeTransactionsSelector } from 'src/routes/safe/store/selectors'
import { Transaction } from 'src/routes/safe/store/models/types/transaction'
export const extendedTransactionsSelector = createSelector(
safeTransactionsSelector,
safeIncomingTransactionsSelector,
(transactions, incomingTransactions) => List([...transactions, ...incomingTransactions]),
(transactions, incomingTransactions): List<Transaction> => List([...transactions, ...incomingTransactions]),
)

View File

@ -1,6 +1,7 @@
import { Map } from 'immutable'
import { connectRouter, routerMiddleware, RouterState } from 'connected-react-router'
import { createHashHistory } from 'history'
import { applyMiddleware, combineReducers, compose, createStore, CombinedState } from 'redux'
import { applyMiddleware, combineReducers, compose, createStore, CombinedState, PreloadedState, Store } from 'redux'
import thunk from 'redux-thunk'
import addressBookMiddleware from 'src/logic/addressBook/store/middleware/addressBookMiddleware'
@ -14,7 +15,10 @@ import {
} from 'src/logic/collectibles/store/reducer/collectibles'
import cookies, { COOKIES_REDUCER_ID } from 'src/logic/cookies/store/reducer/cookies'
import currencyValuesStorageMiddleware from 'src/logic/currencyValues/store/middleware'
import currencyValues, { CURRENCY_VALUES_KEY } from 'src/logic/currencyValues/store/reducer/currencyValues'
import currencyValues, {
CURRENCY_VALUES_KEY,
CurrencyReducerMap,
} from 'src/logic/currencyValues/store/reducer/currencyValues'
import currentSession, { CURRENT_SESSION_REDUCER_ID } from 'src/logic/currentSession/store/reducer/currentSession'
import notifications, { NOTIFICATIONS_REDUCER_ID } from 'src/logic/notifications/store/reducer/notifications'
import tokens, { TOKEN_REDUCER_ID, TokenState } from 'src/logic/tokens/store/reducer/tokens'
@ -24,6 +28,7 @@ import notificationsMiddleware from 'src/routes/safe/store/middleware/notificati
import safeStorage from 'src/routes/safe/store/middleware/safeStorage'
import cancellationTransactions, {
CANCELLATION_TRANSACTIONS_REDUCER_ID,
CancellationTxState,
} from 'src/routes/safe/store/reducer/cancellationTransactions'
import incomingTransactions, {
INCOMING_TRANSACTIONS_REDUCER_ID,
@ -31,8 +36,6 @@ import incomingTransactions, {
import safe, { SAFE_REDUCER_ID, SafeReducerMap } from 'src/routes/safe/store/reducer/safe'
import transactions, { TRANSACTIONS_REDUCER_ID } from 'src/routes/safe/store/reducer/transactions'
import { NFTAssets, NFTTokens } from 'src/logic/collectibles/sources/OpenSea'
import { Map } from 'immutable'
import { CurrencyRateValueRecord } from 'src/logic/currencyValues/store/model/currencyValues'
export const history = createHashHistory()
@ -74,10 +77,10 @@ export type AppReduxState = CombinedState<{
[NFT_TOKENS_REDUCER_ID]: NFTTokens
[TOKEN_REDUCER_ID]: TokenState
[TRANSACTIONS_REDUCER_ID]: Map<string, any>
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: Map<string, any>
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: CancellationTxState
[INCOMING_TRANSACTIONS_REDUCER_ID]: Map<string, any>
[NOTIFICATIONS_REDUCER_ID]: Map<string, any>
[CURRENCY_VALUES_KEY]: Map<string, CurrencyRateValueRecord>
[CURRENCY_VALUES_KEY]: CurrencyReducerMap
[COOKIES_REDUCER_ID]: Map<string, any>
[ADDRESS_BOOK_REDUCER_ID]: AddressBookReducerMap
[CURRENT_SESSION_REDUCER_ID]: Map<string, any>
@ -86,4 +89,5 @@ export type AppReduxState = CombinedState<{
export const store: any = createStore(reducers, finalCreateStore)
export const aNewStore = (localState?: any) => createStore(reducers, localState, finalCreateStore)
export const aNewStore = (localState?: PreloadedState<unknown>): Store =>
createStore(reducers, localState, finalCreateStore)

View File

@ -1,14 +1,15 @@
//
import { fireEvent } from '@testing-library/react'
import { sleep } from 'src/utils/timer'
import { shortVersionOf } from 'src/logic/wallets/ethAddresses'
import { TRANSACTIONS_TAB_BTN_TEST_ID } from 'src/routes/safe/components/Layout/index'
import { TRANSACTIONS_TAB_BTN_TEST_ID } from 'src/routes/safe/components/Layout'
import { TRANSACTION_ROW_TEST_ID } from 'src/routes/safe/components/Transactions/TxsTable'
import {
TRANSACTIONS_DESC_SEND_TEST_ID,
} from 'src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription'
import {
TRANSACTIONS_DESC_ADD_OWNER_TEST_ID,
TRANSACTIONS_DESC_REMOVE_OWNER_TEST_ID,
TRANSACTIONS_DESC_SEND_TEST_ID,
} from 'src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription'
} from 'src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/SettingsDescription'
export const getLastTransaction = async (SafeDom) => {
// Travel to transactions

View File

@ -24,10 +24,7 @@ export const loadFromStorage = async <T = unknown>(key: string): Promise<T | und
}
}
export const saveToStorage = async (
key: string,
value: Record<string, unknown> | boolean | string | number | Array<unknown>,
): Promise<void> => {
export const saveToStorage = async <T = unknown>(key: string, value: T): Promise<void> => {
try {
const stringifiedValue = JSON.stringify(value)
await storage.set(`${PREFIX}__${key}`, stringifiedValue)

867
yarn.lock

File diff suppressed because it is too large Load Diff