(Feature) Spending Limit (#1637)
Co-authored-by: Daniel Sanchez <daniel.sanchez@gnosis.pm>
This commit is contained in:
parent
dca9f19fd0
commit
fc1250d528
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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:
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
@ -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],
|
||||
}
|
||||
},
|
||||
},
|
||||
{},
|
||||
)
|
|
@ -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> => {
|
||||
|
|
|
@ -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]),
|
||||
)
|
||||
|
|
|
@ -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/`
|
||||
}
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }}
|
||||
/>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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: '' })
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 }
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
import AddressInfo from './AddressInfo'
|
||||
import ResetTimeInfo from './ResetTimeInfo'
|
||||
import TokenInfo from './TokenInfo'
|
||||
|
||||
export { AddressInfo, ResetTimeInfo, TokenInfo }
|
||||
export default './DataDisplay'
|
|
@ -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
|
|
@ -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])
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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
|
|
@ -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',
|
||||
},
|
||||
}),
|
||||
)
|
|
@ -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>
|
||||
|
|
|
@ -91,7 +91,7 @@ const useStyles = makeStyles(styles)
|
|||
|
||||
type ownersColumnProps = {
|
||||
tx: Transaction
|
||||
cancelTx: Transaction
|
||||
cancelTx?: Transaction
|
||||
thresholdReached: boolean
|
||||
cancelThresholdReached: boolean
|
||||
onTxConfirm: () => void
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
@ -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)',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}`))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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())
|
||||
}
|
Loading…
Reference in New Issue