(Feature) Spending Limit (#1637)

Co-authored-by: Daniel Sanchez <daniel.sanchez@gnosis.pm>
This commit is contained in:
Fernando 2020-11-25 15:59:17 -03:00 committed by GitHub
parent dca9f19fd0
commit fc1250d528
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 41364 additions and 457 deletions

View File

@ -26,3 +26,7 @@ REACT_APP_APP_VERSION=$npm_package_version
# For Apps
REACT_APP_GNOSIS_APPS_URL=https://safe-apps.staging.gnosisdev.com
# Contracts Addresses
REACT_APP_SPENDING_LIMIT_MODULE_ADDRESS=0x9e9Bf12b5a66c0f0A7435835e0365477E121B110

View File

@ -26,8 +26,9 @@
"electron-build": "electron-builder --mac --windows --linux",
"electron-dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\"",
"format:staged": "lint-staged",
"generate-types": "yarn generate-types:contracts",
"generate-types": "yarn generate-types:contracts && yarn generate-types:spendingLimit",
"generate-types:contracts": "cross-env typechain --target=web3-v1 --outDir './src/types/contracts' './node_modules/@gnosis.pm/safe-contracts/build/contracts/*.json'",
"generate-types:spendingLimit": "cross-env typechain --target=web3-v1 --outDir './src/types/contracts' ./src/logic/contracts/artifacts/*.json",
"lint:check": "eslint './src/**/*.{js,jsx,ts,tsx}'",
"lint:fix": "yarn lint:check --fix",
"postinstall": "patch-package && electron-builder install-app-deps && yarn generate-types",

View File

@ -1,52 +1,70 @@
import Modal from '@material-ui/core/Modal'
import { withStyles } from '@material-ui/core/styles'
import { makeStyles, createStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import * as React from 'react'
import React, { ReactElement, ReactNode } from 'react'
import { sm } from 'src/theme/variables'
const styles = () => ({
root: {
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
overflowY: 'scroll',
},
paper: {
position: 'absolute',
top: '120px',
width: '500px',
height: '540px',
borderRadius: sm,
backgroundColor: '#ffffff',
boxShadow: '0 0 5px 0 rgba(74, 85, 121, 0.5)',
'&:focus': {
outline: 'none',
const useStyles = makeStyles(
createStyles({
root: {
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
overflowY: 'scroll',
},
display: 'flex',
flexDirection: 'column',
},
})
paper: {
position: 'absolute',
top: '120px',
width: '500px',
height: '540px',
borderRadius: sm,
backgroundColor: '#ffffff',
boxShadow: '0 0 5px 0 rgba(74, 85, 121, 0.5)',
'&:focus': {
outline: 'none',
},
display: 'flex',
flexDirection: 'column',
},
}),
)
interface GnoModalProps {
children: ReactNode
description: string
// type copied from Material-UI Modal's `close` prop
handleClose?: {
bivarianceHack(event: Record<string, unknown>, reason: 'backdropClick' | 'escapeKeyDown'): void
}['bivarianceHack']
modalClassName?: string
open: boolean
paperClassName?: string
title: string
}
const GnoModal = ({
children,
classes,
description,
handleClose,
modalClassName,
open,
paperClassName,
title,
}: any) => (
<Modal
aria-describedby={description}
aria-labelledby={title}
className={cn(classes.root, modalClassName)}
onClose={handleClose}
open={open}
>
<div className={cn(classes.paper, paperClassName)}>{children}</div>
</Modal>
)
}: GnoModalProps): ReactElement => {
const classes = useStyles()
export default withStyles(styles as any)(GnoModal)
return (
<Modal
aria-describedby={description}
aria-labelledby={title}
className={cn(classes.root, modalClassName)}
onClose={handleClose}
open={open}
>
<div className={cn(classes.paper, paperClassName)}>{children}</div>
</Modal>
)
}
export default GnoModal

View File

@ -1,6 +1,5 @@
import { makeStyles } from '@material-ui/core/styles'
import { useState } from 'react'
import * as React from 'react'
import React, { ReactElement, useState } from 'react'
import QRIcon from 'src/assets/icons/qrcode.svg'
import { ScanQRModal } from 'src/components/ScanQRModal'
@ -16,7 +15,7 @@ type Props = {
handleScan: (dataResult: string, closeQrModal: () => void) => void
}
export const ScanQRWrapper = ({ handleScan }: Props): React.ReactElement => {
export const ScanQRWrapper = ({ handleScan }: Props): ReactElement => {
const classes = useStyles()
const [qrModalOpen, setQrModalOpen] = useState(false)

File diff suppressed because one or more lines are too long

View File

@ -1,78 +1,173 @@
import {
DataDecoded,
SAFE_METHOD_ID_TO_NAME,
SAFE_METHODS_NAMES,
SPENDING_LIMIT_METHOD_ID_TO_NAME,
SPENDING_LIMIT_METHODS_NAMES,
TOKEN_TRANSFER_METHOD_ID_TO_NAME,
TOKEN_TRANSFER_METHODS_NAMES,
} from 'src/logic/safe/store/models/types/transactions.d'
import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3'
import { DataDecoded, METHOD_TO_ID } from 'src/routes/safe/store/models/types/transactions.d'
import { sameString } from 'src/utils/strings'
type DecodeInfoProps = {
paramsHash: string
params: Record<string, string>
}
const decodeInfo = ({ paramsHash, params }: DecodeInfoProps): DataDecoded['parameters'] => {
const decodedParameters = web3.eth.abi.decodeParameters(Object.values(params), paramsHash)
return Object.keys(params).map((name, index) => ({
name,
type: params[name],
value: decodedParameters[index],
}))
}
export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null => {
const [methodId, params] = [data.slice(0, 10) as keyof typeof METHOD_TO_ID | string, data.slice(10)]
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
const method = SAFE_METHODS_NAMES[methodId]
switch (methodId) {
// swapOwner
case '0xe318b52b': {
const decodedParameters = web3.eth.abi.decodeParameters(['uint', 'address', 'address'], params) as string[]
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'oldOwner', type: 'address', value: decodedParameters[1] },
{ name: 'newOwner', type: 'address', value: decodedParameters[2] },
],
switch (method) {
case SAFE_METHODS_NAMES.SWAP_OWNER: {
const params = {
prevOwner: 'address',
oldOwner: 'address',
newOwner: 'address',
}
// we only need to return the addresses that has been swapped, no need for the `prevOwner`
const [, oldOwner, newOwner] = decodeInfo({ paramsHash, params })
return { method, parameters: [oldOwner, newOwner] }
}
// addOwnerWithThreshold
case '0x0d582f13': {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params)
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'owner', type: 'address', value: decodedParameters[0] },
{ name: '_threshold', type: 'uint', value: decodedParameters[1] },
],
case SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD: {
const params = {
owner: 'address',
_threshold: 'uint',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
// removeOwner
case '0xf8dc5dd9': {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'owner', type: 'address', value: decodedParameters[1] },
{ name: '_threshold', type: 'uint', value: decodedParameters[2] },
],
case SAFE_METHODS_NAMES.REMOVE_OWNER: {
const params = {
prevOwner: 'address',
owner: 'address',
_threshold: 'uint',
}
// we only need to return the removed owner and the new threshold, no need for the `prevOwner`
const [, oldOwner, threshold] = decodeInfo({ paramsHash, params })
return { method, parameters: [oldOwner, threshold] }
}
// changeThreshold
case '0x694e80c3': {
const decodedParameters = web3.eth.abi.decodeParameters(['uint'], params)
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: '_threshold', type: 'uint', value: decodedParameters[0] },
],
case SAFE_METHODS_NAMES.CHANGE_THRESHOLD: {
const params = {
_threshold: 'uint',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
// enableModule
case '0x610b5925': {
const decodedParameters = web3.eth.abi.decodeParameters(['address'], params)
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'module', type: 'address', value: decodedParameters[0] },
],
case SAFE_METHODS_NAMES.ENABLE_MODULE: {
const params = {
module: 'address',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
// disableModule
case '0xe009cfde': {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address'], params)
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'prevModule', type: 'address', value: decodedParameters[0] },
{ name: 'module', type: 'address', value: decodedParameters[1] },
],
case SAFE_METHODS_NAMES.DISABLE_MODULE: {
const params = {
prevModule: 'address',
module: 'address',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
default:
return null
}
}
export const isSetAllowanceMethod = (data: string): boolean => {
const methodId = data.slice(0, 10)
return sameString(SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId], SPENDING_LIMIT_METHODS_NAMES.SET_ALLOWANCE)
}
export const isDeleteAllowanceMethod = (data: string): boolean => {
const methodId = data.slice(0, 10)
return sameString(SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId], SPENDING_LIMIT_METHODS_NAMES.DELETE_ALLOWANCE)
}
export const decodeParamsFromSpendingLimit = (data: string): DataDecoded | null => {
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
const method = SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId]
switch (method) {
case SPENDING_LIMIT_METHODS_NAMES.ADD_DELEGATE: {
const params = {
delegate: 'address',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
case SPENDING_LIMIT_METHODS_NAMES.SET_ALLOWANCE: {
const params = {
delegate: 'address',
token: 'address',
allowanceAmount: 'uint96',
resetTimeMin: 'uint16',
resetBaseMin: 'uint32',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
case SPENDING_LIMIT_METHODS_NAMES.EXECUTE_ALLOWANCE_TRANSFER: {
const params = {
safe: 'address',
token: 'address',
to: 'address',
amount: 'uint96',
paymentToken: 'address',
payment: 'uint96',
delegate: 'address',
signature: 'bytes',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
case SPENDING_LIMIT_METHODS_NAMES.DELETE_ALLOWANCE: {
const params = {
delegate: 'address',
token: 'address',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
default:
@ -81,57 +176,53 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
}
const isSafeMethod = (methodId: string): boolean => {
return !!METHOD_TO_ID[methodId]
return !!SAFE_METHOD_ID_TO_NAME[methodId]
}
export const decodeMethods = (data: string): DataDecoded | null => {
if(!data.length) {
const isSpendingLimitMethod = (methodId: string): boolean => {
return !!SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId]
}
export const decodeMethods = (data: string | null): DataDecoded | null => {
if (!data?.length) {
return null
}
const [methodId, params] = [data.slice(0, 10), data.slice(10)]
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
if (isSafeMethod(methodId)) {
return decodeParamsFromSafeMethod(data)
}
switch (methodId) {
// a9059cbb - transfer(address,uint256)
case '0xa9059cbb': {
const decodeParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params)
return {
method: 'transfer',
parameters: [
{ name: 'to', type: '', value: decodeParameters[0] },
{ name: 'value', type: '', value: decodeParameters[1] },
],
if (isSpendingLimitMethod(methodId)) {
return decodeParamsFromSpendingLimit(data)
}
const method = TOKEN_TRANSFER_METHOD_ID_TO_NAME[methodId]
switch (method) {
case TOKEN_TRANSFER_METHODS_NAMES.TRANSFER: {
const params = {
to: 'address',
value: 'uint',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
// 23b872dd - transferFrom(address,address,uint256)
case '0x23b872dd': {
const decodeParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
return {
method: 'transferFrom',
parameters: [
{ name: 'from', type: '', value: decodeParameters[0] },
{ name: 'to', type: '', value: decodeParameters[1] },
{ name: 'value', type: '', value: decodeParameters[2] },
],
case TOKEN_TRANSFER_METHODS_NAMES.TRANSFER_FROM:
case TOKEN_TRANSFER_METHODS_NAMES.SAFE_TRANSFER_FROM: {
const params = {
from: 'address',
to: 'address',
value: 'uint',
}
}
// 42842e0e - safeTransferFrom(address,address,uint256)
case '0x42842e0e': {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
return {
method: 'safeTransferFrom',
parameters: [
{ name: 'from', type: '', value: decodedParameters[0] },
{ name: 'to', type: '', value: decodedParameters[1] },
{ name: 'value', type: '', value: decodedParameters[2] },
],
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
default:

View File

@ -12,6 +12,9 @@ import { calculateGasOf, calculateGasPrice } from 'src/logic/wallets/ethTransact
import { getWeb3, getNetworkIdFrom } from 'src/logic/wallets/getWeb3'
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
import { GnosisSafeProxyFactory } from 'src/types/contracts/GnosisSafeProxyFactory.d'
import { SPENDING_LIMIT_MODULE_ADDRESS } from 'src/utils/constants'
import SpendingLimitModule from './artifacts/AllowanceModule.json'
export const SENTINEL_ADDRESS = '0x0000000000000000000000000000000000000001'
export const MULTI_SEND_ADDRESS = '0x8d29be29923b68abfdd21e541b9374737b49cdad'
@ -51,8 +54,13 @@ const createProxyFactoryContract = (web3: Web3, networkId: ETHEREUM_NETWORK): Gn
return new web3.eth.Contract(ProxyFactorySol.abi as AbiItem[], contractAddress) as unknown as GnosisSafeProxyFactory
}
export const getGnosisSafeContract = memoize(createGnosisSafeContract)
const createSpendingLimitContract = () => {
const web3 = getWeb3()
return new web3.eth.Contract(SpendingLimitModule.abi as AbiItem[], SPENDING_LIMIT_MODULE_ADDRESS)
}
export const getGnosisSafeContract = memoize(createGnosisSafeContract)
export const getSpendingLimitContract = memoize(createSpendingLimitContract)
const getCreateProxyFactoryContract = memoize(createProxyFactoryContract)
const instantiateMasterCopies = async () => {

View File

@ -99,6 +99,28 @@ const settingsChangeTxNotificationsQueue = {
afterExecutionError: NOTIFICATIONS.SETTINGS_CHANGE_FAILED_MSG,
}
const newSpendingLimitTxNotificationsQueue = {
beforeExecution: NOTIFICATIONS.SIGN_NEW_SPENDING_LIMIT_MSG,
pendingExecution: NOTIFICATIONS.NEW_SPENDING_LIMIT_PENDING_MSG,
afterRejection: NOTIFICATIONS.NEW_SPENDING_LIMIT_REJECTED_MSG,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.NEW_SPENDING_LIMIT_EXECUTED_MSG,
moreConfirmationsNeeded: NOTIFICATIONS.NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG,
},
afterExecutionError: NOTIFICATIONS.NEW_SPENDING_LIMIT_FAILED_MSG,
}
const removeSpendingLimitTxNotificationsQueue = {
beforeExecution: NOTIFICATIONS.SIGN_REMOVE_SPENDING_LIMIT_MSG,
pendingExecution: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_PENDING_MSG,
afterRejection: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_REJECTED_MSG,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_EXECUTED_MSG,
moreConfirmationsNeeded: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG,
},
afterExecutionError: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_FAILED_MSG,
}
const defaultNotificationsQueue = {
beforeExecution: NOTIFICATIONS.SIGN_TX_MSG,
pendingExecution: NOTIFICATIONS.TX_PENDING_MSG,
@ -166,6 +188,14 @@ export const getNotificationsFromTxType: any = (txType, origin) => {
notificationsQueue = settingsChangeTxNotificationsQueue
break
}
case TX_NOTIFICATION_TYPES.NEW_SPENDING_LIMIT_TX: {
notificationsQueue = newSpendingLimitTxNotificationsQueue
break
}
case TX_NOTIFICATION_TYPES.REMOVE_SPENDING_LIMIT_TX: {
notificationsQueue = removeSpendingLimitTxNotificationsQueue
break
}
case TX_NOTIFICATION_TYPES.SAFE_NAME_CHANGE_TX: {
notificationsQueue = safeNameChangeNotificationsQueue
break

View File

@ -46,6 +46,18 @@ const NOTIFICATION_IDS = {
SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: 'SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG',
SETTINGS_CHANGE_FAILED_MSG: 'SETTINGS_CHANGE_FAILED_MSG',
TESTNET_VERSION_MSG: 'TESTNET_VERSION_MSG',
SIGN_NEW_SPENDING_LIMIT_MSG: 'SIGN_NEW_SPENDING_LIMIT_MSG',
NEW_SPENDING_LIMIT_PENDING_MSG: 'NEW_SPENDING_LIMIT_PENDING_MSG',
NEW_SPENDING_LIMIT_REJECTED_MSG: 'NEW_SPENDING_LIMIT_REJECTED_MSG',
NEW_SPENDING_LIMIT_EXECUTED_MSG: 'NEW_SPENDING_LIMIT_EXECUTED_MSG',
NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: 'NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG',
NEW_SPENDING_LIMIT_FAILED_MSG: 'NEW_SPENDING_LIMIT_FAILED_MSG',
SIGN_REMOVE_SPENDING_LIMIT_MSG: 'SIGN_REMOVE_SPENDING_LIMIT_MSG',
REMOVE_SPENDING_LIMIT_PENDING_MSG: 'REMOVE_SPENDING_LIMIT_PENDING_MSG',
REMOVE_SPENDING_LIMIT_REJECTED_MSG: 'REMOVE_SPENDING_LIMIT_REJECTED_MSG',
REMOVE_SPENDING_LIMIT_EXECUTED_MSG: 'REMOVE_SPENDING_LIMIT_EXECUTED_MSG',
REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: 'REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG',
REMOVE_SPENDING_LIMIT_FAILED_MSG: 'REMOVE_SPENDING_LIMIT_FAILED_MSG',
WRONG_NETWORK_MSG: 'WRONG_NETWORK_MSG',
ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS',
ADDRESS_BOOK_EDIT_ENTRY_SUCCESS: 'ADDRESS_BOOK_EDIT_ENTRY_SUCCESS',
@ -191,6 +203,56 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
},
// Spending Limit
SIGN_NEW_SPENDING_LIMIT_MSG: {
message: 'Please sign the new Spending Limit',
options: { variant: INFO, persist: true },
},
NEW_SPENDING_LIMIT_PENDING_MSG: {
message: 'New Spending Limit pending',
options: { variant: INFO, persist: true },
},
NEW_SPENDING_LIMIT_REJECTED_MSG: {
message: 'New Spending Limit rejected',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
},
NEW_SPENDING_LIMIT_EXECUTED_MSG: {
message: 'New Spending Limit successfully executed',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
},
NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: {
message: 'New Spending Limit successfully created. More confirmations needed to execute',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
},
NEW_SPENDING_LIMIT_FAILED_MSG: {
message: 'New Spending Limit failed',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
},
SIGN_REMOVE_SPENDING_LIMIT_MSG: {
message: 'Please sign the remove Spending Limit',
options: { variant: INFO, persist: true },
},
REMOVE_SPENDING_LIMIT_PENDING_MSG: {
message: 'Remove Spending Limit pending',
options: { variant: INFO, persist: true },
},
REMOVE_SPENDING_LIMIT_REJECTED_MSG: {
message: 'Remove Spending Limit rejected',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
},
REMOVE_SPENDING_LIMIT_EXECUTED_MSG: {
message: 'Remove Spending Limit successfully executed',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
},
REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: {
message: 'Remove Spending Limit successfully created. More confirmations needed to execute',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
},
REMOVE_SPENDING_LIMIT_FAILED_MSG: {
message: 'Remove Spending Limit failed',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
},
// Network
TESTNET_VERSION_MSG: {
message: "Testnet Version: Don't send production assets to this Safe",

View File

@ -0,0 +1,19 @@
import { useSelector } from 'react-redux'
import { getNetworkInfo } from 'src/config'
import { Token } from 'src/logic/tokens/store/model/token'
import { sameAddress, ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { safeKnownCoins } from 'src/routes/safe/container/selector'
const { nativeCoin } = getNetworkInfo()
const useTokenInfo = (address: string): Token | undefined => {
const tokens = useSelector(safeKnownCoins)
if (tokens) {
const tokenAddress = sameAddress(address, ZERO_ADDRESS) ? nativeCoin.address : address
return tokens.find((token) => sameAddress(token.address, tokenAddress)) ?? undefined
}
}
export default useTokenInfo

View File

@ -0,0 +1,13 @@
import { createAction } from 'redux-actions'
import { ModuleTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadModuleTransactions'
export const ADD_MODULE_TRANSACTIONS = 'ADD_MODULE_TRANSACTIONS'
export type AddModuleTransactionsAction = {
payload: {
safeAddress: string
modules: ModuleTxServiceModel[]
}
}
export const addModuleTransactions = createAction(ADD_MODULE_TRANSACTIONS)

View File

@ -41,7 +41,7 @@ import { PayableTx } from 'src/types/contracts/types.d'
import { AppReduxState } from 'src/store'
import { Dispatch, DispatchReturn } from './types'
interface CreateTransactionArgs {
export interface CreateTransactionArgs {
navigateToTransactionsTab?: boolean
notifiedTransaction: string
operation?: number

View File

@ -18,6 +18,7 @@ import { AppReduxState } from 'src/store'
import { latestMasterContractVersionSelector } from 'src/logic/safe/store/selectors'
import { getSafeInfo } from 'src/logic/safe/utils/safeInformation'
import { getModules } from 'src/logic/safe/utils/modules'
import { getSpendingLimits } from 'src/logic/safe/utils/spendingLimits'
const buildOwnersFrom = (safeOwners: string[], localSafe?: SafeRecordProps): List<SafeOwner> => {
const ownersList = safeOwners.map((ownerAddress) => {
@ -71,6 +72,7 @@ export const buildSafe = async (
const needsUpdate = safeNeedsUpdate(currentVersion, latestMasterContractVersion)
const featuresEnabled = enabledFeatures(currentVersion)
const modules = await getModules(safeInfo)
const spendingLimits = safeInfo ? await getSpendingLimits(safeInfo.modules, safeAddress) : null
return {
address: safeAddress,
@ -89,6 +91,7 @@ export const buildSafe = async (
blacklistedAssets: Set(),
blacklistedTokens: Set(),
modules,
spendingLimits,
}
}
@ -106,6 +109,9 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch
getLocalSafe(safeAddress),
])
// request SpendingLimit info
const spendingLimits = safeInfo ? await getSpendingLimits(safeInfo.modules, safeAddress) : null
// Converts from [ { address, ownerName} ] to address array
const localOwners = localSafe ? localSafe.owners.map((localOwner) => localOwner.address) : []
@ -116,6 +122,7 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch
address: safeAddress,
name: localSafe?.name,
modules,
spendingLimits,
nonce: Number(remoteNonce),
threshold: Number(remoteThreshold),
featuresEnabled: localSafe?.currentVersion

View File

@ -2,19 +2,27 @@ import axios from 'axios'
import { buildTxServiceUrl } from 'src/logic/safe/transactions'
import { buildIncomingTxServiceUrl } from 'src/logic/safe/transactions/incomingTxHistory'
import { buildModuleTxServiceUrl } from 'src/logic/safe/transactions/moduleTxHistory'
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
import { IncomingTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadIncomingTransactions'
import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
import { ModuleTxServiceModel } from './loadModuleTransactions'
const getServiceUrl = (txType: string, safeAddress: string): string => {
return {
[TransactionTypes.INCOMING]: buildIncomingTxServiceUrl,
[TransactionTypes.OUTGOING]: buildTxServiceUrl,
[TransactionTypes.MODULE]: buildModuleTxServiceUrl,
}[txType](safeAddress)
}
// TODO: Remove this magic
/* eslint-disable */
async function fetchTransactions(
txType: TransactionTypes.MODULE,
safeAddress: string,
eTag: string | null,
): Promise<{ eTag: string | null; results: ModuleTxServiceModel[] }>
async function fetchTransactions(
txType: TransactionTypes.INCOMING,
safeAddress: string,
@ -26,10 +34,10 @@ async function fetchTransactions(
eTag: string | null,
): Promise<{ eTag: string | null; results: TxServiceModel[] }>
async function fetchTransactions(
txType: TransactionTypes.INCOMING | TransactionTypes.OUTGOING,
txType: TransactionTypes.MODULE | TransactionTypes.INCOMING | TransactionTypes.OUTGOING,
safeAddress: string,
eTag: string | null,
): Promise<{ eTag: string | null; results: TxServiceModel[] | IncomingTxServiceModel[] }> {
): Promise<{ eTag: string | null; results: ModuleTxServiceModel[] | TxServiceModel[] | IncomingTxServiceModel[] }> {
/* eslint-enable */
try {
const url = getServiceUrl(txType, safeAddress)

View File

@ -3,9 +3,11 @@ import { ThunkAction, ThunkDispatch } from 'redux-thunk'
import { AnyAction } from 'redux'
import { backOff } from 'exponential-backoff'
import { addIncomingTransactions } from '../../addIncomingTransactions'
import { addIncomingTransactions } from 'src/logic/safe/store/actions/addIncomingTransactions'
import { addModuleTransactions } from 'src/logic/safe/store/actions/addModuleTransactions'
import { loadIncomingTransactions } from './loadIncomingTransactions'
import { loadModuleTransactions } from './loadModuleTransactions'
import { loadOutgoingTransactions } from './loadOutgoingTransactions'
import { addOrUpdateCancellationTransactions } from 'src/logic/safe/store/actions/transactions/addOrUpdateCancellationTransactions'
@ -44,6 +46,12 @@ export default (safeAddress: string): ThunkAction<Promise<void>, AppReduxState,
if (safeIncomingTxs?.size) {
dispatch(addIncomingTransactions(incomingTransactions))
}
const moduleTransactions = await loadModuleTransactions(safeAddress)
if (moduleTransactions.length) {
dispatch(addModuleTransactions({ modules: moduleTransactions, safeAddress }))
}
} catch (error) {
console.log('Error fetching transactions:', error)
}

View File

@ -0,0 +1,35 @@
import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTransactions/fetchTransactions'
import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
import { DataDecoded, Operation } from 'src/logic/safe/store/models/types/transactions.d'
export type ModuleTxServiceModel = {
created: string
executionDate: string
blockNumber: number
transactionHash: string
safe: string
module: string
to: string
value: string
data: string
operation: Operation
dataDecoded: DataDecoded
}
type ETag = string | null
let previousETag: ETag = null
export const loadModuleTransactions = async (safeAddress: string): Promise<ModuleTxServiceModel[]> => {
if (!safeAddress) {
return []
}
const { eTag, results }: { eTag: ETag; results: ModuleTxServiceModel[] } = await fetchTransactions(
TransactionTypes.MODULE,
safeAddress,
previousETag,
)
previousETag = eTag
return results
}

View File

@ -13,6 +13,16 @@ export type ModulePair = [
string,
]
export type SpendingLimit = {
delegate: string
token: string
amount: string
spent: string
resetTimeMin: string
lastResetMin: string
nonce: string
}
export type SafeRecordProps = {
name: string
address: string
@ -20,6 +30,7 @@ export type SafeRecordProps = {
ethBalance: string
owners: List<SafeOwner>
modules?: ModulePair[] | null
spendingLimits?: SpendingLimit[] | null
activeTokens: Set<string>
activeAssets: Set<string>
blacklistedTokens: Set<string>
@ -40,6 +51,7 @@ const makeSafe = Record<SafeRecordProps>({
ethBalance: '0',
owners: List([]),
modules: [],
spendingLimits: [],
activeTokens: Set(),
activeAssets: Set(),
blacklistedTokens: Set(),

View File

@ -1,4 +1,7 @@
import { List, Map, RecordOf } from 'immutable'
import { ModuleTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadModuleTransactions'
import { Token } from 'src/logic/tokens/store/model/token'
import { Confirmation } from './confirmation'
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
import { DataDecoded, Transfer } from './transactions'
@ -14,6 +17,8 @@ export enum TransactionTypes {
UPGRADE = 'upgrade',
TOKEN = 'token',
COLLECTIBLE = 'collectible',
MODULE = 'module',
SPENDING_LIMIT = 'spendingLimit',
}
export type TransactionTypeValues = typeof TransactionTypes[keyof typeof TransactionTypes]
@ -47,7 +52,7 @@ export type TransactionProps = {
data: string | null
dataDecoded: DataDecoded | null
decimals?: (number | string) | null
decodedParams: DecodedParams | null
decodedParams: DecodedParams
executionDate?: string | null
executionTxHash?: string | null
executor: string
@ -101,3 +106,17 @@ export type TxArgs = {
to: string
valueInWei: string
}
type SafeModuleCompatibilityTypes = {
nonce?: string // not required for this tx: added for compatibility
fee?: number // not required for this tx: added for compatibility
executionTxHash?: string // not required for this tx: added for compatibility
safeTxHash: string // table uses this key as a unique row identifier, added for compatibility
}
export type SafeModuleTransaction = ModuleTxServiceModel &
SafeModuleCompatibilityTypes & {
status: TransactionStatus
type: TransactionTypes
tokenInfo?: Token
}

View File

@ -228,16 +228,30 @@ export const SAFE_METHODS_NAMES = {
SWAP_OWNER: 'swapOwner',
ENABLE_MODULE: 'enableModule',
DISABLE_MODULE: 'disableModule',
}
} as const
export const METHOD_TO_ID = {
export const SAFE_METHOD_ID_TO_NAME = {
'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,
}
} as const
export const SPENDING_LIMIT_METHODS_NAMES = {
ADD_DELEGATE: 'addDelegate',
SET_ALLOWANCE: 'setAllowance',
EXECUTE_ALLOWANCE_TRANSFER: 'executeAllowanceTransfer',
DELETE_ALLOWANCE: 'deleteAllowance',
} as const
export const SPENDING_LIMIT_METHOD_ID_TO_NAME = {
'0xe71bdf41': SPENDING_LIMIT_METHODS_NAMES.ADD_DELEGATE,
'0xbeaeb388': SPENDING_LIMIT_METHODS_NAMES.SET_ALLOWANCE,
'0x4515641a': SPENDING_LIMIT_METHODS_NAMES.EXECUTE_ALLOWANCE_TRANSFER,
'0x885133e3': SPENDING_LIMIT_METHODS_NAMES.DELETE_ALLOWANCE,
} as const
export type SafeMethods = typeof SAFE_METHODS_NAMES[keyof typeof SAFE_METHODS_NAMES]
@ -247,13 +261,19 @@ export const TOKEN_TRANSFER_METHODS_NAMES = {
SAFE_TRANSFER_FROM: 'safeTransferFrom',
} as const
export const TOKEN_TRANSFER_METHOD_ID_TO_NAME = {
'0xa9059cbb': TOKEN_TRANSFER_METHODS_NAMES.TRANSFER,
'0x23b872dd': TOKEN_TRANSFER_METHODS_NAMES.TRANSFER_FROM,
'0x42842e0e': TOKEN_TRANSFER_METHODS_NAMES.SAFE_TRANSFER_FROM,
} as const
type TokenMethods = typeof TOKEN_TRANSFER_METHODS_NAMES[keyof typeof TOKEN_TRANSFER_METHODS_NAMES]
type SafeDecodedParams = {
export type SafeDecodedParams = {
[key in SafeMethods]?: Record<string, string>
}
type TokenDecodedParams = {
export type TokenDecodedParams = {
[key in TokenMethods]?: Record<string, string>
}

View File

@ -0,0 +1,32 @@
import { handleActions } from 'redux-actions'
import {
ADD_MODULE_TRANSACTIONS,
AddModuleTransactionsAction,
} from 'src/logic/safe/store/actions/addModuleTransactions'
import { ModuleTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadModuleTransactions'
export const MODULE_TRANSACTIONS_REDUCER_ID = 'moduleTransactions'
export interface ModuleTransactionsState {
[safeAddress: string]: ModuleTxServiceModel[]
}
export default handleActions(
{
[ADD_MODULE_TRANSACTIONS]: (state: ModuleTransactionsState, action: AddModuleTransactionsAction) => {
const { modules, safeAddress } = action.payload
const oldModuleTxs = state[safeAddress] ?? []
const oldModuleTxsHashes = oldModuleTxs.map(({ transactionHash }) => transactionHash)
// As backend is returning the whole list of txs on every request,
// to avoid duplicates, filtering happens in this level.
const newModuleTxs = modules.filter((moduleTx) => !oldModuleTxsHashes.includes(moduleTx.transactionHash))
return {
...state,
[safeAddress]: [...oldModuleTxs, ...newModuleTxs],
}
},
},
{},
)

View File

@ -208,6 +208,8 @@ export const safeModulesSelector = createSelector(safeSelector, safeFieldSelecto
export const safeFeaturesEnabledSelector = createSelector(safeSelector, safeFieldSelector('featuresEnabled'))
export const safeSpendingLimitsSelector = createSelector(safeSelector, safeFieldSelector('spendingLimits'))
export const safeOwnersAddressesListSelector = createSelector(
safeOwnersSelector,
(owners): List<string> => {

View File

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

View File

@ -0,0 +1,9 @@
import { getSafeServiceBaseUrl } from 'src/config'
import { checksumAddress } from 'src/utils/checksumAddress'
export const buildModuleTxServiceUrl = (safeAddress: string): string => {
const address = checksumAddress(safeAddress)
const url = getSafeServiceBaseUrl(address)
return `${url}/module-transactions/`
}

View File

@ -1,11 +1,14 @@
export const TX_NOTIFICATION_TYPES: any = {
export const TX_NOTIFICATION_TYPES = {
STANDARD_TX: 'STANDARD_TX',
CONFIRMATION_TX: 'CONFIRMATION_TX',
CANCELLATION_TX: 'CANCELLATION_TX',
WAITING_TX: 'WAITING_TX',
SETTINGS_CHANGE_TX: 'SETTINGS_CHANGE_TX',
NEW_SPENDING_LIMIT_TX: 'NEW_SPENDING_LIMIT_TX',
REMOVE_SPENDING_LIMIT_TX: 'REMOVE_SPENDING_LIMIT_TX',
SAFE_NAME_CHANGE_TX: 'SAFE_NAME_CHANGE_TX',
OWNER_NAME_CHANGE_TX: 'OWNER_NAME_CHANGE_TX',
ADDRESSBOOK_NEW_ENTRY: 'ADDRESSBOOK_NEW_ENTRY',
ADDRESSBOOK_EDIT_ENTRY: 'ADDRESSBOOK_EDIT_ENTRY',
ADDRESSBOOK_DELETE_ENTRY: 'ADDRESSBOOK_DELETE_ENTRY',
}

View File

@ -1,7 +1,9 @@
import semverLessThan from 'semver/functions/lt'
import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts'
import { CreateTransactionArgs } from 'src/logic/safe/store/actions/createTransaction'
import { ModulePair } from 'src/logic/safe/store/models/safe'
import { CALL, TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { SafeInfo } from 'src/logic/safe/utils/safeInformation'
type ModulesPaginated = {
@ -97,3 +99,20 @@ export const getDisableModuleTxData = (modulePair: ModulePair, safeAddress: stri
return safeInstance.methods.disableModule(previousModule, module).encodeABI()
}
type EnableModuleParams = {
moduleAddress: string
safeAddress: string
}
export const enableModuleTx = ({ moduleAddress, safeAddress }: EnableModuleParams): CreateTransactionArgs => {
const safeInstance = getGnosisSafeInstanceAt(safeAddress)
return {
safeAddress,
to: safeAddress,
operation: CALL,
valueInWei: '0',
txData: safeInstance.methods.enableModule(moduleAddress).encodeABI(),
notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
}
}

View File

@ -0,0 +1,294 @@
import { BigNumber } from 'bignumber.js'
import { getNetworkInfo } from 'src/config'
import { AbiItem } from 'web3-utils'
import { CreateTransactionArgs } from 'src/logic/safe/store/actions/createTransaction'
import { CALL, DELEGATE_CALL, TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { enableModuleTx } from 'src/logic/safe/utils/modules'
import SpendingLimitModule from 'src/logic/contracts/artifacts/AllowanceModule.json'
import generateBatchRequests from 'src/logic/contracts/generateBatchRequests'
import { getSpendingLimitContract, MULTI_SEND_ADDRESS } from 'src/logic/contracts/safeContracts'
import { SpendingLimit } from 'src/logic/safe/store/models/safe'
import { sameAddress, ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { getWeb3, web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { SPENDING_LIMIT_MODULE_ADDRESS } from 'src/utils/constants'
import { getEncodedMultiSendCallData, MultiSendTx } from './upgradeSafe'
import { fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
import { getBalanceAndDecimalsFromToken, GetTokenByAddress } from 'src/logic/tokens/utils/tokenHelpers'
import { sameString } from 'src/utils/strings'
export const currentMinutes = (): number => Math.floor(Date.now() / (1000 * 60))
const requestTokensByDelegate = async (
safeAddress: string,
delegates: string[],
): Promise<[string, string[] | undefined][]> => {
const batch = new web3ReadOnly.BatchRequest()
const whenRequestValues = delegates.map((delegateAddress: string) =>
generateBatchRequests<[string, string[] | undefined]>({
abi: SpendingLimitModule.abi as AbiItem[],
address: SPENDING_LIMIT_MODULE_ADDRESS,
methods: [{ method: 'getTokens', args: [safeAddress, delegateAddress] }],
batch,
context: delegateAddress,
}),
)
batch.execute()
return Promise.all(whenRequestValues)
}
export type SpendingLimitRow = {
delegate: string
token: string
amount: string
spent: string
resetTimeMin: string
lastResetMin: string
nonce: string
}
const ZERO_VALUE = '0'
/**
* Deleted Allowance have their `amount` and `resetTime` set to `0` (zero)
* @param {SpendingLimitRow} allowance
* @returns boolean
*/
const discardZeroAllowance = ({ amount, resetTimeMin }: SpendingLimitRow): boolean =>
!(sameString(amount, ZERO_VALUE) && sameString(resetTimeMin, ZERO_VALUE))
type TokenSpendingLimit = [string, string, string, string, string]
type TokenSpendingLimitContext = {
delegate: string
token: string
}
type TokenSpendingLimitRequest = [TokenSpendingLimitContext, TokenSpendingLimit | undefined]
const requestAllowancesByDelegatesAndTokens = async (
safeAddress: string,
tokensByDelegate: [string, string[] | undefined][],
): Promise<SpendingLimitRow[]> => {
const batch = new web3ReadOnly.BatchRequest()
const whenRequestValues: Promise<TokenSpendingLimitRequest>[] = []
for (const [delegate, tokens] of tokensByDelegate) {
if (tokens) {
for (const token of tokens) {
whenRequestValues.push(
generateBatchRequests<[TokenSpendingLimitContext, TokenSpendingLimit]>({
abi: SpendingLimitModule.abi as AbiItem[],
address: SPENDING_LIMIT_MODULE_ADDRESS,
methods: [{ method: 'getTokenAllowance', args: [safeAddress, delegate, token] }],
batch,
context: { delegate, token },
}),
)
}
}
}
batch.execute()
return Promise.all(whenRequestValues).then((allowances) =>
allowances
// first, we filter out those records whose tokenSpendingLimit is undefined
.filter(([, tokenSpendingLimit]) => tokenSpendingLimit)
// then, we build the SpendingLimitRow object
.map(([{ delegate, token }, tokenSpendingLimit]) => {
const [amount, spent, resetTimeMin, lastResetMin, nonce] = tokenSpendingLimit as TokenSpendingLimit
return {
delegate,
token,
amount,
spent,
resetTimeMin,
lastResetMin,
nonce,
}
})
.filter(discardZeroAllowance),
)
}
export const getSpendingLimits = async (
modules: string[] | undefined,
safeAddress: string,
): Promise<SpendingLimit[] | null> => {
const isSpendingLimitEnabled = modules?.some((module) => sameAddress(module, SPENDING_LIMIT_MODULE_ADDRESS)) ?? false
if (isSpendingLimitEnabled) {
const delegates = await getSpendingLimitContract().methods.getDelegates(safeAddress, 0, 100).call()
const tokensByDelegate = await requestTokensByDelegate(safeAddress, delegates.results)
return requestAllowancesByDelegatesAndTokens(safeAddress, tokensByDelegate)
}
return null
}
type DeleteAllowanceParams = {
beneficiary: string
tokenAddress: string
}
export const getDeleteAllowanceTxData = ({ beneficiary, tokenAddress }: DeleteAllowanceParams): string => {
const { nativeCoin } = getNetworkInfo()
const token = sameAddress(tokenAddress, nativeCoin.address) ? ZERO_ADDRESS : tokenAddress
const web3 = getWeb3()
const spendingLimitContract = new web3.eth.Contract(
SpendingLimitModule.abi as AbiItem[],
SPENDING_LIMIT_MODULE_ADDRESS,
)
return spendingLimitContract.methods.deleteAllowance(beneficiary, token).encodeABI()
}
export const enableSpendingLimitModuleMultiSendTx = (safeAddress: string): MultiSendTx => {
const multiSendTx = enableModuleTx({ moduleAddress: SPENDING_LIMIT_MODULE_ADDRESS, safeAddress })
return {
to: multiSendTx.to,
value: Number(multiSendTx.valueInWei),
data: multiSendTx.txData as string,
operation: DELEGATE_CALL,
}
}
export const addSpendingLimitBeneficiaryMultiSendTx = (beneficiary: string): MultiSendTx => {
const spendingLimitContract = getSpendingLimitContract()
return {
to: SPENDING_LIMIT_MODULE_ADDRESS,
value: 0,
data: spendingLimitContract.methods.addDelegate(beneficiary).encodeABI(),
operation: DELEGATE_CALL,
}
}
type SpendingLimitTxParams = {
spendingLimitArgs: {
beneficiary: string
token: string
spendingLimitInWei: string
resetTimeMin: number
resetBaseMin: number
}
safeAddress
}
export const setSpendingLimitTx = ({
spendingLimitArgs: { beneficiary, token, spendingLimitInWei, resetTimeMin, resetBaseMin },
safeAddress,
}: SpendingLimitTxParams): CreateTransactionArgs => {
const spendingLimitContract = getSpendingLimitContract()
const { nativeCoin } = getNetworkInfo()
return {
safeAddress,
to: SPENDING_LIMIT_MODULE_ADDRESS,
valueInWei: ZERO_VALUE,
txData: spendingLimitContract.methods
.setAllowance(
beneficiary,
token === nativeCoin.address ? ZERO_ADDRESS : token,
spendingLimitInWei,
resetTimeMin,
resetBaseMin,
)
.encodeABI(),
operation: CALL,
notifiedTransaction: TX_NOTIFICATION_TYPES.NEW_SPENDING_LIMIT_TX,
}
}
export const setSpendingLimitMultiSendTx = (args: SpendingLimitTxParams): MultiSendTx => {
const tx = setSpendingLimitTx(args)
return {
to: tx.to,
value: Number(tx.valueInWei),
data: tx.txData as string,
operation: DELEGATE_CALL,
}
}
type SpendingLimitMultiSendTx = {
transactions: Array<MultiSendTx>
safeAddress: string
}
export const spendingLimitMultiSendTx = ({
transactions,
safeAddress,
}: SpendingLimitMultiSendTx): CreateTransactionArgs => ({
safeAddress,
to: MULTI_SEND_ADDRESS,
valueInWei: ZERO_VALUE,
txData: getEncodedMultiSendCallData(transactions, getWeb3()),
notifiedTransaction: TX_NOTIFICATION_TYPES.NEW_SPENDING_LIMIT_TX,
operation: DELEGATE_CALL,
})
type SpendingLimitAllowedBalance = GetTokenByAddress & {
tokenSpendingLimit: SpendingLimit
}
/**
* Calculates the remaining amount available for a particular SpendingLimit
* @param {string} tokenAddress
* @param {SpendingLimit} tokenSpendingLimit
* @param {List<Token>} tokens
* returns string
*/
export const spendingLimitAllowedBalance = ({
tokenAddress,
tokenSpendingLimit,
tokens,
}: SpendingLimitAllowedBalance): string | number => {
const token = getBalanceAndDecimalsFromToken({ tokenAddress, tokens })
if (!token) {
return 0
}
const { balance, decimals } = token
const diff = new BigNumber(tokenSpendingLimit.amount).minus(tokenSpendingLimit.spent).toString()
const diffInFPNotation = fromTokenUnit(diff, decimals)
return new BigNumber(balance).gt(diffInFPNotation) ? diffInFPNotation : balance
}
type GetSpendingLimitByTokenAddress = {
spendingLimits?: SpendingLimit[] | null
tokenAddress?: string
}
/**
* Returns the SpendingLimit info for the specified tokenAddress
* @param {SpendingLimit[] | undefined | null} spendingLimits
* @param {string | undefined} tokenAddress
* @returns SpendingLimit | undefined
*/
export const getSpendingLimitByTokenAddress = ({
spendingLimits,
tokenAddress,
}: GetSpendingLimitByTokenAddress): SpendingLimit | undefined => {
if (!tokenAddress || !spendingLimits) {
return
}
const { nativeCoin } = getNetworkInfo()
return spendingLimits.find(({ token: spendingLimitTokenAddress }) => {
spendingLimitTokenAddress = sameAddress(spendingLimitTokenAddress, ZERO_ADDRESS)
? nativeCoin.address
: spendingLimitTokenAddress
return sameAddress(spendingLimitTokenAddress, tokenAddress)
})
}

View File

@ -10,7 +10,7 @@ import { DELEGATE_CALL } from 'src/logic/safe/transactions'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { MultiSend } from 'src/types/contracts/MultiSend.d'
interface MultiSendTx {
export interface MultiSendTx {
operation: number
to: string
value: number

View File

@ -6,7 +6,7 @@ export type TokenProps = {
symbol: string
decimals: number | string
logoUri: string
balance?: number | string
balance: number | string
}
export const makeToken = Record<TokenProps>({
@ -15,7 +15,7 @@ export const makeToken = Record<TokenProps>({
symbol: '',
decimals: 0,
logoUri: '',
balance: undefined,
balance: 0,
})
// balance is only set in extendedSafeTokensSelector when we display user's token balances

View File

@ -1,3 +1,4 @@
import { List } from 'immutable'
import { AbiItem } from 'web3-utils'
import { getNetworkInfo } from 'src/config'
@ -10,6 +11,7 @@ import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3'
import { isEmptyData } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
import { CALL } from 'src/logic/safe/transactions'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
export const getEthAsToken = (balance: string | number): Token => {
const { nativeCoin } = getNetworkInfo()
@ -82,3 +84,32 @@ export const isSendERC20Transaction = async (tx: TxServiceModel): Promise<boolea
return isSendTokenTx
}
export type GetTokenByAddress = {
tokenAddress: string
tokens: List<Token>
}
export type TokenFound = {
balance: string | number
decimals: string | number
}
/**
* Finds and returns a Token object by the provided address
* @param {string} tokenAddress
* @param {List<Token>} tokens
* @returns Token | undefined
*/
export const getBalanceAndDecimalsFromToken = ({ tokenAddress, tokens }: GetTokenByAddress): TokenFound | undefined => {
const token = tokens?.find(({ address }) => sameAddress(address, tokenAddress))
if (!token) {
return
}
return {
balance: token.balance ?? 0,
decimals: token.decimals ?? 0,
}
}

View File

@ -20,6 +20,7 @@ import { trimSpaces } from 'src/utils/strings'
export interface AddressBookProps {
fieldMutator: (address: string) => void
label?: string
pristine?: boolean
recipientAddress?: string
setIsValidAddress: (valid: boolean) => void
@ -36,6 +37,7 @@ export interface BaseAddressBookInputProps extends AddressBookProps {
const BaseAddressBookInput = ({
addressBookEntries,
fieldMutator,
label = 'Recipient',
setIsValidAddress,
setSelectedEntry,
setValidationText,
@ -137,7 +139,7 @@ const BaseAddressBookInput = ({
fullWidth
id="filled-error-helper-text"
variant="filled"
label={validationText ? validationText : 'Recipient'}
label={validationText ? validationText : label}
InputLabelProps={{ shrink: true, required: true, classes: labelStyles }}
InputProps={{ ...params.InputProps, classes: inputStyles }}
/>

View File

@ -27,6 +27,7 @@ import { safeSelector } from 'src/logic/safe/store/selectors'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import { ContractsAddressBookInput } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
import { sm } from 'src/theme/variables'
import { sameString } from 'src/utils/strings'
import ArrowDown from '../../assets/arrow-down.svg'
@ -147,7 +148,7 @@ const SendCustomTx: React.FC<Props> = ({ initialValues, onClose, onNext, contrac
{selectedEntry && selectedEntry.address ? (
<div
onKeyDown={(e) => {
if (e.key === 'Tab') {
if (sameString(e.key, 'Tab')) {
return
}
setSelectedEntry(null)

View File

@ -1,7 +1,6 @@
import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import { BigNumber } from 'bignumber.js'
import React, { useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { toTokenUnit, fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
@ -16,17 +15,21 @@ import Hairline from 'src/components/layout/Hairline'
import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { getSpendingLimitContract } from 'src/logic/contracts/safeContracts'
import createTransaction from 'src/logic/safe/store/actions/createTransaction'
import { safeSelector } from 'src/logic/safe/store/selectors'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { estimateTxGasCosts } from 'src/logic/safe/transactions/gas'
import { getHumanFriendlyToken } from 'src/logic/tokens/store/actions/fetchTokens'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { sameAddress, ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { EMPTY_DATA } from 'src/logic/wallets/ethTransactions'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import { extendedSafeTokensSelector } from 'src/routes/safe/container/selector'
import { SpendingLimit } from 'src/logic/safe/store/models/safe'
import { sm } from 'src/theme/variables'
import { sameString } from 'src/utils/strings'
import ArrowDown from '../assets/arrow-down.svg'
@ -42,6 +45,8 @@ export type ReviewTxProp = {
amount: string
txRecipient: string
token: string
txType?: string
tokenSpendingLimit?: SpendingLimit
}
type ReviewTxProps = {
@ -58,8 +63,8 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
const [gasCosts, setGasCosts] = useState('< 0.001')
const [data, setData] = useState('')
const txToken = useMemo(() => tokens.find((token) => token.address === tx.token), [tokens, tx.token])
const isSendingETH = txToken?.address === nativeCoin.address
const txToken = useMemo(() => tokens.find((token) => sameAddress(token.address, tx.token)), [tokens, tx.token])
const isSendingETH = sameAddress(txToken?.address, nativeCoin.address)
const txRecipient = isSendingETH ? tx.recipientAddress : txToken?.address
useEffect(() => {
@ -75,8 +80,7 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
if (!isSendingETH) {
const StandardToken = await getHumanFriendlyToken()
const tokenInstance = await StandardToken.at(txToken.address as string)
const decimals = await tokenInstance.decimals()
const txAmount = new BigNumber(tx.amount).times(10 ** decimals.toNumber()).toString()
const txAmount = toTokenUnit(tx.amount, txToken.decimals)
txData = tokenInstance.contract.methods.transfer(tx.recipientAddress, txAmount).encodeABI()
}
@ -99,12 +103,34 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
}, [isSendingETH, safeAddress, tx.amount, tx.recipientAddress, txRecipient, txToken])
const submitTx = async () => {
const isSpendingLimit = sameString(tx.txType, 'spendingLimit')
// txAmount should be 0 if we send tokens
// the real value is encoded in txData and will be used by the contract
// if txAmount > 0 it would send ETH from the Safe
const txAmount = isSendingETH ? toTokenUnit(tx.amount, nativeCoin.decimals) : '0'
if (safeAddress) {
if (!safeAddress) {
console.error('There was an error trying to submit the transaction, the safeAddress was not found')
return
}
if (isSpendingLimit && txToken && tx.tokenSpendingLimit) {
const spendingLimit = getSpendingLimitContract()
spendingLimit.methods
.executeAllowanceTransfer(
safeAddress,
sameAddress(txToken.address, nativeCoin.address) ? ZERO_ADDRESS : txToken.address,
tx.recipientAddress,
toTokenUnit(tx.amount, txToken.decimals),
ZERO_ADDRESS,
0,
tx.tokenSpendingLimit.delegate,
EMPTY_DATA,
)
.send({ from: tx.tokenSpendingLimit.delegate })
.on('transactionHash', () => onClose())
.catch(console.error)
} else {
dispatch(
createTransaction({
safeAddress: safeAddress,
@ -114,10 +140,8 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
}),
)
} else {
console.error('There was an error trying to submit the transaction, the safeAddress was not found')
onClose()
}
onClose()
}
return (

View File

@ -25,6 +25,7 @@ import { NFTToken } from 'src/logic/collectibles/sources/collectibles.d'
import { getExplorerInfo } from 'src/config'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { sm } from 'src/theme/variables'
import { sameString } from 'src/utils/strings'
import ArrowDown from 'src/routes/safe/components/Balances/SendModal/screens/assets/arrow-down.svg'
@ -170,7 +171,7 @@ const SendCollectible = ({
{selectedEntry && selectedEntry.address ? (
<div
onKeyDown={(e) => {
if (e.key === 'Tab') {
if (sameString(e.key, 'Tab')) {
return
}
setSelectedEntry({ address: '', name: '' })

View File

@ -0,0 +1,58 @@
import { RadioButtons, Text } from '@gnosis.pm/safe-react-components'
import { BigNumber } from 'bignumber.js'
import React, { ReactElement, useMemo } from 'react'
import { useForm } from 'react-final-form'
import styled from 'styled-components'
import Field from 'src/components/forms/Field'
import Col from 'src/components/layout/Col'
import Row from 'src/components/layout/Row'
import { SpendingLimit } from 'src/logic/safe/store/models/safe'
import { Token } from 'src/logic/tokens/store/model/token'
import { fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
// TODO: propose refactor in safe-react-components based on this requirements
const SpendingLimitRadioButtons = styled(RadioButtons)`
& .MuiRadio-colorPrimary.Mui-checked {
color: ${({ theme }) => theme.colors.primary};
}
`
interface SpendingLimitRowProps {
tokenSpendingLimit: SpendingLimit
selectedToken: Token
}
export const SpendingLimitRow = ({ tokenSpendingLimit, selectedToken }: SpendingLimitRowProps): ReactElement => {
const availableAmount = useMemo(() => {
return fromTokenUnit(
new BigNumber(tokenSpendingLimit.amount).minus(tokenSpendingLimit.spent).toString(),
selectedToken.decimals,
)
}, [selectedToken.decimals, tokenSpendingLimit.amount, tokenSpendingLimit.spent])
const { mutators } = useForm()
return (
<Row margin="sm">
<Col between="lg" style={{ flexDirection: 'column' }}>
<Text size="lg">Send as</Text>
<Field name="txType" initialValue="multiSig">
{({ input: { name, value } }) => (
<SpendingLimitRadioButtons
name={name}
value={value || 'multiSig'}
onRadioChange={mutators.setTxType}
options={[
{ label: 'Multisig Transaction', value: 'multiSig' },
{
label: `Spending Limit Transaction (${availableAmount} ${selectedToken.symbol})`,
value: 'spendingLimit',
},
]}
/>
)}
</Field>
</Col>
</Row>
)
}

View File

@ -1,21 +1,27 @@
import { Text } from '@gnosis.pm/safe-react-components'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import MenuItem from '@material-ui/core/MenuItem'
import { withStyles } from '@material-ui/core/styles'
import React from 'react'
import { selectStyles, selectedTokenStyles } from './style'
import { List } from 'immutable'
import React, { ReactElement } from 'react'
import Field from 'src/components/forms/Field'
import SelectField from 'src/components/forms/SelectField'
import { required } from 'src/components/forms/validator'
import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import { Token } from 'src/logic/tokens/store/model/token'
const SelectedToken = ({ classes, tokenAddress, tokens }) => {
import { useSelectStyles, useSelectedTokenStyles } from './style'
interface SelectTokenProps {
tokenAddress: string
tokens: List<Token>
}
const SelectedToken = ({ tokenAddress, tokens }: SelectTokenProps): ReactElement => {
const classes = useSelectedTokenStyles()
const token = tokens.find(({ address }) => address === tokenAddress)
return (
@ -28,43 +34,52 @@ const SelectedToken = ({ classes, tokenAddress, tokens }) => {
<ListItemText
className={classes.tokenData}
primary={token.name}
secondary={`${formatAmount(token.balance)} ${token.symbol}`}
secondary={`${formatAmount(token.balance?.toString() ?? '0')} ${token.symbol}`}
/>
</>
) : (
<Paragraph color="disabled" size="md" style={{ opacity: 0.5 }} weight="light">
<Text color="placeHolder" size="xl">
Select an asset*
</Paragraph>
</Text>
)}
</MenuItem>
)
}
const SelectedTokenStyled = withStyles(selectedTokenStyles)(SelectedToken)
const TokenSelectField = ({ classes, initialValue, isValid, tokens }) => (
<Field
classes={{ selectMenu: classes.selectMenu }}
className={isValid ? 'isValid' : 'isInvalid'}
component={SelectField}
displayEmpty
initialValue={initialValue}
name="token"
renderValue={(tokenAddress) => <SelectedTokenStyled tokenAddress={tokenAddress} tokens={tokens} />}
validate={required}
>
{tokens.map((token) => (
<MenuItem key={token.address} value={token.address}>
<ListItemIcon>
<Img alt={token.name} height={28} onError={setImageToPlaceholder} src={token.logoUri} />
</ListItemIcon>
<ListItemText
primary={token.name}
secondary={`${formatAmount(token.balance)} ${token.symbol}`}
data-testid={`select-token-${token.name}`}
/>
</MenuItem>
))}
</Field>
)
interface TokenSelectFieldProps {
initialValue?: string
isValid?: boolean
tokens: List<Token>
}
export default withStyles(selectStyles)(TokenSelectField)
const TokenSelectField = ({ initialValue, isValid = true, tokens }: TokenSelectFieldProps): ReactElement => {
const classes = useSelectStyles()
return (
<Field
classes={{ selectMenu: classes.selectMenu }}
className={isValid ? 'isValid' : 'isInvalid'}
component={SelectField}
displayEmpty
initialValue={initialValue}
name="token"
renderValue={(tokenAddress) => <SelectedToken tokenAddress={tokenAddress} tokens={tokens} />}
validate={required}
>
{tokens.map((token) => (
<MenuItem key={token.address} value={token.address}>
<ListItemIcon>
<Img alt={token.name} height={28} onError={setImageToPlaceholder} src={token.logoUri} />
</ListItemIcon>
<ListItemText
primary={token.name}
secondary={`${formatAmount(token.balance?.toString() ?? '0')} ${token.symbol}`}
data-testid={`select-token-${token.name}`}
/>
</MenuItem>
))}
</Field>
)
}
export default TokenSelectField

View File

@ -1,23 +1,29 @@
import { createStyles, makeStyles } from '@material-ui/core'
import { sm } from 'src/theme/variables'
export const selectedTokenStyles = () => ({
container: {
minHeight: '55px',
padding: 0,
width: '100%',
},
tokenData: {
padding: 0,
margin: 0,
lineHeight: '14px',
},
tokenImage: {
marginRight: sm,
},
})
export const useSelectedTokenStyles = makeStyles(
createStyles({
container: {
minHeight: '55px',
padding: 0,
width: '100%',
},
tokenData: {
padding: 0,
margin: 0,
lineHeight: '14px',
},
tokenImage: {
marginRight: sm,
},
}),
)
export const selectStyles = () => ({
selectMenu: {
paddingRight: 0,
},
})
export const useSelectStyles = makeStyles(
createStyles({
selectMenu: {
paddingRight: 0,
},
}),
)

View File

@ -2,11 +2,11 @@ import IconButton from '@material-ui/core/IconButton'
import InputAdornment from '@material-ui/core/InputAdornment'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import { getExplorerInfo, getNetworkInfo } from 'src/config'
import React, { useEffect, useState } from 'react'
import { OnChange } from 'react-final-form-listeners'
import { BigNumber } from 'bignumber.js'
import React, { ReactElement, useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { getExplorerInfo } from 'src/config'
import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField'
@ -22,17 +22,25 @@ import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { getNameFromAddressBook } from 'src/logic/addressBook/utils'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { SpendingLimit } from 'src/logic/safe/store/models/safe'
import { userAccountSelector } from 'src/logic/wallets/store/selectors'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import { AddressBookInput } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
import { SpendingLimitRow } from 'src/routes/safe/components/Balances/SendModal/screens/SendFunds/SpendingLimitRow'
import TokenSelectField from 'src/routes/safe/components/Balances/SendModal/screens/SendFunds/TokenSelectField'
import { fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
import { extendedSafeTokensSelector } from 'src/routes/safe/container/selector'
import { safeSpendingLimitsSelector } from 'src/logic/safe/store/selectors'
import { sm } from 'src/theme/variables'
import { sameString } from 'src/utils/strings'
import ArrowDown from '../assets/arrow-down.svg'
import { styles } from './style'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { spendingLimitAllowedBalance, getSpendingLimitByTokenAddress } from 'src/logic/safe/utils/spendingLimits'
import { getBalanceAndDecimalsFromToken } from 'src/logic/tokens/utils/tokenHelpers'
const formMutators = {
setMax: (args, state, utils) => {
@ -44,10 +52,21 @@ const formMutators = {
setRecipient: (args, state, utils) => {
utils.changeValue(state, 'recipientAddress', () => args[0])
},
setTxType: (args, state, utils) => {
utils.changeValue(state, 'txType', () => args[0])
},
}
const useStyles = makeStyles(styles)
export type SendFundsTx = {
amount?: string
recipientAddress?: string
token?: string
txType?: string
tokenSpendingLimit?: SpendingLimit
}
type SendFundsProps = {
onClose: () => void
onNext: (txInfo: unknown) => void
@ -56,15 +75,7 @@ type SendFundsProps = {
amount?: string
}
const { nativeCoin } = getNetworkInfo()
const SendFunds = ({
onClose,
onNext,
recipientAddress,
selectedToken = '',
amount,
}: SendFundsProps): React.ReactElement => {
const SendFunds = ({ onClose, onNext, recipientAddress, selectedToken = '', amount }: SendFundsProps): ReactElement => {
const classes = useStyles()
const tokens = useSelector(extendedSafeTokensSelector)
const addressBook = useSelector(addressBookSelector)
@ -97,13 +108,42 @@ const SendFunds = ({
}
}, [selectedEntry, pristine])
let tokenSpendingLimit
const handleSubmit = (values) => {
const submitValues = values
// If the input wasn't modified, there was no mutation of the recipientAddress
if (!values.recipientAddress) {
submitValues.recipientAddress = selectedEntry?.address
}
onNext(submitValues)
onNext({ ...submitValues, tokenSpendingLimit })
}
const spendingLimits = useSelector(safeSpendingLimitsSelector)
const currentUser = useSelector(userAccountSelector)
const sendFundsValidation = (values) => {
const { amount, token: tokenAddress, txType } = values ?? {}
if (!amount || !tokenAddress) {
return
}
const isSpendingLimit = tokenSpendingLimit && txType === 'spendingLimit'
const amountValidation = composeValidators(
required,
mustBeFloat,
minValue(0, false),
maxValue(
isSpendingLimit
? spendingLimitAllowedBalance({ tokenAddress, tokenSpendingLimit, tokens })
: getBalanceAndDecimalsFromToken({ tokenAddress, tokens })?.balance ?? 0,
),
)(amount)
return {
amount: amountValidation,
}
}
return (
@ -122,12 +162,19 @@ const SendFunds = ({
formMutators={formMutators}
initialValues={{ amount, recipientAddress, token: selectedToken }}
onSubmit={handleSubmit}
validation={sendFundsValidation}
>
{(...args) => {
const formState = args[2]
const mutators = args[3]
const { token: tokenAddress } = formState.values
const selectedTokenRecord = tokens.find((token) => token.address === tokenAddress)
const { token: tokenAddress, txType } = formState.values
const selectedToken = tokens?.find((token) => token.address === tokenAddress)
const userSpendingLimits = spendingLimits?.filter(({ delegate }) => sameAddress(delegate, currentUser))
tokenSpendingLimit = getSpendingLimitByTokenAddress({
spendingLimits: userSpendingLimits,
tokenAddress: selectedToken?.address,
})
const handleScan = (value, closeQrModal) => {
let scannedAddress = value
@ -149,6 +196,22 @@ const SendFunds = ({
shouldDisableSubmitButton = !selectedEntry.address
}
const setMaxAllowedAmount = () => {
const isSpendingLimit = tokenSpendingLimit && txType === 'spendingLimit'
let maxAmount = selectedToken?.balance ?? 0
if (isSpendingLimit) {
const spendingLimitBalance = fromTokenUnit(
new BigNumber(tokenSpendingLimit.amount).minus(tokenSpendingLimit.spent).toString(),
selectedToken?.decimals ?? 0,
)
maxAmount = new BigNumber(maxAmount).gt(spendingLimitBalance) ? spendingLimitBalance : maxAmount
}
mutators.setMax(maxAmount)
}
return (
<>
<Block className={classes.formContainer}>
@ -164,7 +227,7 @@ const SendFunds = ({
{selectedEntry && selectedEntry.address ? (
<div
onKeyDown={(e) => {
if (e.key === 'Tab') {
if (sameString(e.key, 'Tab')) {
return
}
setSelectedEntry({ address: '', name: '' })
@ -208,22 +271,21 @@ const SendFunds = ({
<Row margin="sm">
<Col>
<TokenSelectField
initialValue={selectedToken}
isValid={tokenAddress && String(tokenAddress).toUpperCase() !== nativeCoin.name.toUpperCase()}
initialValue={selectedToken?.address}
isValid={!!selectedToken?.address}
tokens={tokens}
/>
</Col>
</Row>
{tokenSpendingLimit && selectedToken && (
<SpendingLimitRow selectedToken={selectedToken} tokenSpendingLimit={tokenSpendingLimit} />
)}
<Row margin="xs">
<Col between="lg">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Amount
</Paragraph>
<ButtonLink
onClick={() => mutators.setMax(selectedTokenRecord?.balance)}
weight="bold"
testId="send-max-btn"
>
<ButtonLink onClick={setMaxAllowedAmount} weight="bold" testId="send-max-btn">
Send max
</ButtonLink>
</Col>
@ -232,24 +294,15 @@ const SendFunds = ({
<Col>
<Field
component={TextField}
inputAdornment={
selectedTokenRecord && {
endAdornment: <InputAdornment position="end">{selectedTokenRecord.symbol}</InputAdornment>,
}
}
inputAdornment={{
endAdornment: <InputAdornment position="end">{selectedToken?.symbol}</InputAdornment>,
}}
name="amount"
placeholder="Amount*"
text="Amount*"
type="text"
testId="amount-input"
validate={composeValidators(
required,
mustBeFloat,
minValue(0, false),
maxValue(selectedTokenRecord?.balance || 0),
)}
/>
<OnChange name="token">{() => mutators.onTokenChange()}</OnChange>
</Col>
</Row>
</Block>
@ -262,7 +315,7 @@ const SendFunds = ({
className={classes.submitButton}
color="primary"
data-testid="review-tx-btn"
disabled={shouldDisableSubmitButton}
disabled={!formState.valid || shouldDisableSubmitButton}
minWidth={140}
type="submit"
variant="contained"

View File

@ -0,0 +1,57 @@
import { TextField as SRCTextField } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react'
import { useField } from 'react-final-form'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import GnoField from 'src/components/forms/Field'
import { composeValidators, minValue, mustBeFloat, required } from 'src/components/forms/validator'
import { extendedSafeTokensSelector } from 'src/routes/safe/container/selector'
import { useStyles } from 'src/routes/safe/components/Settings/SpendingLimit/style'
export const Field = styled(GnoField)`
margin: 8px 0;
width: 100%;
`
const AmountInput = styled.div`
grid-area: amountInput;
`
const TextField = styled(SRCTextField)`
margin: 0;
`
const Amount = (): ReactElement => {
const classes = useStyles()
const {
input: { value: tokenAddress },
} = useField('token', { subscription: { value: true } })
const {
meta: { touched, visited },
} = useField('amount', { subscription: { touched: true, visited: true } })
const tokens = useSelector(extendedSafeTokensSelector)
const selectedTokenRecord = tokens.find((token) => token.address === tokenAddress)
const validate = (touched || visited) && composeValidators(required, mustBeFloat, minValue(0, false))
return (
<AmountInput>
<Field
component={TextField}
label="Amount*"
name="amount"
type="text"
data-testid="amount-input"
endAdornment={selectedTokenRecord?.symbol}
className={classes.amountInput}
validate={validate}
/>
</AmountInput>
)
}
export default Amount

View File

@ -0,0 +1,108 @@
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import React, { KeyboardEvent, ReactElement, useEffect, useState } from 'react'
import { useForm, useFormState } from 'react-final-form'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
import { getExplorerInfo } from 'src/config'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { getNameFromAddressBook } from 'src/logic/addressBook/utils'
import { AddressBookInput } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
import { sameString } from 'src/utils/strings'
const BeneficiaryInput = styled.div`
grid-area: beneficiaryInput;
`
const BeneficiaryScan = styled.div`
grid-area: beneficiaryScan;
`
const Beneficiary = (): ReactElement => {
const { initialValues } = useFormState()
const { mutators } = useForm()
const [selectedEntry, setSelectedEntry] = useState<{ address?: string; name?: string } | null>({
address: initialValues?.beneficiary || '',
name: '',
})
const [pristine, setPristine] = useState<boolean>(!initialValues?.beneficiary)
useEffect(() => {
if (selectedEntry === null) {
mutators?.setBeneficiary?.('')
if (pristine) {
setPristine(false)
}
}
}, [mutators, pristine, selectedEntry])
const addressBook = useSelector(addressBookSelector)
const handleScan = (value, closeQrModal) => {
const scannedAddress = value.startsWith('ethereum:') ? value.replace('ethereum:', '') : value
const scannedName = addressBook
? getNameFromAddressBook(addressBook, scannedAddress, { filterOnlyValidName: true }) ?? ''
: ''
mutators?.setBeneficiary?.(scannedAddress)
setSelectedEntry({
name: scannedName,
address: scannedAddress,
})
closeQrModal()
}
const handleOnKeyDown = (e: KeyboardEvent<HTMLElement>): void => {
if (sameString(e.key, 'Tab')) {
return
}
setSelectedEntry(null)
}
const handleOnClick = () => {
setSelectedEntry(null)
}
return selectedEntry?.address ? (
<BeneficiaryInput
role="button"
aria-pressed="false"
tabIndex={0}
onKeyDown={handleOnKeyDown}
onClick={handleOnClick}
>
<EthHashInfo
hash={selectedEntry.address}
name={selectedEntry.name}
showCopyBtn
showIdenticon
textSize="lg"
shortenHash={4}
explorerUrl={getExplorerInfo(selectedEntry.address)}
/>
</BeneficiaryInput>
) : (
<>
<BeneficiaryInput>
<AddressBookInput
fieldMutator={mutators?.setBeneficiary}
pristine={pristine}
setSelectedEntry={setSelectedEntry}
setIsValidAddress={() => {}}
label="Beneficiary"
/>
</BeneficiaryInput>
<BeneficiaryScan>
<ScanQRWrapper handleScan={handleScan} />
</BeneficiaryScan>
</>
)
}
export default Beneficiary

View File

@ -0,0 +1,118 @@
import { RadioButtons, Text } from '@gnosis.pm/safe-react-components'
import { FormControlLabel, hexToRgb, Switch as SwitchMui } from '@material-ui/core'
import React, { ReactElement } from 'react'
import { useField } from 'react-final-form'
import styled from 'styled-components'
import { Field } from 'src/routes/safe/components/Settings/SpendingLimit/FormFields/Amount'
// TODO: propose refactor in safe-react-components based on this requirements
const SpendingLimitRadioButtons = styled(RadioButtons)`
& .MuiRadio-colorPrimary.Mui-checked {
color: ${({ theme }) => theme.colors.primary};
}
`
// TODO: add `name` and `value` to SRC Switch, as they're required for a better RFF integration
const StyledSwitch = styled(({ ...rest }) => <SwitchMui {...rest} />)`
&& {
.MuiIconButton-label,
.MuiSwitch-colorSecondary {
color: ${({ theme }) => theme.colors.icon};
}
.MuiSwitch-colorSecondary.Mui-checked .MuiIconButton-label {
color: ${({ theme }) => theme.colors.primary};
}
.MuiSwitch-colorSecondary.Mui-checked:hover {
background-color: ${({ theme }) => hexToRgb(`${theme.colors.primary}03`)};
}
.Mui-checked + .MuiSwitch-track {
background-color: ${({ theme }) => theme.colors.primaryLight};
}
}
`
interface RadioButtonOption {
label: string
value: string
}
interface RadioButtonProps {
options: RadioButtonOption[]
initialValue: string
groupName: string
}
const SafeRadioButtons = ({ options, initialValue, groupName }: RadioButtonProps): ReactElement => (
<Field name={groupName} initialValue={initialValue}>
{({ input: { name, value, onChange } }) => (
<SpendingLimitRadioButtons name={name} value={value || initialValue} onRadioChange={onChange} options={options} />
)}
</Field>
)
const Switch = ({ label, name }: { label: string; name: string }): ReactElement => (
<FormControlLabel
label={label}
control={
<Field
name={name}
type="checkbox"
render={({ input: { checked, onChange, name, value } }) => (
<StyledSwitch checked={checked} onChange={onChange} name={name} value={value} />
)}
/>
}
/>
)
const ResetTimeLabel = styled.div`
grid-area: resetTimeLabel;
`
const ResetTimeToggle = styled.div`
grid-area: resetTimeToggle;
`
const ResetTimeOptions = styled.div`
grid-area: resetTimeOption;
`
export const RESET_TIME_OPTIONS = [
{ label: '1 day', value: '1' },
{ label: '1 week', value: '7' },
{ label: '1 month', value: '30' },
]
const ResetTime = (): ReactElement => {
const {
input: { value: withResetTime },
} = useField('withResetTime', { subscription: { value: true } })
const switchExplanation = withResetTime ? 'choose reset time period' : 'one time'
return (
<>
<ResetTimeLabel>
<Text size="xl">Set a reset time so the allowance automatically refills after the defined time period.</Text>
</ResetTimeLabel>
<ResetTimeToggle>
<Switch label={`Reset time (${switchExplanation})`} name="withResetTime" />
</ResetTimeToggle>
{withResetTime && (
<ResetTimeOptions>
<SafeRadioButtons
groupName="resetTime"
initialValue={RESET_TIME_OPTIONS[0].value}
options={RESET_TIME_OPTIONS}
/>
</ResetTimeOptions>
)}
</>
)
}
export default ResetTime

View File

@ -0,0 +1,22 @@
import React, { ReactElement } from 'react'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import TokenSelectField from 'src/routes/safe/components/Balances/SendModal/screens/SendFunds/TokenSelectField'
import { extendedSafeTokensSelector } from 'src/routes/safe/container/selector'
const TokenInput = styled.div`
grid-area: tokenInput;
`
const Token = (): ReactElement => {
const tokens = useSelector(extendedSafeTokensSelector)
return (
<TokenInput>
<TokenSelectField tokens={tokens} />
</TokenInput>
)
}
export default Token

View File

@ -0,0 +1,6 @@
import Amount from './Amount'
import Beneficiary from './Beneficiary'
import ResetTime from './ResetTime'
import Token from './Token'
export { Amount, Beneficiary, ResetTime, Token }

View File

@ -0,0 +1,36 @@
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react'
import { useSelector } from 'react-redux'
import { getExplorerInfo } from 'src/config'
import { getNameFromAddressBookSelector } from 'src/logic/addressBook/store/selectors'
import { sameString } from 'src/utils/strings'
import DataDisplay from './DataDisplay'
interface AddressInfoProps {
address: string
cut?: number
title?: string
}
const AddressInfo = ({ address, cut = 4, title }: AddressInfoProps): ReactElement => {
const name = useSelector((state) => getNameFromAddressBookSelector(state, address))
const explorerUrl = getExplorerInfo(address)
return (
<DataDisplay title={title}>
<EthHashInfo
hash={address}
name={sameString(name, 'UNKNOWN') ? undefined : name}
showCopyBtn
showIdenticon
textSize="lg"
explorerUrl={explorerUrl}
shortenHash={cut}
/>
</DataDisplay>
)
}
export default AddressInfo

View File

@ -0,0 +1,20 @@
import { Text } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react'
interface GenericInfoProps {
title?: string
children: React.ReactNode
}
const DataDisplay = ({ title, children }: GenericInfoProps): ReactElement => (
<>
{title && (
<Text size="lg" color="secondaryLight">
{title}
</Text>
)}
{children}
</>
)
export default DataDisplay

View File

@ -0,0 +1,29 @@
import { IconText, Text } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react'
import Row from 'src/components/layout/Row'
import DataDisplay from './DataDisplay'
interface ResetTimeInfoProps {
title?: string
label?: string
}
const ResetTimeInfo = ({ title, label }: ResetTimeInfoProps): ReactElement => {
return (
<DataDisplay title={title}>
{label ? (
<Row align="center" margin="md">
<IconText iconSize="md" iconType="fuelIndicator" text={label} textSize="lg" />
</Row>
) : (
<Row align="center" margin="md">
<Text size="lg">One-time spending limit</Text>
</Row>
)}
</DataDisplay>
)
}
export default ResetTimeInfo

View File

@ -0,0 +1,40 @@
import { Text } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react'
import styled from 'styled-components'
import { Token } from 'src/logic/tokens/store/model/token'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import DataDisplay from './DataDisplay'
const StyledImage = styled.img`
width: 32px;
height: 32px;
object-fit: contain;
margin: 0 8px 0 0;
`
const StyledImageName = styled.div`
display: flex;
align-items: center;
`
interface TokenInfoProps {
amount: string
title?: string
token: Token
}
const TokenInfo = ({ amount, title, token }: TokenInfoProps): ReactElement => {
return (
<DataDisplay title={title}>
<StyledImageName>
<StyledImage alt={token.name} onError={setImageToPlaceholder} src={token.logoUri} />
<Text size="lg">
{amount} {token.symbol}
</Text>
</StyledImageName>
</DataDisplay>
)
}
export default TokenInfo

View File

@ -0,0 +1,6 @@
import AddressInfo from './AddressInfo'
import ResetTimeInfo from './ResetTimeInfo'
import TokenInfo from './TokenInfo'
export { AddressInfo, ResetTimeInfo, TokenInfo }
export default './DataDisplay'

View File

@ -0,0 +1,59 @@
import { Text } from '@gnosis.pm/safe-react-components'
import React, { ReactElement, useMemo } from 'react'
import styled from 'styled-components'
import { Token } from 'src/logic/tokens/store/model/token'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import useTokenInfo from 'src/logic/safe/hooks/useTokenInfo'
import { fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
import { useWindowDimensions } from 'src/logic/hooks/useWindowDimensions'
const StyledImage = styled.img`
width: 32px;
height: 32px;
object-fit: contain;
margin: 0 8px 0 0;
`
const StyledImageName = styled.div`
display: flex;
align-items: center;
`
type FormattedAmountsProps = { amount: string; spent: string; tokenInfo?: Token }
type FormattedAmounts = { amount: string; spent: string }
const useFormattedAmounts = ({ amount, spent, tokenInfo }: FormattedAmountsProps): FormattedAmounts | undefined => {
return useMemo(() => {
if (tokenInfo) {
const formattedSpent = formatAmount(fromTokenUnit(spent, tokenInfo.decimals)).toString()
const formattedAmount = formatAmount(fromTokenUnit(amount, tokenInfo.decimals)).toString()
return { amount: formattedAmount, spent: formattedSpent }
}
}, [amount, spent, tokenInfo])
}
interface SpentVsAmountProps {
amount: string
spent: string
tokenAddress: string
}
const SpentVsAmount = ({ amount, spent, tokenAddress }: SpentVsAmountProps): ReactElement | null => {
const { width } = useWindowDimensions()
const showIcon = useMemo(() => width > 1024, [width])
const tokenInfo = useTokenInfo(tokenAddress)
const spentInfo = useFormattedAmounts({ amount, spent, tokenInfo })
return spentInfo && tokenInfo ? (
<StyledImageName>
{showIcon && <StyledImage alt={tokenInfo.name} onError={setImageToPlaceholder} src={tokenInfo.logoUri} />}
<Text size="lg">{`${spentInfo.spent} of ${spentInfo.amount} ${tokenInfo.symbol}`}</Text>
</StyledImageName>
) : null
}
export default SpentVsAmount

View File

@ -0,0 +1,78 @@
import { List } from 'immutable'
import { TableColumn } from 'src/components/Table/types.d'
import { SpendingLimitRow } from 'src/logic/safe/utils/spendingLimits'
import { relativeTime } from 'src/utils/date'
export const SPENDING_LIMIT_TABLE_BENEFICIARY_ID = 'beneficiary'
export const SPENDING_LIMIT_TABLE_SPENT_ID = 'spent'
export const SPENDING_LIMIT_TABLE_RESET_TIME_ID = 'resetTime'
export const SPENDING_LIMIT_TABLE_ACTION_ID = 'action'
export type SpendingLimitTable = {
[SPENDING_LIMIT_TABLE_BENEFICIARY_ID]: string
[SPENDING_LIMIT_TABLE_SPENT_ID]: {
spent: string
amount: string
tokenAddress: string
}
[SPENDING_LIMIT_TABLE_RESET_TIME_ID]: {
relativeTime: string
lastResetMin: string
resetTimeMin: string
}
}
export const getSpendingLimitData = (spendingLimits?: SpendingLimitRow[] | null): SpendingLimitTable[] | undefined =>
spendingLimits?.map((spendingLimit) => ({
[SPENDING_LIMIT_TABLE_BENEFICIARY_ID]: spendingLimit.delegate,
[SPENDING_LIMIT_TABLE_SPENT_ID]: {
spent: spendingLimit.spent,
amount: spendingLimit.amount,
tokenAddress: spendingLimit.token,
},
[SPENDING_LIMIT_TABLE_RESET_TIME_ID]: {
relativeTime: relativeTime(spendingLimit.lastResetMin, spendingLimit.resetTimeMin),
lastResetMin: spendingLimit.lastResetMin,
resetTimeMin: spendingLimit.resetTimeMin,
},
}))
export const generateColumns = (): List<TableColumn> => {
const beneficiaryColumn: TableColumn = {
align: 'left',
custom: false,
disablePadding: false,
id: SPENDING_LIMIT_TABLE_BENEFICIARY_ID,
label: 'Beneficiary',
order: false,
}
const spentColumn: TableColumn = {
align: 'left',
custom: false,
disablePadding: false,
id: SPENDING_LIMIT_TABLE_SPENT_ID,
label: 'Spent',
order: false,
}
const resetColumn: TableColumn = {
align: 'left',
custom: false,
disablePadding: false,
id: SPENDING_LIMIT_TABLE_RESET_TIME_ID,
label: 'Reset Time',
order: false,
}
const actionsColumn: TableColumn = {
custom: true,
disablePadding: false,
id: SPENDING_LIMIT_TABLE_ACTION_ID,
label: '',
order: false,
}
return List([beneficiaryColumn, spentColumn, resetColumn, actionsColumn])
}

View File

@ -0,0 +1,110 @@
import { Button, Text } from '@gnosis.pm/safe-react-components'
import TableContainer from '@material-ui/core/TableContainer'
import cn from 'classnames'
import React, { ReactElement, useState } from 'react'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import Row from 'src/components/layout/Row'
import { TableCell, TableRow } from 'src/components/layout/Table'
import Table from 'src/components/Table'
import { AddressInfo } from 'src/routes/safe/components/Settings/SpendingLimit/InfoDisplay'
import RemoveLimitModal from 'src/routes/safe/components/Settings/SpendingLimit/RemoveLimitModal'
import { useStyles } from 'src/routes/safe/components/Settings/SpendingLimit/style'
import { grantedSelector } from 'src/routes/safe/container/selector'
import {
generateColumns,
SPENDING_LIMIT_TABLE_BENEFICIARY_ID,
SPENDING_LIMIT_TABLE_RESET_TIME_ID,
SPENDING_LIMIT_TABLE_SPENT_ID,
SpendingLimitTable,
} from './dataFetcher'
import SpentVsAmount from './SpentVsAmount'
const TableActionButton = styled(Button)`
background-color: transparent;
padding: 0;
&:hover {
background-color: transparent;
}
`
interface SpendingLimitTableProps {
data?: SpendingLimitTable[]
}
const LimitsTable = ({ data }: SpendingLimitTableProps): ReactElement => {
const classes = useStyles()
const granted = useSelector(grantedSelector)
const columns = generateColumns()
const autoColumns = columns.filter(({ custom }) => !custom)
const [selectedRow, setSelectedRow] = useState<SpendingLimitTable>()
return (
<>
<TableContainer style={{ minHeight: '420px' }}>
<Table
columns={columns}
data={data}
defaultFixed
defaultOrderBy={SPENDING_LIMIT_TABLE_BENEFICIARY_ID}
defaultRowsPerPage={5}
label="Spending Limits"
noBorder
size={data?.length}
>
{(sortedData) =>
sortedData.map((row, index) => (
<TableRow
className={cn(classes.hide, index >= 3 && index === sortedData.size - 1 && classes.noBorderBottom)}
data-testid="spending-limit-table-row"
key={index}
tabIndex={-1}
>
{autoColumns.map((column, index) => {
const columnId = column.id
const rowElement = row[columnId]
return (
<TableCell align={column.align} component="td" key={`${columnId}-${index}`}>
{columnId === SPENDING_LIMIT_TABLE_BENEFICIARY_ID && <AddressInfo address={rowElement} />}
{columnId === SPENDING_LIMIT_TABLE_SPENT_ID && <SpentVsAmount {...rowElement} />}
{columnId === SPENDING_LIMIT_TABLE_RESET_TIME_ID && (
<Text size="lg">{rowElement.relativeTime}</Text>
)}
</TableCell>
)
})}
<TableCell component="td">
<Row align="end" className={classes.actions}>
{granted && (
<TableActionButton
size="md"
iconType="delete"
color="error"
variant="outlined"
onClick={() => setSelectedRow(row)}
data-testid="remove-action"
>
{null}
</TableActionButton>
)}
</Row>
</TableCell>
</TableRow>
))
}
</Table>
</TableContainer>
{selectedRow && (
<RemoveLimitModal onClose={() => setSelectedRow(undefined)} spendingLimit={selectedRow} open={true} />
)}
</>
)
}
export default LimitsTable

View File

@ -0,0 +1,102 @@
import { Icon, Text, Title } from '@gnosis.pm/safe-react-components'
import React, { ReactElement, ReactNode, ReactNodeArray } from 'react'
import styled from 'styled-components'
import GnoModal from 'src/components/Modal'
import { useStyles } from 'src/routes/safe/components/Settings/SpendingLimit/style'
const TitleSection = styled.div`
display: flex;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 2px solid ${({ theme }) => theme.colors.separator};
`
const StyledButton = styled.button`
background: none;
border: none;
padding: 5px;
width: 26px;
height: 26px;
span {
margin-right: 0;
}
:hover {
background: ${({ theme }) => theme.colors.separator};
border-radius: 16px;
cursor: pointer;
}
`
const FooterSection = styled.div`
border-top: 2px solid ${({ theme }) => theme.colors.separator};
padding: 16px 24px;
`
const FooterWrapper = styled.div`
display: flex;
justify-content: space-around;
`
export interface TopBarProps {
title: string
titleNote?: string
onClose: () => void
}
const TopBar = ({ title, titleNote, onClose }: TopBarProps): ReactElement => (
<TitleSection>
<Title size="xs" withoutMargin>
{title}
{titleNote && (
<>
{' '}
<Text size="lg" color="secondaryLight" as="span">
{titleNote}
</Text>
</>
)}
</Title>
<StyledButton onClick={onClose}>
<Icon size="sm" type="cross" />
</StyledButton>
</TitleSection>
)
interface FooterProps {
children: ReactNodeArray
}
const Footer = ({ children }: FooterProps): ReactElement => (
<FooterSection>
<FooterWrapper>{children}</FooterWrapper>
</FooterSection>
)
export interface ModalProps {
children: ReactNode
description: string
handleClose: () => void
open: boolean
title: string
}
// TODO: this is a potential proposal for `safe-react-components` Modal
// By being able to combine components for better flexibility, this way Buttons can be part of the form body
const Modal = ({ children, ...props }: ModalProps): ReactElement => {
const classes = useStyles()
return (
<GnoModal {...props} paperClassName={classes.modal}>
{children}
</GnoModal>
)
}
Modal.TopBar = TopBar
Modal.Footer = Footer
export default Modal

View File

@ -0,0 +1,96 @@
import { Button } from '@gnosis.pm/safe-react-components'
import { FormState, Mutator } from 'final-form'
import React, { ReactElement } from 'react'
import styled from 'styled-components'
import GnoForm from 'src/components/forms/GnoForm'
import GnoButton from 'src/components/layout/Button'
import { Amount, Beneficiary, ResetTime, Token } from 'src/routes/safe/components/Settings/SpendingLimit/FormFields'
import Modal from 'src/routes/safe/components/Settings/SpendingLimit/Modal'
const FormContainer = styled.div`
padding: 24px 8px 24px 24px;
align-items: center;
display: grid;
grid-template-columns: 4fr 1fr;
grid-template-rows: 6fr;
gap: 16px 8px;
grid-template-areas:
'beneficiaryInput beneficiaryScan'
'tokenInput .'
'amountInput .'
'resetTimeLabel resetTimeLabel'
'resetTimeToggle resetTimeToggle'
'resetTimeOption resetTimeOption';
`
const YetAnotherButton = styled(GnoButton)`
&.Mui-disabled {
background-color: ${({ theme }) => theme.colors.primary};
color: ${({ theme }) => theme.colors.white};
opacity: 0.5;
}
`
const formMutators: Record<string, Mutator<{ beneficiary: { name: string } }>> = {
setBeneficiary: (args, state, utils) => {
utils.changeValue(state, 'beneficiary', () => args[0])
},
}
interface NewSpendingLimitProps {
initialValues?: Record<string, string>
onCancel: () => void
onReview: (values) => void
}
const canReview = ({
invalid,
submitting,
dirtyFieldsSinceLastSubmit,
values: { beneficiary, token, amount },
}: FormState<{ beneficiary: string; token: string; amount: string }>): boolean =>
!(submitting || invalid || !beneficiary || !token || !amount || !dirtyFieldsSinceLastSubmit)
const Create = ({ initialValues, onCancel, onReview }: NewSpendingLimitProps): ReactElement => {
return (
<>
<Modal.TopBar title="New Spending Limit" titleNote="1 of 2" onClose={onCancel} />
<GnoForm formMutators={formMutators} onSubmit={onReview} initialValues={initialValues}>
{(...args) => {
return (
<>
<FormContainer>
<Beneficiary />
<Token />
<Amount />
<ResetTime />
</FormContainer>
<Modal.Footer>
<Button color="primary" size="md" onClick={onCancel}>
Cancel
</Button>
{/* TODO: replace this with safe-react-components button. */}
{/* This is used as "submit" SRC Button does not triggers submission up until the 2nd click */}
<YetAnotherButton
color="primary"
size="medium"
variant="contained"
type="submit"
disabled={!canReview(args[2])}
>
Review
</YetAnotherButton>
</Modal.Footer>
</>
)
}}
</GnoForm>
</>
)
}
export default Create

View File

@ -0,0 +1,189 @@
import { Button, Text } from '@gnosis.pm/safe-react-components'
import React, { ReactElement, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import Row from 'src/components/layout/Row'
import { getNetworkInfo } from 'src/config'
import createTransaction from 'src/logic/safe/store/actions/createTransaction'
import { SafeRecordProps, SpendingLimit } from 'src/logic/safe/store/models/safe'
import {
addSpendingLimitBeneficiaryMultiSendTx,
currentMinutes,
enableSpendingLimitModuleMultiSendTx,
setSpendingLimitMultiSendTx,
setSpendingLimitTx,
spendingLimitMultiSendTx,
SpendingLimitRow,
} from 'src/logic/safe/utils/spendingLimits'
import { MultiSendTx } from 'src/logic/safe/utils/upgradeSafe'
import { makeToken, Token } from 'src/logic/tokens/store/model/token'
import { fromTokenUnit, toTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
import { sameAddress, ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { RESET_TIME_OPTIONS } from 'src/routes/safe/components/Settings/SpendingLimit/FormFields/ResetTime'
import { AddressInfo, ResetTimeInfo, TokenInfo } from 'src/routes/safe/components/Settings/SpendingLimit/InfoDisplay'
import Modal from 'src/routes/safe/components/Settings/SpendingLimit/Modal'
import { useStyles } from 'src/routes/safe/components/Settings/SpendingLimit/style'
import { safeParamAddressFromStateSelector, safeSpendingLimitsSelector } from 'src/logic/safe/store/selectors'
import { ActionCallback, CREATE } from '.'
const { nativeCoin } = getNetworkInfo()
const useExistentSpendingLimit = ({
spendingLimits,
txToken,
values,
}: {
spendingLimits?: SafeRecordProps['spendingLimits']
txToken: Token
values: ReviewSpendingLimitProps['values']
}) => {
// undefined: before setting a value
// null: if no previous value
// SpendingLimit: if previous value exists
return useMemo<SpendingLimit | null>(() => {
// if `delegate` already exist, check what tokens were delegated to the _beneficiary_ `getTokens(safe, delegate)`
const currentDelegate = spendingLimits?.find(
({ delegate, token }) =>
sameAddress(delegate, values.beneficiary) &&
sameAddress(token, sameAddress(values.token, nativeCoin.address) ? ZERO_ADDRESS : values.token),
)
// let the user know that is about to replace an existent allowance
if (currentDelegate !== undefined) {
return {
...currentDelegate,
amount: fromTokenUnit(currentDelegate.amount, txToken.decimals),
}
} else {
return null
}
}, [spendingLimits, txToken.decimals, values.beneficiary, values.token])
}
interface ReviewSpendingLimitProps {
onBack: ActionCallback
onClose: () => void
txToken: Token
values: Record<string, string>
existentSpendingLimit?: SpendingLimitRow
}
const Review = ({ onBack, onClose, txToken, values }: ReviewSpendingLimitProps): ReactElement => {
const classes = useStyles()
const dispatch = useDispatch()
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const spendingLimits = useSelector(safeSpendingLimitsSelector)
const existentSpendingLimit = useExistentSpendingLimit({ spendingLimits, txToken, values })
const handleSubmit = () => {
const isSpendingLimitEnabled = spendingLimits !== null
const transactions: MultiSendTx[] = []
// is spendingLimit module enabled? -> if not, create the tx to enable it, and encode it
if (!isSpendingLimitEnabled && safeAddress) {
transactions.push(enableSpendingLimitModuleMultiSendTx(safeAddress))
}
// does `delegate` already exist? (`getDelegates`, previously queried to build the table with allowances (??))
// ^ - shall we rely on this or query the list of delegates once again?
const isDelegateAlreadyAdded =
spendingLimits?.some(({ delegate }) => sameAddress(delegate, values?.beneficiary)) ?? false
// if `delegate` does not exist, add it by calling `addDelegate(beneficiary)`
if (!isDelegateAlreadyAdded && values?.beneficiary) {
transactions.push(addSpendingLimitBeneficiaryMultiSendTx(values.beneficiary))
}
// prepare the setAllowance tx
const startTime = currentMinutes() - 30
const spendingLimitArgs = {
beneficiary: values.beneficiary,
token: values.token,
spendingLimitInWei: toTokenUnit(values.amount, txToken.decimals),
resetTimeMin: values.withResetTime ? +values.resetTime * 60 * 24 : 0,
resetBaseMin: values.withResetTime ? startTime : 0,
}
if (safeAddress) {
// if there's no tx for enable module or adding a delegate, then we avoid using multiSend Tx
if (transactions.length === 0) {
dispatch(createTransaction(setSpendingLimitTx({ spendingLimitArgs, safeAddress })))
} else {
transactions.push(setSpendingLimitMultiSendTx({ spendingLimitArgs, safeAddress }))
dispatch(createTransaction(spendingLimitMultiSendTx({ transactions, safeAddress })))
}
}
}
const resetTimeLabel = useMemo(
() => (values.withResetTime ? RESET_TIME_OPTIONS.find(({ value }) => value === values.resetTime)?.label : ''),
[values.resetTime, values.withResetTime],
)
const previousResetTime = (existentSpendingLimit: SpendingLimit) =>
RESET_TIME_OPTIONS.find(({ value }) => value === (+existentSpendingLimit.resetTimeMin / 60 / 24).toString())
?.label ?? 'One-time spending limit'
return (
<>
<Modal.TopBar title="New Spending Limit" titleNote="2 of 2" onClose={onClose} />
<Block className={classes.container}>
<Col margin="lg">
<AddressInfo address={values.beneficiary} title="Beneficiary" />
</Col>
<Col margin="lg">
<TokenInfo
amount={fromTokenUnit(toTokenUnit(values.amount, txToken.decimals), txToken.decimals)}
title="Amount"
token={txToken}
/>
{existentSpendingLimit && (
<Text size="lg" color="error">
Previous Amount: {existentSpendingLimit.amount}
</Text>
)}
</Col>
<Col margin="lg">
<ResetTimeInfo title="Reset Time" label={resetTimeLabel} />
{existentSpendingLimit && (
<Row align="center" margin="md">
<Text size="lg" color="error">
Previous Reset Time: {previousResetTime(existentSpendingLimit)}
</Text>
</Row>
)}
</Col>
{existentSpendingLimit && (
<Text size="xl" color="error" center strong>
You are about to replace an existent spending limit
</Text>
)}
</Block>
<Modal.Footer>
<Button color="primary" size="md" onClick={() => onBack({ values: {}, txToken: makeToken(), step: CREATE })}>
Back
</Button>
<Button
color="primary"
size="md"
variant="contained"
onClick={handleSubmit}
disabled={existentSpendingLimit === undefined}
>
Submit
</Button>
</Modal.Footer>
</>
)
}
export default Review

View File

@ -0,0 +1,106 @@
import { List } from 'immutable'
import React, { ReactElement, Reducer, useCallback, useReducer } from 'react'
import { useSelector } from 'react-redux'
import { makeToken, Token } from 'src/logic/tokens/store/model/token'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { extendedSafeTokensSelector } from 'src/routes/safe/container/selector'
import Modal from 'src/routes/safe/components/Settings/SpendingLimit/Modal'
import Create from './Create'
import Review from './Review'
export const CREATE = 'CREATE' as const
export const REVIEW = 'REVIEW' as const
type Step = typeof CREATE | typeof REVIEW
type State = {
step: Step
values: Record<string, string>
txToken: Token
}
type Action = {
type: Step
newState: State
tokens: List<Token>
}
const newLimitModalReducer = (state: State, action: Action): State => {
const { type, newState, tokens } = action
switch (type) {
case CREATE: {
return {
...state,
step: CREATE,
}
}
case REVIEW: {
return {
...state,
...newState,
// we lookup into the list of tokens for the selected token info
txToken: tokens.find((token) => sameAddress(token.address, newState.values.token)) ?? state.txToken,
step: REVIEW,
}
}
}
}
export type ActionCallback = (state: State) => void
type NewLimitModalHook = [State, { create: ActionCallback; review: ActionCallback }]
const useNewLimitModal = (initialStep: Step): NewLimitModalHook => {
// globally stored tokens
const tokens = useSelector(extendedSafeTokensSelector)
// setup the reducer with initial values
const [state, dispatch] = useReducer<Reducer<State, Action>, State>(
newLimitModalReducer,
{
step: initialStep,
txToken: makeToken(),
values: {},
},
(state) => state,
)
// define actions
const create = useCallback<ActionCallback>((newState) => dispatch({ type: CREATE, newState, tokens }), [tokens])
const review = useCallback<ActionCallback>((newState) => dispatch({ type: REVIEW, newState, tokens }), [tokens])
// returns state and dispatch
return [state, { create, review }]
}
interface SpendingLimitModalProps {
close: () => void
open: boolean
}
const NewLimitModal = ({ close, open }: SpendingLimitModalProps): ReactElement => {
// state and dispatch
const [{ step, txToken, values }, { create, review }] = useNewLimitModal(CREATE)
const handleReview = async (values) => {
// if form is valid, we update the state to REVIEW and sets values
review({ step, txToken, values })
}
return (
<Modal
handleClose={close}
open={open}
title="New Spending Limit"
description="set rules for specific beneficiaries to access funds from this Safe without having to collect all signatures"
>
{step === CREATE && <Create initialValues={values} onCancel={close} onReview={handleReview} />}
{step === REVIEW && <Review onBack={create} onClose={close} txToken={txToken} values={values} />}
</Modal>
)
}
export default NewLimitModal

View File

@ -0,0 +1,79 @@
import { Text } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react'
import styled from 'styled-components'
import Img from 'src/components/layout/Img'
import AssetAmount from './assets/asset-amount.svg'
import Beneficiary from './assets/beneficiary.svg'
import Time from './assets/time.svg'
const StepWrapper = styled.div`
display: flex;
justify-content: space-around;
margin-top: 20px;
max-width: 720px;
text-align: center;
`
const Step = styled.div`
width: 24%;
min-width: 120px;
max-width: 164px;
`
const StepsLine = styled.div`
height: 2px;
flex: 1;
background: #d4d5d3;
margin: 46px 0;
`
const NewLimitSteps = (): ReactElement => (
<StepWrapper>
<Step>
<Img alt="Select Beneficiary" title="Beneficiary" height={96} src={Beneficiary} />
<Text size="lg" color="placeHolder" strong center>
Select Beneficiary
</Text>
<Text size="lg" color="placeHolder" center>
Choose an account that will benefit from this allowance.
</Text>
<Text size="lg" color="placeHolder" center>
The beneficiary does not have to be an owner of this Safe
</Text>
</Step>
<StepsLine />
<Step>
<Img alt="Select asset and amount" title="Asset and Amount" height={96} src={AssetAmount} />
<Text size="lg" color="placeHolder" strong center>
Select asset and amount
</Text>
<Text size="lg" color="placeHolder" center>
You can set a spending limit for any asset stored in your Safe
</Text>
</Step>
<StepsLine />
<Step>
<Img alt="Select time" title="Time" height={96} src={Time} />
<Text size="lg" color="placeHolder" strong center>
Select time
</Text>
<Text size="lg" color="placeHolder" center>
You can choose to set a one-time spending limit or to have it automatically refill after a defined time-period
</Text>
</Step>
</StepWrapper>
)
export default NewLimitSteps

View File

@ -0,0 +1,102 @@
import { Button } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import useTokenInfo from 'src/logic/safe/hooks/useTokenInfo'
import createTransaction from 'src/logic/safe/store/actions/createTransaction'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { getDeleteAllowanceTxData } from 'src/logic/safe/utils/spendingLimits'
import { fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
import { SPENDING_LIMIT_MODULE_ADDRESS } from 'src/utils/constants'
import { RESET_TIME_OPTIONS } from './FormFields/ResetTime'
import { AddressInfo, ResetTimeInfo, TokenInfo } from './InfoDisplay'
import { SpendingLimitTable } from './LimitsTable/dataFetcher'
import Modal from './Modal'
import { useStyles } from './style'
interface RemoveSpendingLimitModalProps {
onClose: () => void
spendingLimit: SpendingLimitTable
open: boolean
}
const RemoveLimitModal = ({ onClose, spendingLimit, open }: RemoveSpendingLimitModalProps): ReactElement => {
const classes = useStyles()
const tokenInfo = useTokenInfo(spendingLimit.spent.tokenAddress)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const dispatch = useDispatch()
const removeSelectedSpendingLimit = async (): Promise<void> => {
try {
const {
beneficiary,
spent: { tokenAddress },
} = spendingLimit
const txData = getDeleteAllowanceTxData({ beneficiary, tokenAddress })
dispatch(
createTransaction({
safeAddress,
to: SPENDING_LIMIT_MODULE_ADDRESS,
valueInWei: '0',
txData,
notifiedTransaction: TX_NOTIFICATION_TYPES.REMOVE_SPENDING_LIMIT_TX,
}),
)
} catch (e) {
console.error(
`failed to remove spending limit ${spendingLimit.beneficiary} -> ${spendingLimit.spent.tokenAddress}`,
e.message,
)
}
}
const resetTimeLabel =
RESET_TIME_OPTIONS.find(({ value }) => +value === +spendingLimit.resetTime.resetTimeMin / 24 / 60)?.label ?? ''
return (
<Modal
handleClose={onClose}
open={open}
title="Remove Spending Limit"
description="Remove the selected Spending Limit"
>
<Modal.TopBar title="Remove Spending Limit" onClose={onClose} />
<Block className={classes.container}>
<Col margin="lg">
<AddressInfo title="Beneficiary" address={spendingLimit.beneficiary} />
</Col>
<Col margin="lg">
{tokenInfo && (
<TokenInfo
amount={fromTokenUnit(spendingLimit.spent.amount, tokenInfo.decimals)}
title="Amount"
token={tokenInfo}
/>
)}
</Col>
<Col margin="lg">
<ResetTimeInfo title="Reset Time" label={resetTimeLabel} />
</Col>
</Block>
<Modal.Footer>
<Button size="md" color="secondary" onClick={onClose}>
Cancel
</Button>
<Button color="error" size="md" variant="contained" onClick={removeSelectedSpendingLimit}>
Remove
</Button>
</Modal.Footer>
</Modal>
)
}
export default RemoveLimitModal

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="96" viewBox="0 0 108 96">
<g fill="none" fill-rule="evenodd">
<path d="M0 0H108V96H0z" opacity=".557"/>
<path fill="#F7F5F5" d="M54 4c25.405 0 46 20.595 46 46S79.405 96 54 96 8 75.405 8 50 28.595 4 54 4"/>
<path fill="#B2B5B2" d="M73.32 49.744c-9.838 0-17.842 8.004-17.842 17.843 0 9.84 8.004 17.843 17.842 17.843 9.84 0 17.843-8.004 17.843-17.843S83.16 49.744 73.32 49.744m0 39.686c-12.044 0-21.842-9.799-21.842-21.843s9.798-21.843 21.842-21.843c12.044 0 21.843 9.8 21.843 21.843 0 12.044-9.799 21.843-21.843 21.843M35.657 65.261L22.471 43.332l12.178 7.108c.312.181.659.272 1.008.272s.696-.091 1.008-.272l12.177-7.108-13.185 21.929zm0-59.002l16.291 30.629-16.291 9.509-16.292-9.509L35.657 6.259zm20.94 31.628c.011-.082.011-.164.011-.246.001-.062.004-.122 0-.183l-.008-.045c-.033-.34-.15-.67-.348-.953L37.423 1.061C37.076.408 36.396 0 35.657 0c-.739 0-1.419.408-1.766 1.061L15.061 36.46c-.199.283-.315.613-.349.953l-.006.045c-.005.061-.002.121-.001.183-.001.082.001.164.011.246l.002.01c.045.36.186.704.416.992l18.809 31.283c.361.602 1.012.97 1.714.97s1.353-.368 1.714-.97L56.18 38.889c.229-.288.37-.632.415-.992l.002-.01z"/>
<path fill="#B2B5B2" d="M85.407 65.587H75.32V55.501c0-1.104-.895-2-2-2-1.104 0-2 .896-2 2v10.086H61.234c-1.104 0-2 .896-2 2s.896 2 2 2H71.32v10.087c0 1.104.896 2 2 2 1.105 0 2-.896 2-2V69.587h10.087c1.104 0 2-.896 2-2s-.896-2-2-2"/>
<path fill="#008C73" d="M89.737 19.335H79.651V9.248c0-1.104-.895-2-2-2-1.104 0-2 .896-2 2v10.087H65.564c-1.104 0-2 .896-2 2s.896 2 2 2h10.087v10.086c0 1.104.896 2 2 2 1.105 0 2-.896 2-2V23.335h10.086c1.104 0 2-.896 2-2s-.896-2-2-2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="96" viewBox="0 0 108 96">
<g fill="none" fill-rule="evenodd">
<path d="M0 0H108V96H0z" opacity=".557"/>
<path fill="#F7F5F5" d="M54 4c25.405 0 46 20.595 46 46S79.405 96 54 96 8 75.405 8 50 28.595 4 54 4"/>
<g>
<path fill="#008C73" d="M83.653 43.747h-7.77v-7.77c0-1.103-.894-2-2-2-1.103 0-2 .897-2 2v7.77h-7.766c-1.105 0-2 .896-2 2s.895 2 2 2h7.767v7.768c0 1.104.896 2 2 2 1.105 0 2-.896 2-2v-7.768h7.769c1.104 0 2-.896 2-2s-.896-2-2-2" transform="translate(5 24)"/>
<path fill="#008C73" d="M42.12 16.726c0-3.675 2.99-6.665 6.665-6.665s6.665 2.99 6.665 6.665-2.99 6.664-6.665 6.664-6.664-2.989-6.664-6.664m26.26 20.668c-3.41-6.652-8.073-10.436-14.173-11.5 3.132-1.86 5.242-5.269 5.242-9.168 0-5.88-4.784-10.665-10.665-10.665-5.88 0-10.664 4.784-10.664 10.665 0 4.317 2.584 8.035 6.282 9.711-9.101 2.803-12.981 11.934-15.353 20.066-.31 1.06.3 2.17 1.36 2.48.187.055.375.081.56.081.867 0 1.665-.567 1.92-1.442 3.69-12.652 8.945-18.05 17.57-18.053 6.424.001 10.987 3.067 14.362 9.65.502.983 1.706 1.372 2.692.866.983-.504 1.37-1.709.867-2.69" transform="translate(5 24)"/>
<path fill="#B2B5B2" d="M21.496 17.329c-3.675 0-6.665-2.99-6.665-6.665S17.82 4 21.496 4s6.664 2.989 6.664 6.664-2.99 6.665-6.664 6.665m14.07 15.289c.872-1.088 1.829-2.099 2.908-2.984-3.065-5.177-7.032-8.329-12.003-9.544 3.379-1.791 5.689-5.342 5.689-9.426C32.16 4.784 27.376 0 21.496 0 15.615 0 10.83 4.784 10.83 10.664c0 4.077 2.302 7.623 5.672 9.418C6.632 22.477 2.543 31.998.08 40.442c-.31 1.06.3 2.17 1.36 2.48.187.054.375.081.56.081.867 0 1.665-.568 1.92-1.442 3.69-12.652 8.945-18.052 17.57-18.054 6.228.001 10.729 2.906 14.075 9.111M76.083 17.329c-3.675 0-6.664-2.99-6.664-6.665S72.408 4 76.083 4s6.664 2.989 6.664 6.664-2.99 6.665-6.664 6.665M97.49 40.442c-2.462-8.443-6.55-17.962-16.417-20.359 3.37-1.794 5.674-5.341 5.674-9.419C86.747 4.784 81.963 0 76.083 0S65.419 4.784 65.419 10.664c0 4.078 2.303 7.625 5.674 9.419-4.448 1.082-8.075 3.714-10.981 7.955 1.135.665 2.203 1.464 3.205 2.404 3.18-4.716 7.27-6.934 12.763-6.935 8.625.002 13.88 5.402 17.57 18.054.255.874 1.053 1.442 1.919 1.442.186 0 .374-.027.56-.081 1.06-.31 1.67-1.42 1.36-2.48" transform="translate(5 24)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="96" viewBox="0 0 108 96">
<g fill="none" fill-rule="evenodd">
<path d="M0 0H108V96H0z" opacity=".557"/>
<path fill="#F7F5F5" d="M54 4c25.405 0 46 20.595 46 46S79.405 96 54 96C28.596 96 8 75.405 8 50S28.596 4 54 4"/>
<path fill="#B2B5B2" d="M52.737 79.776H24.979l13.879-12.473 13.879 12.473zM32.839 56.74c3.232-1.984 3.326-6.256 3.326-6.739 0-3.526-1.905-5.673-3.07-6.662-.067-.062-.14-.119-.216-.17-2.534-1.716-4.631-3.298-6.381-4.79h24.9c-1.79 1.522-3.945 3.14-6.56 4.91-.07.048-.137.1-.201.156-1.99 1.607-3.085 3.936-3.085 6.556 0 .482.09 4.77 3.164 6.71 14.06 9.556 15.043 15.018 15.027 23.066h-1.022L40.195 63.127c-.76-.684-1.914-.684-2.674 0l-18.526 16.65h-1.136c.028-8.713 1.418-13.848 14.98-23.038zm27.018-36.515c-.017 5.503-.58 9.58-4.41 14.153H22.443c-4.06-4.807-4.518-8.938-4.567-14.153h41.98zm5.276 59.552h-1.396c-.041-9.62-1.642-16.09-16.83-26.412-1.106-.698-1.355-2.713-1.355-3.364 0-1.78.87-2.856 1.6-3.446l.013-.01c15.119-10.257 16.665-16.7 16.692-26.32h1.276c1.104 0 2-.895 2-2 0-1.104-.896-2-2-2H12.585c-1.105 0-2 .896-2 2 0 1.105.895 2 2 2h1.298c.145 9.63 1.767 16.086 16.656 26.19.616.527 1.626 1.682 1.626 3.575-.008.65-.287 2.662-1.437 3.351l-.092.06C15.44 63.69 13.888 70.14 13.859 79.775h-1.274c-1.105 0-2 .896-2 2 0 1.105.895 2 2 2h52.548c1.104 0 2-.895 2-2 0-1.104-.896-2-2-2z"/>
<path fill="#008C73" d="M104.819 35.913l-8.204-1.276c-1.09-.164-2.093.56-2.278 1.634l-1.428 8.243c-.189 1.088.541 2.124 1.629 2.312.116.02.23.029.343.029.956 0 1.8-.685 1.969-1.658l.716-4.135c.822 1.281 1.476 2.673 1.92 4.138 2.647 8.722-2.296 17.972-11.019 20.618-8.721 2.651-17.97-2.296-20.618-11.018-2.647-8.722 2.296-17.972 11.018-20.618 1.569-.476 3.183-.717 4.801-.717 1.104 0 2-.896 2-2 0-1.105-.896-2-2-2-2.01 0-4.016.299-5.962.888-10.833 3.288-16.972 14.776-13.685 25.609 2.687 8.853 10.85 14.571 19.657 14.571 1.97 0 3.972-.286 5.952-.887 10.833-3.287 16.971-14.775 13.684-25.608-.494-1.626-1.189-3.183-2.061-4.631l2.951.459c1.091.164 2.113-.577 2.284-1.669.17-1.092-.578-2.114-1.669-2.284"/>
<path fill="#008C73" d="M76.963 56.121c-.671 0-1.328-.338-1.706-.952-.578-.942-.284-2.173.656-2.752l5.75-3.535v-8.115c0-1.105.895-2 2-2s2 .895 2 2V50c0 .694-.36 1.34-.953 1.704l-6.7 4.121c-.328.2-.69.296-1.047.296"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,72 @@
import { Button, Text, Title } from '@gnosis.pm/safe-react-components'
import React, { ReactElement, useState } from 'react'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import Row from 'src/components/layout/Row'
import { safeSpendingLimitsSelector } from 'src/logic/safe/store/selectors'
import { grantedSelector } from 'src/routes/safe/container/selector'
import LimitsTable from './LimitsTable'
import { getSpendingLimitData } from './LimitsTable/dataFetcher'
import NewLimitModal from './NewLimitModal'
import NewLimitSteps from './NewLimitSteps'
import { useStyles } from './style'
const InfoText = styled(Text)`
margin-top: 16px;
`
const SpendingLimitSettings = (): ReactElement => {
const classes = useStyles()
const granted = useSelector(grantedSelector)
const allowances = useSelector(safeSpendingLimitsSelector)
const spendingLimitData = getSpendingLimitData(allowances)
const [showNewSpendingLimitModal, setShowNewSpendingLimitModal] = useState(false)
const openNewSpendingLimitModal = () => {
setShowNewSpendingLimitModal(true)
}
const closeNewSpendingLimitModal = () => {
setShowNewSpendingLimitModal(false)
}
return (
<>
<Block className={classes.container} grow="grow">
<Title size="xs" withoutMargin>
Spending Limit
</Title>
<InfoText size="lg">
You can set rules for specific beneficiaries to access funds from this Safe without having to collect all
signatures.
</InfoText>
{spendingLimitData?.length ? <LimitsTable data={spendingLimitData} /> : <NewLimitSteps />}
</Block>
{granted && (
<>
<Row align="end" className={classes.buttonRow} grow>
<Col end="xs">
<Button
className={classes.actionButton}
color="primary"
size="md"
data-testid="new-spending-limit-button"
onClick={openNewSpendingLimitModal}
variant="contained"
>
New spending limit
</Button>
</Col>
</Row>
{showNewSpendingLimitModal && <NewLimitModal close={closeNewSpendingLimitModal} open={true} />}
</>
)}
</>
)
}
export default SpendingLimitSettings

View File

@ -0,0 +1,132 @@
import { createStyles, makeStyles } from '@material-ui/core'
import {
background,
boldFont,
border,
error,
fontColor,
lg,
md,
secondaryText,
sm,
smallFontSize,
xl,
} from 'src/theme/variables'
export const useStyles = makeStyles(
createStyles({
title: {
padding: lg,
paddingBottom: 0,
},
hide: {
'&:hover': {
backgroundColor: '#fff3e2',
},
'&:hover $actions': {
visibility: 'initial',
},
},
actions: {
justifyContent: 'flex-end',
visibility: 'hidden',
},
noBorderBottom: {
'& > td': {
borderBottom: 'none',
},
},
annotation: {
paddingLeft: lg,
},
ownersText: {
color: secondaryText,
'& b': {
color: fontColor,
},
},
container: {
padding: lg,
},
actionButton: {
fontWeight: boldFont,
marginRight: sm,
},
buttonRow: {
padding: lg,
position: 'absolute',
left: 0,
bottom: 0,
boxSizing: 'border-box',
width: '100%',
justifyContent: 'flex-end',
borderTop: `2px solid ${border}`,
},
modifyBtn: {
height: xl,
fontSize: smallFontSize,
},
removeModuleIcon: {
marginLeft: lg,
cursor: 'pointer',
},
modalHeading: {
boxSizing: 'border-box',
justifyContent: 'space-between',
maxHeight: '75px',
padding: `${sm} ${lg}`,
},
modalContainer: {
minHeight: '369px',
},
modalManage: {
fontSize: lg,
},
modalClose: {
height: '35px',
width: '35px',
},
modalButtonRow: {
height: '84px',
justifyContent: 'center',
},
modalButtonRemove: {
color: '#fff',
backgroundColor: error,
height: '42px',
},
modalName: {
textOverflow: 'ellipsis',
overflow: 'hidden',
},
modalUserName: {
whiteSpace: 'nowrap',
},
modalOwner: {
backgroundColor: background,
padding: md,
alignItems: 'center',
},
modalUser: {
justifyContent: 'left',
},
modalDescription: {
padding: md,
},
modalOpen: {
paddingLeft: sm,
width: 'auto',
'&:hover': {
cursor: 'pointer',
},
},
modal: {
height: 'auto',
maxWidth: 'calc(100% - 30px)',
overflow: 'hidden',
},
amountInput: {
width: '100% !important',
},
}),
)

View File

@ -7,6 +7,7 @@ import { useState } from 'react'
import { useSelector } from 'react-redux'
import Advanced from './Advanced'
import SpendingLimitSettings from './SpendingLimit'
import ManageOwners from './ManageOwners'
import { RemoveSafeModal } from './RemoveSafeModal'
import SafeDetails from './SafeDetails'
@ -118,12 +119,22 @@ const Settings: React.FC = () => {
</Row>
<Hairline className={classes.hairline} />
<Row className={cn(classes.menuOption, menuOptionIndex === 4 && classes.active)} onClick={handleChange(4)}>
<IconText
iconSize="sm"
textSize="xl"
iconType="fuelIndicator"
text="Spending Limit"
color={menuOptionIndex === 4 ? 'primary' : 'secondary'}
/>
</Row>
<Hairline className={classes.hairline} />
<Row className={cn(classes.menuOption, menuOptionIndex === 5 && classes.active)} onClick={handleChange(5)}>
<IconText
iconSize="sm"
textSize="xl"
iconType="settingsTool"
text="Advanced"
color={menuOptionIndex === 4 ? 'primary' : 'secondary'}
color={menuOptionIndex === 5 ? 'primary' : 'secondary'}
/>
</Row>
<Hairline className={classes.hairline} />
@ -134,7 +145,8 @@ const Settings: React.FC = () => {
{menuOptionIndex === 1 && <SafeDetails />}
{menuOptionIndex === 2 && <ManageOwners addressBook={addressBook} granted={granted} owners={owners} />}
{menuOptionIndex === 3 && <ThresholdSettings />}
{menuOptionIndex === 4 && <Advanced />}
{menuOptionIndex === 4 && <SpendingLimitSettings />}
{menuOptionIndex === 5 && <Advanced />}
</Block>
</Col>
</Block>

View File

@ -91,7 +91,7 @@ const useStyles = makeStyles(styles)
type ownersColumnProps = {
tx: Transaction
cancelTx: Transaction
cancelTx?: Transaction
thresholdReached: boolean
cancelThresholdReached: boolean
onTxConfirm: () => void

View File

@ -7,6 +7,10 @@ import styled from 'styled-components'
import { styles } from './styles'
import Value from './Value'
import Col from 'src/components/layout/Col'
import { RESET_TIME_OPTIONS } from 'src/routes/safe/components/Settings/SpendingLimit/FormFields/ResetTime'
import useTokenInfo from 'src/logic/safe/hooks/useTokenInfo'
import { AddressInfo, ResetTimeInfo, TokenInfo } from 'src/routes/safe/components/Settings/SpendingLimit/InfoDisplay'
import Block from 'src/components/layout/Block'
import {
extractMultiSendDataDecoded,
@ -20,12 +24,13 @@ import { getNameFromAddressBookSelector } from 'src/logic/addressBook/store/sele
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/logic/safe/store/models/types/transaction'
import { DataDecoded } from 'src/routes/safe/store/models/types/transactions.d'
import { Transaction, SafeModuleTransaction } from 'src/logic/safe/store/models/types/transaction'
import { DataDecoded } from 'src/logic/safe/store/models/types/transactions.d'
import DividerLine from 'src/components/DividerLine'
import { isArrayParameter } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
import { getExplorerInfo, getNetworkInfo } from 'src/config'
import { decodeMethods, isDeleteAllowanceMethod, isSetAllowanceMethod } from 'src/logic/contracts/methodIds'
export const TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID = 'tx-description-custom-value'
export const TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID = 'tx-description-custom-data'
@ -47,6 +52,7 @@ const TxDetailsMethodParam = styled.div<{ isArrayParameter: boolean }>`
`
const TxDetailsContent = styled.div`
padding: 8px 8px 8px 16px;
overflow-wrap: break-word;
`
const TxInfo = styled.div`
@ -76,26 +82,103 @@ const TxInfoDetails = ({ data }: { data: DataDecoded }): React.ReactElement => (
</TxInfo>
)
const SpendingLimitDetailsContainer = styled.div`
padding-left: 24px;
`
const spendingLimitTxType = (data: string | null): { isSetSpendingLimit: boolean; isDeleteSpendingLimit: boolean } => ({
isSetSpendingLimit: !!data && isSetAllowanceMethod(data),
isDeleteSpendingLimit: !!data && isDeleteAllowanceMethod(data),
})
interface NewSpendingLimitDetailsProps {
data: DataDecoded
}
const ModifySpendingLimitDetails = ({ data }: NewSpendingLimitDetailsProps): React.ReactElement => {
const [beneficiary, tokenAddress, amount, resetTimeMin] = React.useMemo(
() => data.parameters.map(({ value }) => value),
[data.parameters],
)
const resetTimeLabel = React.useMemo(
() => RESET_TIME_OPTIONS.find(({ value }) => +value === +resetTimeMin / 24 / 60)?.label ?? '',
[resetTimeMin],
)
const tokenInfo = useTokenInfo(tokenAddress)
return (
<>
<TxInfo>
<Bold>Modify Spending Limit:</Bold>
</TxInfo>
<SpendingLimitDetailsContainer>
<Col margin="lg">
<AddressInfo title="Beneficiary" address={beneficiary} />
</Col>
<Col margin="lg">
{tokenInfo && (
<TokenInfo amount={fromTokenUnit(amount, tokenInfo.decimals)} title="Amount" token={tokenInfo} />
)}
</Col>
<Col margin="lg">
<ResetTimeInfo title="Reset Time" label={resetTimeLabel} />
</Col>
</SpendingLimitDetailsContainer>
</>
)
}
const DeleteSpendingLimitDetails = ({ data }: NewSpendingLimitDetailsProps): React.ReactElement => {
const [beneficiary, tokenAddress] = React.useMemo(() => data.parameters.map(({ value }) => value), [data.parameters])
const tokenInfo = useTokenInfo(tokenAddress)
return (
<>
<TxInfo>
<Bold>Delete Spending Limit:</Bold>
</TxInfo>
<SpendingLimitDetailsContainer>
<Col margin="lg">
<AddressInfo title="Beneficiary" address={beneficiary} />
</Col>
<Col margin="lg">{tokenInfo && <TokenInfo amount="" title="Token" token={tokenInfo} />}</Col>
</SpendingLimitDetailsContainer>
</>
)
}
const MultiSendCustomDataAction = ({ tx, order }: { tx: MultiSendDetails; order: number }): React.ReactElement => {
const classes = useStyles()
const methodName = tx.data?.method ? ` (${tx.data.method})` : ''
const methodName = tx.dataDecoded?.method ? ` (${tx.dataDecoded.method})` : ''
const data = tx.dataDecoded ?? decodeMethods(tx.data)
const explorerUrl = getExplorerInfo(tx.to)
const { isSetSpendingLimit, isDeleteSpendingLimit } = spendingLimitTxType(tx.data)
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 {fromTokenUnit(tx.value, nativeCoin.decimals)} {nativeCoin.name} to:
</Bold>
<EthHashInfo hash={tx.to} showIdenticon showCopyBtn explorerUrl={explorerUrl} />
</TxInfo>
{isSetSpendingLimit || isDeleteSpendingLimit ? (
<TxDetailsContent>
{isSetSpendingLimit && <ModifySpendingLimitDetails data={data as DataDecoded} />}
{isDeleteSpendingLimit && <DeleteSpendingLimitDetails data={data as DataDecoded} />}
</TxDetailsContent>
) : (
<TxDetailsContent>
<TxInfo>
<Bold>
Send {fromTokenUnit(tx.value, nativeCoin.decimals)} {nativeCoin.name} to:
</Bold>
<EthHashInfo hash={tx.to} showIdenticon showCopyBtn explorerUrl={explorerUrl} />
</TxInfo>
{!!tx.data && <TxInfoDetails data={tx.data} />}
</TxDetailsContent>
{!!data ? <TxInfoDetails data={data} /> : tx.data && <HexEncodedData data={tx.data} />}
</TxDetailsContent>
)}
</Collapse>
)
}
@ -173,45 +256,69 @@ const TxActionData = ({ dataDecoded }: { dataDecoded: DataDecoded }): React.Reac
)
}
interface GenericCustomDataProps {
amount?: string
interface HexEncodedDataProps {
data: string
recipient: string
storedTx: Transaction
}
const GenericCustomData = ({ amount = '0', data, recipient, storedTx }: GenericCustomDataProps): React.ReactElement => {
const HexEncodedData = ({ data }: HexEncodedDataProps): React.ReactElement => {
const classes = useStyles()
const recipientName = useSelector((state) => getNameFromAddressBookSelector(state, recipient))
const explorerUrl = getExplorerInfo(recipient)
return (
<Block className={classes.txData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}>
<Bold>Data (hex encoded):</Bold>
<TxData data={data} />
</Block>
)
}
interface GenericCustomDataProps {
amount?: string
data?: string | null
recipient?: string
storedTx: Transaction | SafeModuleTransaction
}
const GenericCustomData = ({
amount = '0',
data = null,
recipient,
storedTx,
}: GenericCustomDataProps): React.ReactElement => {
const recipientName = useSelector((state) => getNameFromAddressBookSelector(state, recipient))
const explorerUrl = recipient ? getExplorerInfo(recipient) : ''
const txData = storedTx?.dataDecoded ?? decodeMethods(data)
const { isSetSpendingLimit, isDeleteSpendingLimit } = spendingLimitTxType(data)
return isSetSpendingLimit || isDeleteSpendingLimit ? (
<>
{isSetSpendingLimit && <ModifySpendingLimitDetails data={txData as DataDecoded} />}
{isDeleteSpendingLimit && <DeleteSpendingLimitDetails data={txData as DataDecoded} />}
</>
) : (
<Block>
<Block data-testid={TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID}>
<Bold>Send {amount} to:</Bold>
{recipient && (
<Block data-testid={TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID}>
<Bold>Send {amount} to:</Bold>
<EthHashInfo
hash={recipient}
name={recipientName === 'UNKNOWN' ? undefined : recipientName}
showIdenticon
showCopyBtn
explorerUrl={explorerUrl}
/>
</Block>
<EthHashInfo
hash={recipient}
name={recipientName === 'UNKNOWN' ? undefined : recipientName}
showIdenticon
showCopyBtn
explorerUrl={explorerUrl}
/>
</Block>
)}
{!!storedTx?.dataDecoded && <TxActionData dataDecoded={storedTx.dataDecoded} />}
<Block className={classes.txData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}>
<Bold>Data (hex encoded):</Bold>
<TxData data={data} />
</Block>
{!!txData ? <TxActionData dataDecoded={txData} /> : data && <HexEncodedData data={data} />}
</Block>
)
}
interface CustomDescriptionProps {
amount?: string
data: string
recipient: string
data?: string | null
recipient?: string
storedTx: Transaction
}

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { ReactElement } from 'react'
import { useSelector } from 'react-redux'
import { EtherscanLink } from 'src/components/EtherscanLink'
import Block from 'src/components/layout/Block'
@ -20,7 +20,7 @@ interface RemovedOwnerProps {
removedOwner: string
}
const RemovedOwner = ({ removedOwner }: RemovedOwnerProps): React.ReactElement => {
const RemovedOwner = ({ removedOwner }: RemovedOwnerProps): ReactElement => {
const ownerChangedName = useSelector((state) => getNameFromAddressBookSelector(state, removedOwner))
return (
@ -39,7 +39,7 @@ interface AddedOwnerProps {
addedOwner: string
}
const AddedOwner = ({ addedOwner }: AddedOwnerProps): React.ReactElement => {
const AddedOwner = ({ addedOwner }: AddedOwnerProps): ReactElement => {
const ownerChangedName = useSelector((state) => getNameFromAddressBookSelector(state, addedOwner))
return (
@ -58,7 +58,7 @@ interface NewThresholdProps {
newThreshold: string
}
const NewThreshold = ({ newThreshold }: NewThresholdProps): React.ReactElement => (
const NewThreshold = ({ newThreshold }: NewThresholdProps): ReactElement => (
<Block data-testid={TRANSACTIONS_DESC_CHANGE_THRESHOLD_TEST_ID}>
<Bold>Change required confirmations:</Bold>
<Paragraph noMargin size="md">
@ -71,7 +71,7 @@ interface AddModuleProps {
module: string
}
const AddModule = ({ module }: AddModuleProps): React.ReactElement => (
const AddModule = ({ module }: AddModuleProps): ReactElement => (
<Block data-testid={TRANSACTIONS_DESC_ADD_MODULE_TEST_ID}>
<Bold>Add module:</Bold>
<EtherscanLink value={module} knownAddress={false} />
@ -82,7 +82,7 @@ interface RemoveModuleProps {
module: string
}
const RemoveModule = ({ module }: RemoveModuleProps): React.ReactElement => (
const RemoveModule = ({ module }: RemoveModuleProps): ReactElement => (
<Block data-testid={TRANSACTIONS_DESC_REMOVE_MODULE_TEST_ID}>
<Bold>Remove module:</Bold>
<EtherscanLink value={module} knownAddress={false} />
@ -90,7 +90,7 @@ const RemoveModule = ({ module }: RemoveModuleProps): React.ReactElement => (
)
interface SettingsDescriptionProps {
action: SafeMethods
action?: SafeMethods
addedOwner?: string
newThreshold?: string
removedOwner?: string
@ -103,7 +103,7 @@ const SettingsDescription = ({
newThreshold,
removedOwner,
module,
}: SettingsDescriptionProps): React.ReactElement => {
}: SettingsDescriptionProps): ReactElement => {
if (action === SAFE_METHODS_NAMES.REMOVE_OWNER && removedOwner && newThreshold) {
return (
<>

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { ReactElement } from 'react'
import { useSelector } from 'react-redux'
import { EtherscanLink } from 'src/components/EtherscanLink'
import Block from 'src/components/layout/Block'
@ -11,7 +11,7 @@ import SendModal from 'src/routes/safe/components/Balances/SendModal'
interface TransferDescriptionProps {
amountWithSymbol: string
recipient: string
recipient?: string
tokenAddress?: string
rawAmount?: string
isTokenTransfer: boolean
@ -23,7 +23,7 @@ const TransferDescription = ({
tokenAddress,
rawAmount,
isTokenTransfer,
}: TransferDescriptionProps): React.ReactElement => {
}: TransferDescriptionProps): ReactElement | null => {
const recipientName = useSelector((state) => getNameFromAddressBookSelector(state, recipient))
const [sendModalOpen, setSendModalOpen] = React.useState(false)
@ -31,7 +31,7 @@ const TransferDescription = ({
setSendModalOpen(true)
}
return (
return recipient ? (
<>
<Block data-testid={TRANSACTIONS_DESC_SEND_TEST_ID}>
<Bold>Send {amountWithSymbol} to:</Bold>
@ -60,7 +60,7 @@ const TransferDescription = ({
tokenAmount={rawAmount}
/>
</>
)
) : null
}
export default TransferDescription

View File

@ -7,59 +7,45 @@ import SettingsDescription from './SettingsDescription'
import CustomDescription from './CustomDescription'
import TransferDescription from './TransferDescription'
import { getRawTxAmount, getTxAmount } from 'src/routes/safe/components/Transactions/TxsTable/columns'
import { getTxAmount } from 'src/routes/safe/components/Transactions/TxsTable/columns'
import Block from 'src/components/layout/Block'
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
import { Transaction, TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
export const TRANSACTIONS_DESC_SEND_TEST_ID = 'tx-description-send'
const useStyles = makeStyles(styles)
const SettingsDescriptionTx = ({ tx }: { tx: Transaction }): React.ReactElement => {
const { action, addedOwner, module, newThreshold, removedOwner } = getTxData(tx)
return <SettingsDescription {...{ action, addedOwner, module, newThreshold, removedOwner }} />
}
const CustomDescriptionTx = ({ tx }: { tx: Transaction }): React.ReactElement => {
const amount = getTxAmount(tx, false)
const { data, recipient } = getTxData(tx)
return <CustomDescription {...{ amount, data, recipient }} storedTx={tx} />
}
const UpgradeDescriptionTx = ({ tx }: { tx: Transaction }): React.ReactElement => {
const { data } = getTxData(tx)
return <div>{data}</div>
}
const TransferDescriptionTx = ({ tx }: { tx: Transaction }): React.ReactElement => {
const amountWithSymbol = getTxAmount(tx, false)
const { recipient, isTokenTransfer = false } = getTxData(tx)
return <TransferDescription {...{ amountWithSymbol, recipient, isTokenTransfer }} />
}
const TxDescription = ({ tx }: { tx: Transaction }): React.ReactElement => {
const classes = useStyles()
const {
action,
addedOwner,
cancellationTx,
creationTx,
customTx,
data,
modifySettingsTx,
module,
newThreshold,
recipient,
removedOwner,
upgradeTx,
tokenAddress,
isTokenTransfer,
}: any = getTxData(tx)
const amountWithSymbol = getTxAmount(tx, false)
const amount = getRawTxAmount(tx)
return (
<Block className={classes.txDataContainer}>
{modifySettingsTx && action && (
<SettingsDescription
action={action}
addedOwner={addedOwner}
newThreshold={newThreshold}
removedOwner={removedOwner}
module={module}
/>
)}
{!upgradeTx && customTx && (
<CustomDescription amount={amountWithSymbol} data={data} recipient={recipient} storedTx={tx} />
)}
{upgradeTx && <div>{data}</div>}
{!cancellationTx && !modifySettingsTx && !customTx && !creationTx && !upgradeTx && (
<TransferDescription
amountWithSymbol={amountWithSymbol}
recipient={recipient}
tokenAddress={tokenAddress}
rawAmount={amount}
isTokenTransfer={isTokenTransfer}
/>
)}
{tx.type === TransactionTypes.SETTINGS && <SettingsDescriptionTx tx={tx} />}
{tx.type === TransactionTypes.CUSTOM && <CustomDescriptionTx tx={tx} />}
{tx.type === TransactionTypes.UPGRADE && <UpgradeDescriptionTx tx={tx} />}
{[TransactionTypes.TOKEN, TransactionTypes.COLLECTIBLE].includes(tx.type) && <TransferDescriptionTx tx={tx} />}
</Block>
)
}

View File

@ -1,5 +1,5 @@
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
import { SAFE_METHODS_NAMES } from 'src/routes/safe/store/models/types/transactions.d'
import { SAFE_METHODS_NAMES, SafeMethods, TokenDecodedParams } from 'src/logic/safe/store/models/types/transactions.d'
import { sameString } from 'src/utils/strings'
import { getNetworkInfo } from 'src/config'
@ -17,7 +17,7 @@ interface TxData {
data?: string | null
recipient?: string
module?: string
action?: string
action?: SafeMethods
addedOwner?: string
removedOwner?: string
newThreshold?: string
@ -97,7 +97,7 @@ const getTxDataForTxsWithDecodedParams = (tx: Transaction): TxData => {
}
if (tx.isTokenTransfer) {
const { to } = tx.decodedParams.transfer || {}
const { to } = (tx.decodedParams as TokenDecodedParams).transfer || {}
txData.recipient = to
txData.isTokenTransfer = true
txData.tokenAddress = tx.recipient
@ -105,7 +105,7 @@ const getTxDataForTxsWithDecodedParams = (tx: Transaction): TxData => {
}
if (tx.isCollectibleTransfer) {
const { safeTransferFrom, transfer, transferFrom } = tx.decodedParams
const { safeTransferFrom, transfer, transferFrom } = tx.decodedParams as TokenDecodedParams
const { to, value } = safeTransferFrom || transferFrom || transfer || {}
txData.recipient = to
txData.tokenId = value

View File

@ -1,6 +1,5 @@
import { makeStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import React, { useState } from 'react'
import React, { ReactElement, useMemo, useState } from 'react'
import { useSelector } from 'react-redux'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
@ -11,8 +10,15 @@ import TxDescription from './TxDescription'
import { IncomingTx } from './IncomingTx'
import { CreationTx } from './CreationTx'
import { OutgoingTx } from './OutgoingTx'
import { styles } from './style'
import useStyles from './style'
import {
getModuleAmount,
NOT_AVAILABLE,
TableData,
TX_TABLE_RAW_CANCEL_TX_ID,
TX_TABLE_RAW_TX_ID,
} from 'src/routes/safe/components/Transactions/TxsTable/columns'
import Block from 'src/components/layout/Block'
import Bold from 'src/components/layout/Bold'
import Col from 'src/components/layout/Col'
@ -22,21 +28,73 @@ import Row from 'src/components/layout/Row'
import Span from 'src/components/layout/Span'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { INCOMING_TX_TYPES } from 'src/logic/safe/store/models/incomingTransaction'
import { safeNonceSelector, safeThresholdSelector } from 'src/logic/safe/store/selectors'
import { Transaction, TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
import { Transaction, TransactionTypes, SafeModuleTransaction } from 'src/logic/safe/store/models/types/transaction'
import IncomingTxDescription from './IncomingTxDescription'
import { getExplorerInfo, getNetworkInfo } from 'src/config'
import TransferDescription from './TxDescription/TransferDescription'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { safeNonceSelector, safeThresholdSelector } from 'src/logic/safe/store/selectors'
const useStyles = makeStyles(styles as any)
const ExpandedModuleTx = ({ tx }: { tx: SafeModuleTransaction }): ReactElement => {
const classes = useStyles()
interface ExpandedTxProps {
cancelTx: Transaction
const recipient = useMemo(() => {
if (tx.type === TransactionTypes.SPENDING_LIMIT) {
if (tx.dataDecoded) {
// if `dataDecoded` is defined, then it's a token transfer
return tx.dataDecoded?.parameters[0].value
} else {
// if `data` is not defined, then it's an ETH transfer
return tx.to
}
}
}, [tx.dataDecoded, tx.to, tx.type])
const amountWithSymbol = getModuleAmount(tx)
return (
<Block className={classes.expandedTxBlock}>
<Row>
<Col layout="column" xs={6}>
<Block className={cn(classes.txDataContainer, classes.incomingTxBlock)}>
<div style={{ display: 'flex' }}>
<Bold className={classes.txHash}>Hash:</Bold>
{tx.executionTxHash ? (
<EthHashInfo
hash={tx.executionTxHash}
showCopyBtn
explorerUrl={getExplorerInfo(tx.executionTxHash)}
shortenHash={4}
/>
) : (
'n/a'
)}
</div>
</Block>
<Hairline />
<Block className={cn(classes.txDataContainer, classes.incomingTxBlock)}>
{recipient && (
<TransferDescription
amountWithSymbol={amountWithSymbol}
isTokenTransfer={!sameAddress(amountWithSymbol, NOT_AVAILABLE)}
recipient={recipient}
/>
)}
</Block>
</Col>
</Row>
</Block>
)
}
interface ExpandedSafeTxProps {
cancelTx?: Transaction
tx: Transaction
}
const { nativeCoin } = getNetworkInfo()
const ExpandedTx = ({ cancelTx, tx }: ExpandedTxProps): React.ReactElement => {
const ExpandedSafeTx = ({ cancelTx, tx }: ExpandedSafeTxProps): ReactElement => {
const { fromWei, toBN } = getWeb3().utils
const classes = useStyles()
const nonce = useSelector(safeNonceSelector)
@ -122,7 +180,7 @@ const ExpandedTx = ({ cancelTx, tx }: ExpandedTxProps): React.ReactElement => {
/>
)}
{openModal === 'rejectTx' && <RejectTxModal isOpen onClose={closeModal} tx={tx} />}
{openModal === 'executeRejectTx' && (
{openModal === 'executeRejectTx' && cancelTx && (
<ApproveTxModal
canExecute={canExecuteCancel}
isCancelTx
@ -136,4 +194,12 @@ const ExpandedTx = ({ cancelTx, tx }: ExpandedTxProps): React.ReactElement => {
)
}
export default ExpandedTx
export const ExpandedTx = ({ row }: { row: TableData }): ReactElement => {
const isModuleTx = [TransactionTypes.SPENDING_LIMIT, TransactionTypes.MODULE].includes(row.tx.type)
if (isModuleTx) {
return <ExpandedModuleTx tx={row[TX_TABLE_RAW_TX_ID] as SafeModuleTransaction} />
}
return <ExpandedSafeTx cancelTx={row[TX_TABLE_RAW_CANCEL_TX_ID]} tx={row[TX_TABLE_RAW_TX_ID] as Transaction} />
}

View File

@ -1,28 +1,29 @@
import { createStyles, makeStyles } from '@material-ui/core'
import { border, lg, md } from 'src/theme/variables'
const cssStyles = {
col: {
wordBreak: 'break-word',
whiteSpace: 'normal',
},
expandedTxBlock: {
borderBottom: `2px solid ${border}`,
},
txDataContainer: {
padding: `${lg} ${md}`,
},
txHash: {
paddingRight: '3px',
},
incomingTxBlock: {
borderRight: '2px solid rgb(232, 231, 230)',
},
emptyRowDataContainer: {
paddingTop: lg,
paddingLeft: md,
paddingBottom: md,
borderRight: '2px solid rgb(232, 231, 230)',
},
}
export const styles = (): typeof cssStyles => cssStyles
export default makeStyles(
createStyles({
col: {
wordBreak: 'break-word',
whiteSpace: 'normal',
},
expandedTxBlock: {
borderBottom: `2px solid ${border}`,
},
txDataContainer: {
padding: `${lg} ${md}`,
},
txHash: {
paddingRight: '3px',
},
incomingTxBlock: {
borderRight: '2px solid rgb(232, 231, 230)',
},
emptyRowDataContainer: {
paddingTop: lg,
paddingLeft: md,
paddingBottom: md,
borderRight: '2px solid rgb(232, 231, 230)',
},
}),
)

View File

@ -20,6 +20,8 @@ const typeToIcon = {
creation: SettingsTxIcon,
cancellation: SettingsTxIcon,
upgrade: SettingsTxIcon,
module: SettingsTxIcon,
spendingLimit: SettingsTxIcon,
}
const typeToLabel = {
@ -32,6 +34,8 @@ const typeToLabel = {
creation: 'Safe created',
cancellation: 'Cancellation transaction',
upgrade: 'Contract Upgrade',
module: 'Module transaction',
spendingLimit: 'Spending Limit',
}
interface TxTypeProps {

View File

@ -11,7 +11,8 @@ import { buildOrderFieldFrom } from 'src/components/Table/sorting'
import { TableColumn } from 'src/components/Table/types.d'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { INCOMING_TX_TYPES } from 'src/logic/safe/store/models/incomingTransaction'
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
import { SafeModuleTransaction, Transaction, TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
import { TokenDecodedParams } from 'src/logic/safe/store/models/types/transactions.d'
import { CancellationTransactions } from 'src/logic/safe/store/reducer/cancellationTransactions'
import { getNetworkInfo } from 'src/config'
@ -26,7 +27,7 @@ export const TX_TABLE_EXPAND_ICON = 'expand'
export const formatDate = (date: string): string => format(parseISO(date), 'MMM d, yyyy - HH:mm:ss')
const NOT_AVAILABLE = 'n/a'
export const NOT_AVAILABLE = 'n/a'
interface AmountData {
decimals?: number | string
@ -59,7 +60,8 @@ export const getIncomingTxAmount = (tx: Transaction, formatted = true): string =
export const getTxAmount = (tx: Transaction, formatted = true): string => {
const { decimals = 18, decodedParams, isTokenTransfer, symbol } = tx
const { value } = isTokenTransfer && !!decodedParams?.transfer ? decodedParams.transfer : tx
const tokenDecodedTransfer = isTokenTransfer && (decodedParams as TokenDecodedParams)?.transfer
const { value } = tokenDecodedTransfer || tx
if (tx.isCollectibleTransfer) {
return `1 ${tx.symbol}`
@ -72,10 +74,32 @@ export const getTxAmount = (tx: Transaction, formatted = true): string => {
return getAmountWithSymbol({ decimals: decimals as string, symbol: symbol as string, value }, formatted)
}
export const getModuleAmount = (tx: SafeModuleTransaction, formatted = true): string => {
if (tx.type === TransactionTypes.SPENDING_LIMIT && tx.tokenInfo) {
const { decimals, symbol } = tx.tokenInfo
let value
if (tx.dataDecoded) {
// if `dataDecoded` is defined, then it's a token transfer
const [, amount] = tx.dataDecoded.parameters
value = amount.value
} else {
// if `dataDecoded` is not defined, then it's an ETH transfer
value = tx.value
}
return getAmountWithSymbol({ decimals, symbol, value }, formatted)
}
return NOT_AVAILABLE
}
export const getRawTxAmount = (tx: Transaction): string => {
const { decimals, decodedParams, isTokenTransfer } = tx
const { nativeCoin } = getNetworkInfo()
const { value } = isTokenTransfer && !!decodedParams?.transfer ? decodedParams.transfer : tx
const tokenDecodedTransfer = isTokenTransfer && (decodedParams as TokenDecodedParams)?.transfer
const { value } = tokenDecodedTransfer || tx
if (tx.isCollectibleTransfer) {
return '1'
@ -98,10 +122,20 @@ export interface TableData {
dateOrder?: number
id: string
status: string
tx?: Transaction
tx: Transaction | SafeModuleTransaction
type: any
}
const getModuleTxTableData = (tx: SafeModuleTransaction): TableData => ({
[TX_TABLE_ID]: tx.blockNumber?.toString() ?? '',
[TX_TABLE_TYPE_ID]: <TxType txType={tx.type} origin={null} />,
[TX_TABLE_DATE_ID]: formatDate(tx.executionDate),
[buildOrderFieldFrom(TX_TABLE_DATE_ID)]: getTime(parseISO(tx.executionDate)),
[TX_TABLE_AMOUNT_ID]: getModuleAmount(tx),
[TX_TABLE_STATUS_ID]: tx.status,
[TX_TABLE_RAW_TX_ID]: tx,
})
const getIncomingTxTableData = (tx: Transaction): TableData => ({
[TX_TABLE_ID]: tx.blockNumber?.toString() ?? '',
[TX_TABLE_TYPE_ID]: <TxType txType="incoming" origin={null} />,
@ -112,12 +146,24 @@ const getIncomingTxTableData = (tx: Transaction): TableData => ({
[TX_TABLE_RAW_TX_ID]: tx,
})
// This follows the approach of calculating the tx information closest to the presentation components.
// Instead of populating tx in the store with another flag, Spending Limit tx is inferred here.
const getTxType = (tx: Transaction): TransactionTypes => {
const SET_ALLOWANCE_HASH = 'beaeb388'
const DELETE_ALLOWANCE_HASH = '885133e3'
return tx.data?.includes(SET_ALLOWANCE_HASH) || tx.data?.includes(DELETE_ALLOWANCE_HASH)
? TransactionTypes.SPENDING_LIMIT
: tx.type
}
const getTransactionTableData = (tx: Transaction, cancelTx?: Transaction): TableData => {
const txDate = tx.submissionDate
const txType = getTxType(tx)
return {
[TX_TABLE_ID]: tx.blockNumber?.toString() ?? '',
[TX_TABLE_TYPE_ID]: <TxType origin={tx.origin} txType={tx.type} />,
[TX_TABLE_TYPE_ID]: <TxType origin={tx.origin} txType={txType} />,
[TX_TABLE_DATE_ID]: txDate ? formatDate(txDate) : '',
[buildOrderFieldFrom(TX_TABLE_DATE_ID)]: txDate ? getTime(parseISO(txDate)) : null,
[TX_TABLE_AMOUNT_ID]: getTxAmount(tx),
@ -128,15 +174,22 @@ const getTransactionTableData = (tx: Transaction, cancelTx?: Transaction): Table
}
export const getTxTableData = (
transactions: List<Transaction>,
transactions: List<Transaction | SafeModuleTransaction>,
cancelTxs: CancellationTransactions,
): List<TableData> => {
return transactions.map((tx) => {
if (INCOMING_TX_TYPES[tx.type] !== undefined) {
return getIncomingTxTableData(tx)
const isModuleTx = [TransactionTypes.SPENDING_LIMIT, TransactionTypes.MODULE].includes(tx.type)
const isIncomingTx = INCOMING_TX_TYPES[tx.type] !== undefined
if (isModuleTx) {
return getModuleTxTableData(tx as SafeModuleTransaction)
}
return getTransactionTableData(tx, cancelTxs.get(`${tx.nonce}`))
if (isIncomingTx) {
return getIncomingTxTableData(tx as Transaction)
}
return getTransactionTableData(tx as Transaction, cancelTxs.get(`${tx.nonce}`))
})
}

View File

@ -10,9 +10,9 @@ import cn from 'classnames'
import React, { useState, useEffect } from 'react'
import { useSelector } from 'react-redux'
import ExpandedTxComponent from './ExpandedTx'
import { ExpandedTx } from './ExpandedTx'
import Status from './Status'
import { TX_TABLE_ID, TX_TABLE_RAW_CANCEL_TX_ID, TX_TABLE_RAW_TX_ID, generateColumns, getTxTableData } from './columns'
import { TX_TABLE_ID, generateColumns, getTxTableData } from './columns'
import { styles } from './style'
import Table from 'src/components/Table'
@ -35,8 +35,8 @@ const TxsTable = ({ classes }) => {
trackEvent({ category: SAFE_NAVIGATION_EVENT, action: 'Transactions' })
}, [trackEvent])
const handleTxExpand = (safeTxHash) => {
setExpandedTx((prevTx) => (prevTx === safeTxHash ? null : safeTxHash))
const handleTxExpand = (rowId) => {
setExpandedTx((prevRowId) => (prevRowId === rowId ? null : rowId))
}
const columns = generateColumns()
@ -44,8 +44,8 @@ const TxsTable = ({ classes }) => {
const filteredData = getTxTableData(transactions, cancellationTransactions)
.sort((tx1, tx2) => {
// First order by nonce
const aNonce = tx1.tx?.nonce
const bNonce = tx1.tx?.nonce
const aNonce = Number(tx1.tx?.nonce)
const bNonce = Number(tx1.tx?.nonce)
if (aNonce && bNonce) {
const difference = aNonce - bNonce
if (difference !== 0) {
@ -82,54 +82,56 @@ const TxsTable = ({ classes }) => {
size={filteredData.size}
>
{(sortedData) =>
sortedData.map((row, index) => (
<React.Fragment key={index}>
<TableRow
className={cn(classes.row, expandedTx === row.tx.safeTxHash && classes.expandedRow)}
data-testid={TRANSACTION_ROW_TEST_ID}
onClick={() => handleTxExpand(row.tx.safeTxHash)}
tabIndex={-1}
>
{autoColumns.map((column) => (
<TableCell
align={column.align}
className={cn(classes.cell, ['cancelled', 'failed'].includes(row.status) && classes.cancelledRow)}
component="td"
key={column.id}
style={cellWidth(column.width)}
>
{row[column.id]}
</TableCell>
))}
<TableCell component="td">
<Row align="end" className={classes.actions}>
<Status status={row.status} />
</Row>
</TableCell>
<TableCell className={classes.expandCellStyle}>
<IconButton disableRipple>
{expandedTx === row.tx.safeTxHash ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</TableCell>
</TableRow>
<TableRow>
<TableCell
className={classes.extendedTxContainer}
colSpan={6}
style={{ paddingBottom: 0, paddingTop: 0 }}
sortedData.map((row) => {
const rowId = `${row.tx.safeTxHash}-${row.tx.type}`
return (
<React.Fragment key={rowId}>
<TableRow
className={cn(classes.row, expandedTx === rowId && classes.expandedRow)}
data-testid={TRANSACTION_ROW_TEST_ID}
onClick={() => handleTxExpand(rowId)}
tabIndex={-1}
>
<Collapse
component={() => (
<ExpandedTxComponent cancelTx={row[TX_TABLE_RAW_CANCEL_TX_ID]} tx={row[TX_TABLE_RAW_TX_ID]} />
)}
in={expandedTx === row.tx.safeTxHash}
timeout="auto"
unmountOnExit
/>
</TableCell>
</TableRow>
</React.Fragment>
))
{autoColumns.map((column) => (
<TableCell
align={column.align}
className={cn(
classes.cell,
['cancelled', 'failed'].includes(row.status) && classes.cancelledRow,
)}
component="td"
key={column.id}
style={cellWidth(column.width)}
>
{row[column.id]}
</TableCell>
))}
<TableCell component="td">
<Row align="end" className={classes.actions}>
<Status status={row.status} />
</Row>
</TableCell>
<TableCell className={classes.expandCellStyle}>
<IconButton disableRipple>{expandedTx === rowId ? <ExpandLess /> : <ExpandMore />}</IconButton>
</TableCell>
</TableRow>
<TableRow>
<TableCell
className={classes.extendedTxContainer}
colSpan={6}
style={{ paddingBottom: 0, paddingTop: 0 }}
>
<Collapse
component={() => <ExpandedTx row={row} />}
in={expandedTx === rowId}
timeout="auto"
unmountOnExit
/>
</TableCell>
</TableRow>
</React.Fragment>
)
})
}
</Table>
</TableContainer>

View File

@ -2,13 +2,27 @@ import { List, Map } from 'immutable'
import { createSelector } from 'reselect'
import { Token } from 'src/logic/tokens/store/model/token'
import { tokensSelector } from 'src/logic/tokens/store/selectors'
import { tokenListSelector, tokensSelector } from 'src/logic/tokens/store/selectors'
import { getEthAsToken } from 'src/logic/tokens/utils/tokenHelpers'
import { isUserAnOwner } from 'src/logic/wallets/ethAddresses'
import { isUserAnOwner, sameAddress } from 'src/logic/wallets/ethAddresses'
import { userAccountSelector } from 'src/logic/wallets/store/selectors'
import { safeActiveTokensSelector, safeBalancesSelector, safeSelector } from 'src/logic/safe/store/selectors'
import {
safeActiveTokensSelector,
safeBalancesSelector,
safeParamAddressFromStateSelector,
safeSelector,
} from 'src/logic/safe/store/selectors'
import { SafeRecord } from 'src/logic/safe/store/models/safe'
import { AppReduxState } from 'src/store'
import { MODULE_TRANSACTIONS_REDUCER_ID } from 'src/logic/safe/store/reducer/moduleTransactions'
import { ModuleTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadModuleTransactions'
import {
SafeModuleTransaction,
TransactionStatus,
TransactionTypes,
} from 'src/logic/safe/store/models/types/transaction'
import { SPENDING_LIMIT_MODULE_ADDRESS } from 'src/utils/constants'
export const grantedSelector = createSelector(
userAccountSelector,
@ -48,3 +62,67 @@ export const extendedSafeTokensSelector = createSelector(
return extendedTokens.toList()
},
)
export const safeKnownCoins = createSelector(
tokensSelector,
safeEthAsTokenSelector,
(safeTokens, nativeCoinAsToken): List<Token> => {
if (nativeCoinAsToken) {
return safeTokens.set(nativeCoinAsToken.address, nativeCoinAsToken).toList()
}
return safeTokens.toList()
},
)
const moduleTransactionsSelector = (state: AppReduxState) => state[MODULE_TRANSACTIONS_REDUCER_ID]
export const modulesTransactionsBySafeSelector = createSelector(
moduleTransactionsSelector,
safeParamAddressFromStateSelector,
(moduleTransactions, safeAddress): ModuleTxServiceModel[] => {
// no module tx for the current safe so far
if (!moduleTransactions || !safeAddress || !moduleTransactions[safeAddress]) {
return []
}
return moduleTransactions[safeAddress]
},
)
export const safeModuleTransactionsSelector = createSelector(
tokenListSelector,
modulesTransactionsBySafeSelector,
(tokens, safeModuleTransactions): SafeModuleTransaction[] => {
return safeModuleTransactions.map((moduleTx) => {
// if not spendingLimit module tx, then it's an generic module tx
const type = sameAddress(moduleTx.module, SPENDING_LIMIT_MODULE_ADDRESS)
? TransactionTypes.SPENDING_LIMIT
: TransactionTypes.MODULE
// TODO: this is strictly attached to Spending Limit Module.
// This has to be moved nearest the module info rendering.
// add token info to the model, so data can be properly displayed in the UI
let tokenInfo
if (type === TransactionTypes.SPENDING_LIMIT) {
if (moduleTx.data) {
// if `data` is defined, then it's a token transfer
tokenInfo = tokens.find(({ address }) => sameAddress(address, moduleTx.to))
} else {
// if `data` is not defined, then it's an ETH transfer
// ETH does not exist in the list of tokens, so we recreate the record here
tokenInfo = getEthAsToken(0)
}
}
return {
...moduleTx,
safeTxHash: moduleTx.transactionHash,
executionTxHash: moduleTx.transactionHash,
status: TransactionStatus.SUCCESS,
tokenInfo,
type,
}
})
},
)

View File

@ -19,7 +19,8 @@ import { Transaction } from 'src/logic/safe/store/models/types/transaction'
export type MultiSendDetails = {
operation: Operation
to: string
data: DataDecoded | null
data: string | null
dataDecoded: DataDecoded | null
value: number
}
@ -48,7 +49,8 @@ export const extractMultiSendDetails = (parameter: Parameter): MultiSendDetails[
operation: valueDecoded.operation,
to: valueDecoded.to,
value: valueDecoded.value,
data: valueDecoded?.dataDecoded ?? null,
dataDecoded: valueDecoded?.dataDecoded ?? null,
data: valueDecoded?.data ?? null,
}
})
}

View File

@ -41,6 +41,10 @@ import { NFTAssets, NFTTokens } from 'src/logic/collectibles/sources/collectible
import { SafeReducerMap } from 'src/routes/safe/store/reducer/types/safe'
import allTransactions, { TRANSACTIONS, TransactionsState } from '../logic/safe/store/reducer/allTransactions'
import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
import moduleTransactions, {
MODULE_TRANSACTIONS_REDUCER_ID,
ModuleTransactionsState,
} from 'src/logic/safe/store/reducer/moduleTransactions'
export const history = createHashHistory()
@ -68,6 +72,7 @@ const reducers = combineReducers({
[TRANSACTIONS_REDUCER_ID]: transactions,
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: cancellationTransactions,
[INCOMING_TRANSACTIONS_REDUCER_ID]: incomingTransactions,
[MODULE_TRANSACTIONS_REDUCER_ID]: moduleTransactions,
[NOTIFICATIONS_REDUCER_ID]: notifications,
[CURRENCY_VALUES_KEY]: currencyValues,
[COOKIES_REDUCER_ID]: cookies,
@ -85,6 +90,7 @@ export type AppReduxState = CombinedState<{
[TRANSACTIONS_REDUCER_ID]: Map<string, any>
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: CancellationTxState
[INCOMING_TRANSACTIONS_REDUCER_ID]: Map<string, any>
[MODULE_TRANSACTIONS_REDUCER_ID]: ModuleTransactionsState
[NOTIFICATIONS_REDUCER_ID]: Map<string, any>
[CURRENCY_VALUES_KEY]: CurrencyValuesState
[COOKIES_REDUCER_ID]: Map<string, any>

View File

@ -26,3 +26,5 @@ export const ETHERSCAN_API_KEY = process.env.REACT_APP_ETHERSCAN_API_KEY
export const EXCHANGE_RATE_URL = 'https://api.exchangeratesapi.io/latest'
export const EXCHANGE_RATE_URL_FALLBACK = 'https://api.coinbase.com/v2/exchange-rates'
export const IPFS_GATEWAY = process.env.REACT_APP_IPFS_GATEWAY
export const SPENDING_LIMIT_MODULE_ADDRESS =
process.env.REACT_APP_SPENDING_LIMIT_MODULE_ADDRESS || '0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134'

13
src/utils/date.ts Normal file
View File

@ -0,0 +1,13 @@
import { formatRelative } from 'date-fns'
export const relativeTime = (baseTimeMin: string, resetTimeMin: string): string => {
if (resetTimeMin === '0') {
return 'One-time'
}
const baseTimeSeconds = +baseTimeMin * 60
const resetTimeSeconds = +resetTimeMin * 60
const nextResetTimeMilliseconds = (baseTimeSeconds + resetTimeSeconds) * 1000
return formatRelative(nextResetTimeMilliseconds, Date.now())
}