(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
|
# For Apps
|
||||||
REACT_APP_GNOSIS_APPS_URL=https://safe-apps.staging.gnosisdev.com
|
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-build": "electron-builder --mac --windows --linux",
|
||||||
"electron-dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\"",
|
"electron-dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\"",
|
||||||
"format:staged": "lint-staged",
|
"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: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:check": "eslint './src/**/*.{js,jsx,ts,tsx}'",
|
||||||
"lint:fix": "yarn lint:check --fix",
|
"lint:fix": "yarn lint:check --fix",
|
||||||
"postinstall": "patch-package && electron-builder install-app-deps && yarn generate-types",
|
"postinstall": "patch-package && electron-builder install-app-deps && yarn generate-types",
|
||||||
|
|
|
@ -1,52 +1,70 @@
|
||||||
import Modal from '@material-ui/core/Modal'
|
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 cn from 'classnames'
|
||||||
import * as React from 'react'
|
import React, { ReactElement, ReactNode } from 'react'
|
||||||
|
|
||||||
import { sm } from 'src/theme/variables'
|
import { sm } from 'src/theme/variables'
|
||||||
|
|
||||||
const styles = () => ({
|
const useStyles = makeStyles(
|
||||||
root: {
|
createStyles({
|
||||||
alignItems: 'center',
|
root: {
|
||||||
justifyContent: 'center',
|
alignItems: 'center',
|
||||||
display: 'flex',
|
justifyContent: 'center',
|
||||||
overflowY: 'scroll',
|
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',
|
|
||||||
},
|
},
|
||||||
display: 'flex',
|
paper: {
|
||||||
flexDirection: 'column',
|
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 = ({
|
const GnoModal = ({
|
||||||
children,
|
children,
|
||||||
classes,
|
|
||||||
description,
|
description,
|
||||||
handleClose,
|
handleClose,
|
||||||
modalClassName,
|
modalClassName,
|
||||||
open,
|
open,
|
||||||
paperClassName,
|
paperClassName,
|
||||||
title,
|
title,
|
||||||
}: any) => (
|
}: GnoModalProps): ReactElement => {
|
||||||
<Modal
|
const classes = useStyles()
|
||||||
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 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 { makeStyles } from '@material-ui/core/styles'
|
||||||
import { useState } from 'react'
|
import React, { ReactElement, useState } from 'react'
|
||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
import QRIcon from 'src/assets/icons/qrcode.svg'
|
import QRIcon from 'src/assets/icons/qrcode.svg'
|
||||||
import { ScanQRModal } from 'src/components/ScanQRModal'
|
import { ScanQRModal } from 'src/components/ScanQRModal'
|
||||||
|
@ -16,7 +15,7 @@ type Props = {
|
||||||
handleScan: (dataResult: string, closeQrModal: () => void) => void
|
handleScan: (dataResult: string, closeQrModal: () => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScanQRWrapper = ({ handleScan }: Props): React.ReactElement => {
|
export const ScanQRWrapper = ({ handleScan }: Props): ReactElement => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const [qrModalOpen, setQrModalOpen] = useState(false)
|
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 { 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 => {
|
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) {
|
switch (method) {
|
||||||
// swapOwner
|
case SAFE_METHODS_NAMES.SWAP_OWNER: {
|
||||||
case '0xe318b52b': {
|
const params = {
|
||||||
const decodedParameters = web3.eth.abi.decodeParameters(['uint', 'address', 'address'], params) as string[]
|
prevOwner: 'address',
|
||||||
return {
|
oldOwner: 'address',
|
||||||
method: METHOD_TO_ID[methodId],
|
newOwner: 'address',
|
||||||
parameters: [
|
|
||||||
{ name: 'oldOwner', type: 'address', value: decodedParameters[1] },
|
|
||||||
{ name: 'newOwner', type: 'address', value: decodedParameters[2] },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD: {
|
||||||
case '0x0d582f13': {
|
const params = {
|
||||||
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params)
|
owner: 'address',
|
||||||
return {
|
_threshold: 'uint',
|
||||||
method: METHOD_TO_ID[methodId],
|
|
||||||
parameters: [
|
|
||||||
{ name: 'owner', type: 'address', value: decodedParameters[0] },
|
|
||||||
{ name: '_threshold', type: 'uint', value: decodedParameters[1] },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parameters = decodeInfo({ paramsHash, params })
|
||||||
|
|
||||||
|
return { method, parameters }
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeOwner
|
case SAFE_METHODS_NAMES.REMOVE_OWNER: {
|
||||||
case '0xf8dc5dd9': {
|
const params = {
|
||||||
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
|
prevOwner: 'address',
|
||||||
return {
|
owner: 'address',
|
||||||
method: METHOD_TO_ID[methodId],
|
_threshold: 'uint',
|
||||||
parameters: [
|
|
||||||
{ name: 'owner', type: 'address', value: decodedParameters[1] },
|
|
||||||
{ name: '_threshold', type: 'uint', value: decodedParameters[2] },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 SAFE_METHODS_NAMES.CHANGE_THRESHOLD: {
|
||||||
case '0x694e80c3': {
|
const params = {
|
||||||
const decodedParameters = web3.eth.abi.decodeParameters(['uint'], params)
|
_threshold: 'uint',
|
||||||
return {
|
|
||||||
method: METHOD_TO_ID[methodId],
|
|
||||||
parameters: [
|
|
||||||
{ name: '_threshold', type: 'uint', value: decodedParameters[0] },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parameters = decodeInfo({ paramsHash, params })
|
||||||
|
|
||||||
|
return { method, parameters }
|
||||||
}
|
}
|
||||||
|
|
||||||
// enableModule
|
case SAFE_METHODS_NAMES.ENABLE_MODULE: {
|
||||||
case '0x610b5925': {
|
const params = {
|
||||||
const decodedParameters = web3.eth.abi.decodeParameters(['address'], params)
|
module: 'address',
|
||||||
return {
|
|
||||||
method: METHOD_TO_ID[methodId],
|
|
||||||
parameters: [
|
|
||||||
{ name: 'module', type: 'address', value: decodedParameters[0] },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parameters = decodeInfo({ paramsHash, params })
|
||||||
|
|
||||||
|
return { method, parameters }
|
||||||
}
|
}
|
||||||
|
|
||||||
// disableModule
|
case SAFE_METHODS_NAMES.DISABLE_MODULE: {
|
||||||
case '0xe009cfde': {
|
const params = {
|
||||||
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address'], params)
|
prevModule: 'address',
|
||||||
return {
|
module: 'address',
|
||||||
method: METHOD_TO_ID[methodId],
|
|
||||||
parameters: [
|
|
||||||
{ name: 'prevModule', type: 'address', value: decodedParameters[0] },
|
|
||||||
{ name: 'module', type: 'address', value: decodedParameters[1] },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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:
|
default:
|
||||||
|
@ -81,57 +176,53 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSafeMethod = (methodId: string): boolean => {
|
const isSafeMethod = (methodId: string): boolean => {
|
||||||
return !!METHOD_TO_ID[methodId]
|
return !!SAFE_METHOD_ID_TO_NAME[methodId]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const decodeMethods = (data: string): DataDecoded | null => {
|
const isSpendingLimitMethod = (methodId: string): boolean => {
|
||||||
if(!data.length) {
|
return !!SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decodeMethods = (data: string | null): DataDecoded | null => {
|
||||||
|
if (!data?.length) {
|
||||||
return null
|
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)) {
|
if (isSafeMethod(methodId)) {
|
||||||
return decodeParamsFromSafeMethod(data)
|
return decodeParamsFromSafeMethod(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (methodId) {
|
if (isSpendingLimitMethod(methodId)) {
|
||||||
// a9059cbb - transfer(address,uint256)
|
return decodeParamsFromSpendingLimit(data)
|
||||||
case '0xa9059cbb': {
|
}
|
||||||
const decodeParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params)
|
|
||||||
return {
|
const method = TOKEN_TRANSFER_METHOD_ID_TO_NAME[methodId]
|
||||||
method: 'transfer',
|
|
||||||
parameters: [
|
switch (method) {
|
||||||
{ name: 'to', type: '', value: decodeParameters[0] },
|
case TOKEN_TRANSFER_METHODS_NAMES.TRANSFER: {
|
||||||
{ name: 'value', type: '', value: decodeParameters[1] },
|
const params = {
|
||||||
],
|
to: 'address',
|
||||||
|
value: 'uint',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parameters = decodeInfo({ paramsHash, params })
|
||||||
|
|
||||||
|
return { method, parameters }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 23b872dd - transferFrom(address,address,uint256)
|
case TOKEN_TRANSFER_METHODS_NAMES.TRANSFER_FROM:
|
||||||
case '0x23b872dd': {
|
case TOKEN_TRANSFER_METHODS_NAMES.SAFE_TRANSFER_FROM: {
|
||||||
const decodeParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
|
const params = {
|
||||||
return {
|
from: 'address',
|
||||||
method: 'transferFrom',
|
to: 'address',
|
||||||
parameters: [
|
value: 'uint',
|
||||||
{ name: 'from', type: '', value: decodeParameters[0] },
|
|
||||||
{ name: 'to', type: '', value: decodeParameters[1] },
|
|
||||||
{ name: 'value', type: '', value: decodeParameters[2] },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 42842e0e - safeTransferFrom(address,address,uint256)
|
const parameters = decodeInfo({ paramsHash, params })
|
||||||
case '0x42842e0e': {
|
|
||||||
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
|
return { method, parameters }
|
||||||
return {
|
|
||||||
method: 'safeTransferFrom',
|
|
||||||
parameters: [
|
|
||||||
{ name: 'from', type: '', value: decodedParameters[0] },
|
|
||||||
{ name: 'to', type: '', value: decodedParameters[1] },
|
|
||||||
{ name: 'value', type: '', value: decodedParameters[2] },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -12,6 +12,9 @@ import { calculateGasOf, calculateGasPrice } from 'src/logic/wallets/ethTransact
|
||||||
import { getWeb3, getNetworkIdFrom } from 'src/logic/wallets/getWeb3'
|
import { getWeb3, getNetworkIdFrom } from 'src/logic/wallets/getWeb3'
|
||||||
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
|
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
|
||||||
import { GnosisSafeProxyFactory } from 'src/types/contracts/GnosisSafeProxyFactory.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 SENTINEL_ADDRESS = '0x0000000000000000000000000000000000000001'
|
||||||
export const MULTI_SEND_ADDRESS = '0x8d29be29923b68abfdd21e541b9374737b49cdad'
|
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
|
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 getCreateProxyFactoryContract = memoize(createProxyFactoryContract)
|
||||||
|
|
||||||
const instantiateMasterCopies = async () => {
|
const instantiateMasterCopies = async () => {
|
||||||
|
|
|
@ -99,6 +99,28 @@ const settingsChangeTxNotificationsQueue = {
|
||||||
afterExecutionError: NOTIFICATIONS.SETTINGS_CHANGE_FAILED_MSG,
|
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 = {
|
const defaultNotificationsQueue = {
|
||||||
beforeExecution: NOTIFICATIONS.SIGN_TX_MSG,
|
beforeExecution: NOTIFICATIONS.SIGN_TX_MSG,
|
||||||
pendingExecution: NOTIFICATIONS.TX_PENDING_MSG,
|
pendingExecution: NOTIFICATIONS.TX_PENDING_MSG,
|
||||||
|
@ -166,6 +188,14 @@ export const getNotificationsFromTxType: any = (txType, origin) => {
|
||||||
notificationsQueue = settingsChangeTxNotificationsQueue
|
notificationsQueue = settingsChangeTxNotificationsQueue
|
||||||
break
|
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: {
|
case TX_NOTIFICATION_TYPES.SAFE_NAME_CHANGE_TX: {
|
||||||
notificationsQueue = safeNameChangeNotificationsQueue
|
notificationsQueue = safeNameChangeNotificationsQueue
|
||||||
break
|
break
|
||||||
|
|
|
@ -46,6 +46,18 @@ const NOTIFICATION_IDS = {
|
||||||
SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: 'SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG',
|
SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: 'SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG',
|
||||||
SETTINGS_CHANGE_FAILED_MSG: 'SETTINGS_CHANGE_FAILED_MSG',
|
SETTINGS_CHANGE_FAILED_MSG: 'SETTINGS_CHANGE_FAILED_MSG',
|
||||||
TESTNET_VERSION_MSG: 'TESTNET_VERSION_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',
|
WRONG_NETWORK_MSG: 'WRONG_NETWORK_MSG',
|
||||||
ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS',
|
ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS',
|
||||||
ADDRESS_BOOK_EDIT_ENTRY_SUCCESS: 'ADDRESS_BOOK_EDIT_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 },
|
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
|
// Network
|
||||||
TESTNET_VERSION_MSG: {
|
TESTNET_VERSION_MSG: {
|
||||||
message: "Testnet Version: Don't send production assets to this Safe",
|
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 { AppReduxState } from 'src/store'
|
||||||
import { Dispatch, DispatchReturn } from './types'
|
import { Dispatch, DispatchReturn } from './types'
|
||||||
|
|
||||||
interface CreateTransactionArgs {
|
export interface CreateTransactionArgs {
|
||||||
navigateToTransactionsTab?: boolean
|
navigateToTransactionsTab?: boolean
|
||||||
notifiedTransaction: string
|
notifiedTransaction: string
|
||||||
operation?: number
|
operation?: number
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { AppReduxState } from 'src/store'
|
||||||
import { latestMasterContractVersionSelector } from 'src/logic/safe/store/selectors'
|
import { latestMasterContractVersionSelector } from 'src/logic/safe/store/selectors'
|
||||||
import { getSafeInfo } from 'src/logic/safe/utils/safeInformation'
|
import { getSafeInfo } from 'src/logic/safe/utils/safeInformation'
|
||||||
import { getModules } from 'src/logic/safe/utils/modules'
|
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 buildOwnersFrom = (safeOwners: string[], localSafe?: SafeRecordProps): List<SafeOwner> => {
|
||||||
const ownersList = safeOwners.map((ownerAddress) => {
|
const ownersList = safeOwners.map((ownerAddress) => {
|
||||||
|
@ -71,6 +72,7 @@ export const buildSafe = async (
|
||||||
const needsUpdate = safeNeedsUpdate(currentVersion, latestMasterContractVersion)
|
const needsUpdate = safeNeedsUpdate(currentVersion, latestMasterContractVersion)
|
||||||
const featuresEnabled = enabledFeatures(currentVersion)
|
const featuresEnabled = enabledFeatures(currentVersion)
|
||||||
const modules = await getModules(safeInfo)
|
const modules = await getModules(safeInfo)
|
||||||
|
const spendingLimits = safeInfo ? await getSpendingLimits(safeInfo.modules, safeAddress) : null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
address: safeAddress,
|
address: safeAddress,
|
||||||
|
@ -89,6 +91,7 @@ export const buildSafe = async (
|
||||||
blacklistedAssets: Set(),
|
blacklistedAssets: Set(),
|
||||||
blacklistedTokens: Set(),
|
blacklistedTokens: Set(),
|
||||||
modules,
|
modules,
|
||||||
|
spendingLimits,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,6 +109,9 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch
|
||||||
getLocalSafe(safeAddress),
|
getLocalSafe(safeAddress),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// request SpendingLimit info
|
||||||
|
const spendingLimits = safeInfo ? await getSpendingLimits(safeInfo.modules, safeAddress) : null
|
||||||
|
|
||||||
// Converts from [ { address, ownerName} ] to address array
|
// Converts from [ { address, ownerName} ] to address array
|
||||||
const localOwners = localSafe ? localSafe.owners.map((localOwner) => localOwner.address) : []
|
const localOwners = localSafe ? localSafe.owners.map((localOwner) => localOwner.address) : []
|
||||||
|
|
||||||
|
@ -116,6 +122,7 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch
|
||||||
address: safeAddress,
|
address: safeAddress,
|
||||||
name: localSafe?.name,
|
name: localSafe?.name,
|
||||||
modules,
|
modules,
|
||||||
|
spendingLimits,
|
||||||
nonce: Number(remoteNonce),
|
nonce: Number(remoteNonce),
|
||||||
threshold: Number(remoteThreshold),
|
threshold: Number(remoteThreshold),
|
||||||
featuresEnabled: localSafe?.currentVersion
|
featuresEnabled: localSafe?.currentVersion
|
||||||
|
|
|
@ -2,19 +2,27 @@ import axios from 'axios'
|
||||||
|
|
||||||
import { buildTxServiceUrl } from 'src/logic/safe/transactions'
|
import { buildTxServiceUrl } from 'src/logic/safe/transactions'
|
||||||
import { buildIncomingTxServiceUrl } from 'src/logic/safe/transactions/incomingTxHistory'
|
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 { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
|
||||||
import { IncomingTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadIncomingTransactions'
|
import { IncomingTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadIncomingTransactions'
|
||||||
import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
|
import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
|
||||||
|
import { ModuleTxServiceModel } from './loadModuleTransactions'
|
||||||
|
|
||||||
const getServiceUrl = (txType: string, safeAddress: string): string => {
|
const getServiceUrl = (txType: string, safeAddress: string): string => {
|
||||||
return {
|
return {
|
||||||
[TransactionTypes.INCOMING]: buildIncomingTxServiceUrl,
|
[TransactionTypes.INCOMING]: buildIncomingTxServiceUrl,
|
||||||
[TransactionTypes.OUTGOING]: buildTxServiceUrl,
|
[TransactionTypes.OUTGOING]: buildTxServiceUrl,
|
||||||
|
[TransactionTypes.MODULE]: buildModuleTxServiceUrl,
|
||||||
}[txType](safeAddress)
|
}[txType](safeAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Remove this magic
|
// TODO: Remove this magic
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
async function fetchTransactions(
|
||||||
|
txType: TransactionTypes.MODULE,
|
||||||
|
safeAddress: string,
|
||||||
|
eTag: string | null,
|
||||||
|
): Promise<{ eTag: string | null; results: ModuleTxServiceModel[] }>
|
||||||
async function fetchTransactions(
|
async function fetchTransactions(
|
||||||
txType: TransactionTypes.INCOMING,
|
txType: TransactionTypes.INCOMING,
|
||||||
safeAddress: string,
|
safeAddress: string,
|
||||||
|
@ -26,10 +34,10 @@ async function fetchTransactions(
|
||||||
eTag: string | null,
|
eTag: string | null,
|
||||||
): Promise<{ eTag: string | null; results: TxServiceModel[] }>
|
): Promise<{ eTag: string | null; results: TxServiceModel[] }>
|
||||||
async function fetchTransactions(
|
async function fetchTransactions(
|
||||||
txType: TransactionTypes.INCOMING | TransactionTypes.OUTGOING,
|
txType: TransactionTypes.MODULE | TransactionTypes.INCOMING | TransactionTypes.OUTGOING,
|
||||||
safeAddress: string,
|
safeAddress: string,
|
||||||
eTag: string | null,
|
eTag: string | null,
|
||||||
): Promise<{ eTag: string | null; results: TxServiceModel[] | IncomingTxServiceModel[] }> {
|
): Promise<{ eTag: string | null; results: ModuleTxServiceModel[] | TxServiceModel[] | IncomingTxServiceModel[] }> {
|
||||||
/* eslint-enable */
|
/* eslint-enable */
|
||||||
try {
|
try {
|
||||||
const url = getServiceUrl(txType, safeAddress)
|
const url = getServiceUrl(txType, safeAddress)
|
||||||
|
|
|
@ -3,9 +3,11 @@ import { ThunkAction, ThunkDispatch } from 'redux-thunk'
|
||||||
import { AnyAction } from 'redux'
|
import { AnyAction } from 'redux'
|
||||||
import { backOff } from 'exponential-backoff'
|
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 { loadIncomingTransactions } from './loadIncomingTransactions'
|
||||||
|
import { loadModuleTransactions } from './loadModuleTransactions'
|
||||||
import { loadOutgoingTransactions } from './loadOutgoingTransactions'
|
import { loadOutgoingTransactions } from './loadOutgoingTransactions'
|
||||||
|
|
||||||
import { addOrUpdateCancellationTransactions } from 'src/logic/safe/store/actions/transactions/addOrUpdateCancellationTransactions'
|
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) {
|
if (safeIncomingTxs?.size) {
|
||||||
dispatch(addIncomingTransactions(incomingTransactions))
|
dispatch(addIncomingTransactions(incomingTransactions))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moduleTransactions = await loadModuleTransactions(safeAddress)
|
||||||
|
|
||||||
|
if (moduleTransactions.length) {
|
||||||
|
dispatch(addModuleTransactions({ modules: moduleTransactions, safeAddress }))
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Error fetching transactions:', 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,
|
string,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export type SpendingLimit = {
|
||||||
|
delegate: string
|
||||||
|
token: string
|
||||||
|
amount: string
|
||||||
|
spent: string
|
||||||
|
resetTimeMin: string
|
||||||
|
lastResetMin: string
|
||||||
|
nonce: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SafeRecordProps = {
|
export type SafeRecordProps = {
|
||||||
name: string
|
name: string
|
||||||
address: string
|
address: string
|
||||||
|
@ -20,6 +30,7 @@ export type SafeRecordProps = {
|
||||||
ethBalance: string
|
ethBalance: string
|
||||||
owners: List<SafeOwner>
|
owners: List<SafeOwner>
|
||||||
modules?: ModulePair[] | null
|
modules?: ModulePair[] | null
|
||||||
|
spendingLimits?: SpendingLimit[] | null
|
||||||
activeTokens: Set<string>
|
activeTokens: Set<string>
|
||||||
activeAssets: Set<string>
|
activeAssets: Set<string>
|
||||||
blacklistedTokens: Set<string>
|
blacklistedTokens: Set<string>
|
||||||
|
@ -40,6 +51,7 @@ const makeSafe = Record<SafeRecordProps>({
|
||||||
ethBalance: '0',
|
ethBalance: '0',
|
||||||
owners: List([]),
|
owners: List([]),
|
||||||
modules: [],
|
modules: [],
|
||||||
|
spendingLimits: [],
|
||||||
activeTokens: Set(),
|
activeTokens: Set(),
|
||||||
activeAssets: Set(),
|
activeAssets: Set(),
|
||||||
blacklistedTokens: Set(),
|
blacklistedTokens: Set(),
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
import { List, Map, RecordOf } from 'immutable'
|
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 { Confirmation } from './confirmation'
|
||||||
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
|
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
|
||||||
import { DataDecoded, Transfer } from './transactions'
|
import { DataDecoded, Transfer } from './transactions'
|
||||||
|
@ -14,6 +17,8 @@ export enum TransactionTypes {
|
||||||
UPGRADE = 'upgrade',
|
UPGRADE = 'upgrade',
|
||||||
TOKEN = 'token',
|
TOKEN = 'token',
|
||||||
COLLECTIBLE = 'collectible',
|
COLLECTIBLE = 'collectible',
|
||||||
|
MODULE = 'module',
|
||||||
|
SPENDING_LIMIT = 'spendingLimit',
|
||||||
}
|
}
|
||||||
export type TransactionTypeValues = typeof TransactionTypes[keyof typeof TransactionTypes]
|
export type TransactionTypeValues = typeof TransactionTypes[keyof typeof TransactionTypes]
|
||||||
|
|
||||||
|
@ -47,7 +52,7 @@ export type TransactionProps = {
|
||||||
data: string | null
|
data: string | null
|
||||||
dataDecoded: DataDecoded | null
|
dataDecoded: DataDecoded | null
|
||||||
decimals?: (number | string) | null
|
decimals?: (number | string) | null
|
||||||
decodedParams: DecodedParams | null
|
decodedParams: DecodedParams
|
||||||
executionDate?: string | null
|
executionDate?: string | null
|
||||||
executionTxHash?: string | null
|
executionTxHash?: string | null
|
||||||
executor: string
|
executor: string
|
||||||
|
@ -101,3 +106,17 @@ export type TxArgs = {
|
||||||
to: string
|
to: string
|
||||||
valueInWei: 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',
|
SWAP_OWNER: 'swapOwner',
|
||||||
ENABLE_MODULE: 'enableModule',
|
ENABLE_MODULE: 'enableModule',
|
||||||
DISABLE_MODULE: 'disableModule',
|
DISABLE_MODULE: 'disableModule',
|
||||||
}
|
} as const
|
||||||
|
|
||||||
export const METHOD_TO_ID = {
|
export const SAFE_METHOD_ID_TO_NAME = {
|
||||||
'0xe318b52b': SAFE_METHODS_NAMES.SWAP_OWNER,
|
'0xe318b52b': SAFE_METHODS_NAMES.SWAP_OWNER,
|
||||||
'0x0d582f13': SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD,
|
'0x0d582f13': SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD,
|
||||||
'0xf8dc5dd9': SAFE_METHODS_NAMES.REMOVE_OWNER,
|
'0xf8dc5dd9': SAFE_METHODS_NAMES.REMOVE_OWNER,
|
||||||
'0x694e80c3': SAFE_METHODS_NAMES.CHANGE_THRESHOLD,
|
'0x694e80c3': SAFE_METHODS_NAMES.CHANGE_THRESHOLD,
|
||||||
'0x610b5925': SAFE_METHODS_NAMES.ENABLE_MODULE,
|
'0x610b5925': SAFE_METHODS_NAMES.ENABLE_MODULE,
|
||||||
'0xe009cfde': SAFE_METHODS_NAMES.DISABLE_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]
|
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',
|
SAFE_TRANSFER_FROM: 'safeTransferFrom',
|
||||||
} as const
|
} 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 TokenMethods = typeof TOKEN_TRANSFER_METHODS_NAMES[keyof typeof TOKEN_TRANSFER_METHODS_NAMES]
|
||||||
|
|
||||||
type SafeDecodedParams = {
|
export type SafeDecodedParams = {
|
||||||
[key in SafeMethods]?: Record<string, string>
|
[key in SafeMethods]?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenDecodedParams = {
|
export type TokenDecodedParams = {
|
||||||
[key in TokenMethods]?: Record<string, string>
|
[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 safeFeaturesEnabledSelector = createSelector(safeSelector, safeFieldSelector('featuresEnabled'))
|
||||||
|
|
||||||
|
export const safeSpendingLimitsSelector = createSelector(safeSelector, safeFieldSelector('spendingLimits'))
|
||||||
|
|
||||||
export const safeOwnersAddressesListSelector = createSelector(
|
export const safeOwnersAddressesListSelector = createSelector(
|
||||||
safeOwnersSelector,
|
safeOwnersSelector,
|
||||||
(owners): List<string> => {
|
(owners): List<string> => {
|
||||||
|
|
|
@ -2,10 +2,13 @@ import { List } from 'immutable'
|
||||||
import { createSelector } from 'reselect'
|
import { createSelector } from 'reselect'
|
||||||
|
|
||||||
import { safeIncomingTransactionsSelector, safeTransactionsSelector } from 'src/logic/safe/store/selectors'
|
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(
|
export const extendedTransactionsSelector = createSelector(
|
||||||
safeTransactionsSelector,
|
safeTransactionsSelector,
|
||||||
safeIncomingTransactionsSelector,
|
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',
|
STANDARD_TX: 'STANDARD_TX',
|
||||||
CONFIRMATION_TX: 'CONFIRMATION_TX',
|
CONFIRMATION_TX: 'CONFIRMATION_TX',
|
||||||
CANCELLATION_TX: 'CANCELLATION_TX',
|
CANCELLATION_TX: 'CANCELLATION_TX',
|
||||||
WAITING_TX: 'WAITING_TX',
|
WAITING_TX: 'WAITING_TX',
|
||||||
SETTINGS_CHANGE_TX: 'SETTINGS_CHANGE_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',
|
SAFE_NAME_CHANGE_TX: 'SAFE_NAME_CHANGE_TX',
|
||||||
OWNER_NAME_CHANGE_TX: 'OWNER_NAME_CHANGE_TX',
|
OWNER_NAME_CHANGE_TX: 'OWNER_NAME_CHANGE_TX',
|
||||||
ADDRESSBOOK_NEW_ENTRY: 'ADDRESSBOOK_NEW_ENTRY',
|
ADDRESSBOOK_NEW_ENTRY: 'ADDRESSBOOK_NEW_ENTRY',
|
||||||
|
ADDRESSBOOK_EDIT_ENTRY: 'ADDRESSBOOK_EDIT_ENTRY',
|
||||||
ADDRESSBOOK_DELETE_ENTRY: 'ADDRESSBOOK_DELETE_ENTRY',
|
ADDRESSBOOK_DELETE_ENTRY: 'ADDRESSBOOK_DELETE_ENTRY',
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import semverLessThan from 'semver/functions/lt'
|
import semverLessThan from 'semver/functions/lt'
|
||||||
|
|
||||||
import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts'
|
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 { 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'
|
import { SafeInfo } from 'src/logic/safe/utils/safeInformation'
|
||||||
|
|
||||||
type ModulesPaginated = {
|
type ModulesPaginated = {
|
||||||
|
@ -97,3 +99,20 @@ export const getDisableModuleTxData = (modulePair: ModulePair, safeAddress: stri
|
||||||
|
|
||||||
return safeInstance.methods.disableModule(previousModule, module).encodeABI()
|
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 { getWeb3 } from 'src/logic/wallets/getWeb3'
|
||||||
import { MultiSend } from 'src/types/contracts/MultiSend.d'
|
import { MultiSend } from 'src/types/contracts/MultiSend.d'
|
||||||
|
|
||||||
interface MultiSendTx {
|
export interface MultiSendTx {
|
||||||
operation: number
|
operation: number
|
||||||
to: string
|
to: string
|
||||||
value: number
|
value: number
|
||||||
|
|
|
@ -6,7 +6,7 @@ export type TokenProps = {
|
||||||
symbol: string
|
symbol: string
|
||||||
decimals: number | string
|
decimals: number | string
|
||||||
logoUri: string
|
logoUri: string
|
||||||
balance?: number | string
|
balance: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeToken = Record<TokenProps>({
|
export const makeToken = Record<TokenProps>({
|
||||||
|
@ -15,7 +15,7 @@ export const makeToken = Record<TokenProps>({
|
||||||
symbol: '',
|
symbol: '',
|
||||||
decimals: 0,
|
decimals: 0,
|
||||||
logoUri: '',
|
logoUri: '',
|
||||||
balance: undefined,
|
balance: 0,
|
||||||
})
|
})
|
||||||
// balance is only set in extendedSafeTokensSelector when we display user's token balances
|
// 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 { AbiItem } from 'web3-utils'
|
||||||
|
|
||||||
import { getNetworkInfo } from 'src/config'
|
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 { isEmptyData } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
|
||||||
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
|
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
|
||||||
import { CALL } from 'src/logic/safe/transactions'
|
import { CALL } from 'src/logic/safe/transactions'
|
||||||
|
import { sameAddress } from 'src/logic/wallets/ethAddresses'
|
||||||
|
|
||||||
export const getEthAsToken = (balance: string | number): Token => {
|
export const getEthAsToken = (balance: string | number): Token => {
|
||||||
const { nativeCoin } = getNetworkInfo()
|
const { nativeCoin } = getNetworkInfo()
|
||||||
|
@ -82,3 +84,32 @@ export const isSendERC20Transaction = async (tx: TxServiceModel): Promise<boolea
|
||||||
|
|
||||||
return isSendTokenTx
|
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 {
|
export interface AddressBookProps {
|
||||||
fieldMutator: (address: string) => void
|
fieldMutator: (address: string) => void
|
||||||
|
label?: string
|
||||||
pristine?: boolean
|
pristine?: boolean
|
||||||
recipientAddress?: string
|
recipientAddress?: string
|
||||||
setIsValidAddress: (valid: boolean) => void
|
setIsValidAddress: (valid: boolean) => void
|
||||||
|
@ -36,6 +37,7 @@ export interface BaseAddressBookInputProps extends AddressBookProps {
|
||||||
const BaseAddressBookInput = ({
|
const BaseAddressBookInput = ({
|
||||||
addressBookEntries,
|
addressBookEntries,
|
||||||
fieldMutator,
|
fieldMutator,
|
||||||
|
label = 'Recipient',
|
||||||
setIsValidAddress,
|
setIsValidAddress,
|
||||||
setSelectedEntry,
|
setSelectedEntry,
|
||||||
setValidationText,
|
setValidationText,
|
||||||
|
@ -137,7 +139,7 @@ const BaseAddressBookInput = ({
|
||||||
fullWidth
|
fullWidth
|
||||||
id="filled-error-helper-text"
|
id="filled-error-helper-text"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
label={validationText ? validationText : 'Recipient'}
|
label={validationText ? validationText : label}
|
||||||
InputLabelProps={{ shrink: true, required: true, classes: labelStyles }}
|
InputLabelProps={{ shrink: true, required: true, classes: labelStyles }}
|
||||||
InputProps={{ ...params.InputProps, classes: inputStyles }}
|
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 SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
|
||||||
import { ContractsAddressBookInput } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
|
import { ContractsAddressBookInput } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
|
||||||
import { sm } from 'src/theme/variables'
|
import { sm } from 'src/theme/variables'
|
||||||
|
import { sameString } from 'src/utils/strings'
|
||||||
|
|
||||||
import ArrowDown from '../../assets/arrow-down.svg'
|
import ArrowDown from '../../assets/arrow-down.svg'
|
||||||
|
|
||||||
|
@ -147,7 +148,7 @@ const SendCustomTx: React.FC<Props> = ({ initialValues, onClose, onNext, contrac
|
||||||
{selectedEntry && selectedEntry.address ? (
|
{selectedEntry && selectedEntry.address ? (
|
||||||
<div
|
<div
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Tab') {
|
if (sameString(e.key, 'Tab')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSelectedEntry(null)
|
setSelectedEntry(null)
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import IconButton from '@material-ui/core/IconButton'
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import Close from '@material-ui/icons/Close'
|
import Close from '@material-ui/icons/Close'
|
||||||
import { BigNumber } from 'bignumber.js'
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
import { toTokenUnit, fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
|
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 Img from 'src/components/layout/Img'
|
||||||
import Paragraph from 'src/components/layout/Paragraph'
|
import Paragraph from 'src/components/layout/Paragraph'
|
||||||
import Row from 'src/components/layout/Row'
|
import Row from 'src/components/layout/Row'
|
||||||
|
import { getSpendingLimitContract } from 'src/logic/contracts/safeContracts'
|
||||||
import createTransaction from 'src/logic/safe/store/actions/createTransaction'
|
import createTransaction from 'src/logic/safe/store/actions/createTransaction'
|
||||||
import { safeSelector } from 'src/logic/safe/store/selectors'
|
import { safeSelector } from 'src/logic/safe/store/selectors'
|
||||||
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
|
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
|
||||||
import { estimateTxGasCosts } from 'src/logic/safe/transactions/gas'
|
import { estimateTxGasCosts } from 'src/logic/safe/transactions/gas'
|
||||||
import { getHumanFriendlyToken } from 'src/logic/tokens/store/actions/fetchTokens'
|
import { getHumanFriendlyToken } from 'src/logic/tokens/store/actions/fetchTokens'
|
||||||
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
|
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 { EMPTY_DATA } from 'src/logic/wallets/ethTransactions'
|
||||||
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
|
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
|
||||||
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
|
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
|
||||||
import { extendedSafeTokensSelector } from 'src/routes/safe/container/selector'
|
import { extendedSafeTokensSelector } from 'src/routes/safe/container/selector'
|
||||||
|
import { SpendingLimit } from 'src/logic/safe/store/models/safe'
|
||||||
import { sm } from 'src/theme/variables'
|
import { sm } from 'src/theme/variables'
|
||||||
|
import { sameString } from 'src/utils/strings'
|
||||||
|
|
||||||
import ArrowDown from '../assets/arrow-down.svg'
|
import ArrowDown from '../assets/arrow-down.svg'
|
||||||
|
|
||||||
|
@ -42,6 +45,8 @@ export type ReviewTxProp = {
|
||||||
amount: string
|
amount: string
|
||||||
txRecipient: string
|
txRecipient: string
|
||||||
token: string
|
token: string
|
||||||
|
txType?: string
|
||||||
|
tokenSpendingLimit?: SpendingLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReviewTxProps = {
|
type ReviewTxProps = {
|
||||||
|
@ -58,8 +63,8 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
|
||||||
const [gasCosts, setGasCosts] = useState('< 0.001')
|
const [gasCosts, setGasCosts] = useState('< 0.001')
|
||||||
const [data, setData] = useState('')
|
const [data, setData] = useState('')
|
||||||
|
|
||||||
const txToken = useMemo(() => tokens.find((token) => token.address === tx.token), [tokens, tx.token])
|
const txToken = useMemo(() => tokens.find((token) => sameAddress(token.address, tx.token)), [tokens, tx.token])
|
||||||
const isSendingETH = txToken?.address === nativeCoin.address
|
const isSendingETH = sameAddress(txToken?.address, nativeCoin.address)
|
||||||
const txRecipient = isSendingETH ? tx.recipientAddress : txToken?.address
|
const txRecipient = isSendingETH ? tx.recipientAddress : txToken?.address
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -75,8 +80,7 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
|
||||||
if (!isSendingETH) {
|
if (!isSendingETH) {
|
||||||
const StandardToken = await getHumanFriendlyToken()
|
const StandardToken = await getHumanFriendlyToken()
|
||||||
const tokenInstance = await StandardToken.at(txToken.address as string)
|
const tokenInstance = await StandardToken.at(txToken.address as string)
|
||||||
const decimals = await tokenInstance.decimals()
|
const txAmount = toTokenUnit(tx.amount, txToken.decimals)
|
||||||
const txAmount = new BigNumber(tx.amount).times(10 ** decimals.toNumber()).toString()
|
|
||||||
|
|
||||||
txData = tokenInstance.contract.methods.transfer(tx.recipientAddress, txAmount).encodeABI()
|
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])
|
}, [isSendingETH, safeAddress, tx.amount, tx.recipientAddress, txRecipient, txToken])
|
||||||
|
|
||||||
const submitTx = async () => {
|
const submitTx = async () => {
|
||||||
|
const isSpendingLimit = sameString(tx.txType, 'spendingLimit')
|
||||||
// txAmount should be 0 if we send tokens
|
// txAmount should be 0 if we send tokens
|
||||||
// the real value is encoded in txData and will be used by the contract
|
// the real value is encoded in txData and will be used by the contract
|
||||||
// if txAmount > 0 it would send ETH from the Safe
|
// if txAmount > 0 it would send ETH from the Safe
|
||||||
const txAmount = isSendingETH ? toTokenUnit(tx.amount, nativeCoin.decimals) : '0'
|
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(
|
dispatch(
|
||||||
createTransaction({
|
createTransaction({
|
||||||
safeAddress: safeAddress,
|
safeAddress: safeAddress,
|
||||||
|
@ -114,10 +140,8 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
|
||||||
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
|
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
onClose()
|
||||||
console.error('There was an error trying to submit the transaction, the safeAddress was not found')
|
|
||||||
}
|
}
|
||||||
onClose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { NFTToken } from 'src/logic/collectibles/sources/collectibles.d'
|
||||||
import { getExplorerInfo } from 'src/config'
|
import { getExplorerInfo } from 'src/config'
|
||||||
import { sameAddress } from 'src/logic/wallets/ethAddresses'
|
import { sameAddress } from 'src/logic/wallets/ethAddresses'
|
||||||
import { sm } from 'src/theme/variables'
|
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'
|
import ArrowDown from 'src/routes/safe/components/Balances/SendModal/screens/assets/arrow-down.svg'
|
||||||
|
|
||||||
|
@ -170,7 +171,7 @@ const SendCollectible = ({
|
||||||
{selectedEntry && selectedEntry.address ? (
|
{selectedEntry && selectedEntry.address ? (
|
||||||
<div
|
<div
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Tab') {
|
if (sameString(e.key, 'Tab')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSelectedEntry({ address: '', name: '' })
|
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 ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||||
import ListItemText from '@material-ui/core/ListItemText'
|
import ListItemText from '@material-ui/core/ListItemText'
|
||||||
import MenuItem from '@material-ui/core/MenuItem'
|
import MenuItem from '@material-ui/core/MenuItem'
|
||||||
import { withStyles } from '@material-ui/core/styles'
|
import { List } from 'immutable'
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
|
|
||||||
import { selectStyles, selectedTokenStyles } from './style'
|
|
||||||
|
|
||||||
import Field from 'src/components/forms/Field'
|
import Field from 'src/components/forms/Field'
|
||||||
import SelectField from 'src/components/forms/SelectField'
|
import SelectField from 'src/components/forms/SelectField'
|
||||||
import { required } from 'src/components/forms/validator'
|
import { required } from 'src/components/forms/validator'
|
||||||
import Img from 'src/components/layout/Img'
|
import Img from 'src/components/layout/Img'
|
||||||
import Paragraph from 'src/components/layout/Paragraph'
|
|
||||||
|
|
||||||
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
|
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
|
||||||
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
|
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)
|
const token = tokens.find(({ address }) => address === tokenAddress)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -28,43 +34,52 @@ const SelectedToken = ({ classes, tokenAddress, tokens }) => {
|
||||||
<ListItemText
|
<ListItemText
|
||||||
className={classes.tokenData}
|
className={classes.tokenData}
|
||||||
primary={token.name}
|
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*
|
Select an asset*
|
||||||
</Paragraph>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const SelectedTokenStyled = withStyles(selectedTokenStyles)(SelectedToken)
|
|
||||||
|
|
||||||
const TokenSelectField = ({ classes, initialValue, isValid, tokens }) => (
|
interface TokenSelectFieldProps {
|
||||||
<Field
|
initialValue?: string
|
||||||
classes={{ selectMenu: classes.selectMenu }}
|
isValid?: boolean
|
||||||
className={isValid ? 'isValid' : 'isInvalid'}
|
tokens: List<Token>
|
||||||
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>
|
|
||||||
)
|
|
||||||
|
|
||||||
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'
|
import { sm } from 'src/theme/variables'
|
||||||
|
|
||||||
export const selectedTokenStyles = () => ({
|
export const useSelectedTokenStyles = makeStyles(
|
||||||
container: {
|
createStyles({
|
||||||
minHeight: '55px',
|
container: {
|
||||||
padding: 0,
|
minHeight: '55px',
|
||||||
width: '100%',
|
padding: 0,
|
||||||
},
|
width: '100%',
|
||||||
tokenData: {
|
},
|
||||||
padding: 0,
|
tokenData: {
|
||||||
margin: 0,
|
padding: 0,
|
||||||
lineHeight: '14px',
|
margin: 0,
|
||||||
},
|
lineHeight: '14px',
|
||||||
tokenImage: {
|
},
|
||||||
marginRight: sm,
|
tokenImage: {
|
||||||
},
|
marginRight: sm,
|
||||||
})
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const selectStyles = () => ({
|
export const useSelectStyles = makeStyles(
|
||||||
selectMenu: {
|
createStyles({
|
||||||
paddingRight: 0,
|
selectMenu: {
|
||||||
},
|
paddingRight: 0,
|
||||||
})
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
|
@ -2,11 +2,11 @@ import IconButton from '@material-ui/core/IconButton'
|
||||||
import InputAdornment from '@material-ui/core/InputAdornment'
|
import InputAdornment from '@material-ui/core/InputAdornment'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import Close from '@material-ui/icons/Close'
|
import Close from '@material-ui/icons/Close'
|
||||||
import { getExplorerInfo, getNetworkInfo } from 'src/config'
|
import { BigNumber } from 'bignumber.js'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
import { OnChange } from 'react-final-form-listeners'
|
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
|
|
||||||
|
import { getExplorerInfo } from 'src/config'
|
||||||
import Field from 'src/components/forms/Field'
|
import Field from 'src/components/forms/Field'
|
||||||
import GnoForm from 'src/components/forms/GnoForm'
|
import GnoForm from 'src/components/forms/GnoForm'
|
||||||
import TextField from 'src/components/forms/TextField'
|
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 { addressBookSelector } from 'src/logic/addressBook/store/selectors'
|
||||||
import { getNameFromAddressBook } from 'src/logic/addressBook/utils'
|
import { getNameFromAddressBook } from 'src/logic/addressBook/utils'
|
||||||
import { sameAddress } from 'src/logic/wallets/ethAddresses'
|
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 SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
|
||||||
import { AddressBookInput } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
|
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 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 { extendedSafeTokensSelector } from 'src/routes/safe/container/selector'
|
||||||
|
import { safeSpendingLimitsSelector } from 'src/logic/safe/store/selectors'
|
||||||
import { sm } from 'src/theme/variables'
|
import { sm } from 'src/theme/variables'
|
||||||
|
import { sameString } from 'src/utils/strings'
|
||||||
|
|
||||||
import ArrowDown from '../assets/arrow-down.svg'
|
import ArrowDown from '../assets/arrow-down.svg'
|
||||||
|
|
||||||
import { styles } from './style'
|
import { styles } from './style'
|
||||||
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
|
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 = {
|
const formMutators = {
|
||||||
setMax: (args, state, utils) => {
|
setMax: (args, state, utils) => {
|
||||||
|
@ -44,10 +52,21 @@ const formMutators = {
|
||||||
setRecipient: (args, state, utils) => {
|
setRecipient: (args, state, utils) => {
|
||||||
utils.changeValue(state, 'recipientAddress', () => args[0])
|
utils.changeValue(state, 'recipientAddress', () => args[0])
|
||||||
},
|
},
|
||||||
|
setTxType: (args, state, utils) => {
|
||||||
|
utils.changeValue(state, 'txType', () => args[0])
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles(styles)
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
export type SendFundsTx = {
|
||||||
|
amount?: string
|
||||||
|
recipientAddress?: string
|
||||||
|
token?: string
|
||||||
|
txType?: string
|
||||||
|
tokenSpendingLimit?: SpendingLimit
|
||||||
|
}
|
||||||
|
|
||||||
type SendFundsProps = {
|
type SendFundsProps = {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onNext: (txInfo: unknown) => void
|
onNext: (txInfo: unknown) => void
|
||||||
|
@ -56,15 +75,7 @@ type SendFundsProps = {
|
||||||
amount?: string
|
amount?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const { nativeCoin } = getNetworkInfo()
|
const SendFunds = ({ onClose, onNext, recipientAddress, selectedToken = '', amount }: SendFundsProps): ReactElement => {
|
||||||
|
|
||||||
const SendFunds = ({
|
|
||||||
onClose,
|
|
||||||
onNext,
|
|
||||||
recipientAddress,
|
|
||||||
selectedToken = '',
|
|
||||||
amount,
|
|
||||||
}: SendFundsProps): React.ReactElement => {
|
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const tokens = useSelector(extendedSafeTokensSelector)
|
const tokens = useSelector(extendedSafeTokensSelector)
|
||||||
const addressBook = useSelector(addressBookSelector)
|
const addressBook = useSelector(addressBookSelector)
|
||||||
|
@ -97,13 +108,42 @@ const SendFunds = ({
|
||||||
}
|
}
|
||||||
}, [selectedEntry, pristine])
|
}, [selectedEntry, pristine])
|
||||||
|
|
||||||
|
let tokenSpendingLimit
|
||||||
const handleSubmit = (values) => {
|
const handleSubmit = (values) => {
|
||||||
const submitValues = values
|
const submitValues = values
|
||||||
// If the input wasn't modified, there was no mutation of the recipientAddress
|
// If the input wasn't modified, there was no mutation of the recipientAddress
|
||||||
if (!values.recipientAddress) {
|
if (!values.recipientAddress) {
|
||||||
submitValues.recipientAddress = selectedEntry?.address
|
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 (
|
return (
|
||||||
|
@ -122,12 +162,19 @@ const SendFunds = ({
|
||||||
formMutators={formMutators}
|
formMutators={formMutators}
|
||||||
initialValues={{ amount, recipientAddress, token: selectedToken }}
|
initialValues={{ amount, recipientAddress, token: selectedToken }}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
validation={sendFundsValidation}
|
||||||
>
|
>
|
||||||
{(...args) => {
|
{(...args) => {
|
||||||
const formState = args[2]
|
const formState = args[2]
|
||||||
const mutators = args[3]
|
const mutators = args[3]
|
||||||
const { token: tokenAddress } = formState.values
|
const { token: tokenAddress, txType } = formState.values
|
||||||
const selectedTokenRecord = tokens.find((token) => token.address === tokenAddress)
|
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) => {
|
const handleScan = (value, closeQrModal) => {
|
||||||
let scannedAddress = value
|
let scannedAddress = value
|
||||||
|
@ -149,6 +196,22 @@ const SendFunds = ({
|
||||||
shouldDisableSubmitButton = !selectedEntry.address
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Block className={classes.formContainer}>
|
<Block className={classes.formContainer}>
|
||||||
|
@ -164,7 +227,7 @@ const SendFunds = ({
|
||||||
{selectedEntry && selectedEntry.address ? (
|
{selectedEntry && selectedEntry.address ? (
|
||||||
<div
|
<div
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Tab') {
|
if (sameString(e.key, 'Tab')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSelectedEntry({ address: '', name: '' })
|
setSelectedEntry({ address: '', name: '' })
|
||||||
|
@ -208,22 +271,21 @@ const SendFunds = ({
|
||||||
<Row margin="sm">
|
<Row margin="sm">
|
||||||
<Col>
|
<Col>
|
||||||
<TokenSelectField
|
<TokenSelectField
|
||||||
initialValue={selectedToken}
|
initialValue={selectedToken?.address}
|
||||||
isValid={tokenAddress && String(tokenAddress).toUpperCase() !== nativeCoin.name.toUpperCase()}
|
isValid={!!selectedToken?.address}
|
||||||
tokens={tokens}
|
tokens={tokens}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
{tokenSpendingLimit && selectedToken && (
|
||||||
|
<SpendingLimitRow selectedToken={selectedToken} tokenSpendingLimit={tokenSpendingLimit} />
|
||||||
|
)}
|
||||||
<Row margin="xs">
|
<Row margin="xs">
|
||||||
<Col between="lg">
|
<Col between="lg">
|
||||||
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
|
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
|
||||||
Amount
|
Amount
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<ButtonLink
|
<ButtonLink onClick={setMaxAllowedAmount} weight="bold" testId="send-max-btn">
|
||||||
onClick={() => mutators.setMax(selectedTokenRecord?.balance)}
|
|
||||||
weight="bold"
|
|
||||||
testId="send-max-btn"
|
|
||||||
>
|
|
||||||
Send max
|
Send max
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -232,24 +294,15 @@ const SendFunds = ({
|
||||||
<Col>
|
<Col>
|
||||||
<Field
|
<Field
|
||||||
component={TextField}
|
component={TextField}
|
||||||
inputAdornment={
|
inputAdornment={{
|
||||||
selectedTokenRecord && {
|
endAdornment: <InputAdornment position="end">{selectedToken?.symbol}</InputAdornment>,
|
||||||
endAdornment: <InputAdornment position="end">{selectedTokenRecord.symbol}</InputAdornment>,
|
}}
|
||||||
}
|
|
||||||
}
|
|
||||||
name="amount"
|
name="amount"
|
||||||
placeholder="Amount*"
|
placeholder="Amount*"
|
||||||
text="Amount*"
|
text="Amount*"
|
||||||
type="text"
|
type="text"
|
||||||
testId="amount-input"
|
testId="amount-input"
|
||||||
validate={composeValidators(
|
|
||||||
required,
|
|
||||||
mustBeFloat,
|
|
||||||
minValue(0, false),
|
|
||||||
maxValue(selectedTokenRecord?.balance || 0),
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<OnChange name="token">{() => mutators.onTokenChange()}</OnChange>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Block>
|
</Block>
|
||||||
|
@ -262,7 +315,7 @@ const SendFunds = ({
|
||||||
className={classes.submitButton}
|
className={classes.submitButton}
|
||||||
color="primary"
|
color="primary"
|
||||||
data-testid="review-tx-btn"
|
data-testid="review-tx-btn"
|
||||||
disabled={shouldDisableSubmitButton}
|
disabled={!formState.valid || shouldDisableSubmitButton}
|
||||||
minWidth={140}
|
minWidth={140}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="contained"
|
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 { useSelector } from 'react-redux'
|
||||||
|
|
||||||
import Advanced from './Advanced'
|
import Advanced from './Advanced'
|
||||||
|
import SpendingLimitSettings from './SpendingLimit'
|
||||||
import ManageOwners from './ManageOwners'
|
import ManageOwners from './ManageOwners'
|
||||||
import { RemoveSafeModal } from './RemoveSafeModal'
|
import { RemoveSafeModal } from './RemoveSafeModal'
|
||||||
import SafeDetails from './SafeDetails'
|
import SafeDetails from './SafeDetails'
|
||||||
|
@ -118,12 +119,22 @@ const Settings: React.FC = () => {
|
||||||
</Row>
|
</Row>
|
||||||
<Hairline className={classes.hairline} />
|
<Hairline className={classes.hairline} />
|
||||||
<Row className={cn(classes.menuOption, menuOptionIndex === 4 && classes.active)} onClick={handleChange(4)}>
|
<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
|
<IconText
|
||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
textSize="xl"
|
textSize="xl"
|
||||||
iconType="settingsTool"
|
iconType="settingsTool"
|
||||||
text="Advanced"
|
text="Advanced"
|
||||||
color={menuOptionIndex === 4 ? 'primary' : 'secondary'}
|
color={menuOptionIndex === 5 ? 'primary' : 'secondary'}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Hairline className={classes.hairline} />
|
<Hairline className={classes.hairline} />
|
||||||
|
@ -134,7 +145,8 @@ const Settings: React.FC = () => {
|
||||||
{menuOptionIndex === 1 && <SafeDetails />}
|
{menuOptionIndex === 1 && <SafeDetails />}
|
||||||
{menuOptionIndex === 2 && <ManageOwners addressBook={addressBook} granted={granted} owners={owners} />}
|
{menuOptionIndex === 2 && <ManageOwners addressBook={addressBook} granted={granted} owners={owners} />}
|
||||||
{menuOptionIndex === 3 && <ThresholdSettings />}
|
{menuOptionIndex === 3 && <ThresholdSettings />}
|
||||||
{menuOptionIndex === 4 && <Advanced />}
|
{menuOptionIndex === 4 && <SpendingLimitSettings />}
|
||||||
|
{menuOptionIndex === 5 && <Advanced />}
|
||||||
</Block>
|
</Block>
|
||||||
</Col>
|
</Col>
|
||||||
</Block>
|
</Block>
|
||||||
|
|
|
@ -91,7 +91,7 @@ const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
type ownersColumnProps = {
|
type ownersColumnProps = {
|
||||||
tx: Transaction
|
tx: Transaction
|
||||||
cancelTx: Transaction
|
cancelTx?: Transaction
|
||||||
thresholdReached: boolean
|
thresholdReached: boolean
|
||||||
cancelThresholdReached: boolean
|
cancelThresholdReached: boolean
|
||||||
onTxConfirm: () => void
|
onTxConfirm: () => void
|
||||||
|
|
|
@ -7,6 +7,10 @@ import styled from 'styled-components'
|
||||||
import { styles } from './styles'
|
import { styles } from './styles'
|
||||||
import Value from './Value'
|
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 Block from 'src/components/layout/Block'
|
||||||
import {
|
import {
|
||||||
extractMultiSendDataDecoded,
|
extractMultiSendDataDecoded,
|
||||||
|
@ -20,12 +24,13 @@ import { getNameFromAddressBookSelector } from 'src/logic/addressBook/store/sele
|
||||||
import Paragraph from 'src/components/layout/Paragraph'
|
import Paragraph from 'src/components/layout/Paragraph'
|
||||||
import LinkWithRef from 'src/components/layout/Link'
|
import LinkWithRef from 'src/components/layout/Link'
|
||||||
import { shortVersionOf } from 'src/logic/wallets/ethAddresses'
|
import { shortVersionOf } from 'src/logic/wallets/ethAddresses'
|
||||||
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
|
import { Transaction, SafeModuleTransaction } from 'src/logic/safe/store/models/types/transaction'
|
||||||
import { DataDecoded } from 'src/routes/safe/store/models/types/transactions.d'
|
import { DataDecoded } from 'src/logic/safe/store/models/types/transactions.d'
|
||||||
import DividerLine from 'src/components/DividerLine'
|
import DividerLine from 'src/components/DividerLine'
|
||||||
import { isArrayParameter } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
|
import { isArrayParameter } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
|
||||||
|
|
||||||
import { getExplorerInfo, getNetworkInfo } from 'src/config'
|
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_VALUE_TEST_ID = 'tx-description-custom-value'
|
||||||
export const TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID = 'tx-description-custom-data'
|
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`
|
const TxDetailsContent = styled.div`
|
||||||
padding: 8px 8px 8px 16px;
|
padding: 8px 8px 8px 16px;
|
||||||
|
overflow-wrap: break-word;
|
||||||
`
|
`
|
||||||
|
|
||||||
const TxInfo = styled.div`
|
const TxInfo = styled.div`
|
||||||
|
@ -76,26 +82,103 @@ const TxInfoDetails = ({ data }: { data: DataDecoded }): React.ReactElement => (
|
||||||
</TxInfo>
|
</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 MultiSendCustomDataAction = ({ tx, order }: { tx: MultiSendDetails; order: number }): React.ReactElement => {
|
||||||
const classes = useStyles()
|
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 explorerUrl = getExplorerInfo(tx.to)
|
||||||
|
const { isSetSpendingLimit, isDeleteSpendingLimit } = spendingLimitTxType(tx.data)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<Collapse
|
||||||
collapseClassName={classes.collapse}
|
collapseClassName={classes.collapse}
|
||||||
headerWrapperClassName={classes.collapseHeaderWrapper}
|
headerWrapperClassName={classes.collapseHeaderWrapper}
|
||||||
title={<IconText iconSize="sm" iconType="code" text={`Action ${order + 1}${methodName}`} textSize="lg" />}
|
title={<IconText iconSize="sm" iconType="code" text={`Action ${order + 1}${methodName}`} textSize="lg" />}
|
||||||
>
|
>
|
||||||
<TxDetailsContent>
|
{isSetSpendingLimit || isDeleteSpendingLimit ? (
|
||||||
<TxInfo>
|
<TxDetailsContent>
|
||||||
<Bold>
|
{isSetSpendingLimit && <ModifySpendingLimitDetails data={data as DataDecoded} />}
|
||||||
Send {fromTokenUnit(tx.value, nativeCoin.decimals)} {nativeCoin.name} to:
|
{isDeleteSpendingLimit && <DeleteSpendingLimitDetails data={data as DataDecoded} />}
|
||||||
</Bold>
|
</TxDetailsContent>
|
||||||
<EthHashInfo hash={tx.to} showIdenticon showCopyBtn explorerUrl={explorerUrl} />
|
) : (
|
||||||
</TxInfo>
|
<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} />}
|
{!!data ? <TxInfoDetails data={data} /> : tx.data && <HexEncodedData data={tx.data} />}
|
||||||
</TxDetailsContent>
|
</TxDetailsContent>
|
||||||
|
)}
|
||||||
</Collapse>
|
</Collapse>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -173,45 +256,69 @@ const TxActionData = ({ dataDecoded }: { dataDecoded: DataDecoded }): React.Reac
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GenericCustomDataProps {
|
interface HexEncodedDataProps {
|
||||||
amount?: string
|
|
||||||
data: string
|
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 classes = useStyles()
|
||||||
const recipientName = useSelector((state) => getNameFromAddressBookSelector(state, recipient))
|
|
||||||
const explorerUrl = getExplorerInfo(recipient)
|
|
||||||
return (
|
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>
|
||||||
<Block data-testid={TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID}>
|
{recipient && (
|
||||||
<Bold>Send {amount} to:</Bold>
|
<Block data-testid={TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID}>
|
||||||
|
<Bold>Send {amount} to:</Bold>
|
||||||
|
|
||||||
<EthHashInfo
|
<EthHashInfo
|
||||||
hash={recipient}
|
hash={recipient}
|
||||||
name={recipientName === 'UNKNOWN' ? undefined : recipientName}
|
name={recipientName === 'UNKNOWN' ? undefined : recipientName}
|
||||||
showIdenticon
|
showIdenticon
|
||||||
showCopyBtn
|
showCopyBtn
|
||||||
explorerUrl={explorerUrl}
|
explorerUrl={explorerUrl}
|
||||||
/>
|
/>
|
||||||
</Block>
|
</Block>
|
||||||
|
)}
|
||||||
|
|
||||||
{!!storedTx?.dataDecoded && <TxActionData dataDecoded={storedTx.dataDecoded} />}
|
{!!txData ? <TxActionData dataDecoded={txData} /> : data && <HexEncodedData data={data} />}
|
||||||
|
|
||||||
<Block className={classes.txData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}>
|
|
||||||
<Bold>Data (hex encoded):</Bold>
|
|
||||||
<TxData data={data} />
|
|
||||||
</Block>
|
|
||||||
</Block>
|
</Block>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomDescriptionProps {
|
interface CustomDescriptionProps {
|
||||||
amount?: string
|
amount?: string
|
||||||
data: string
|
data?: string | null
|
||||||
recipient: string
|
recipient?: string
|
||||||
storedTx: Transaction
|
storedTx: Transaction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import { EtherscanLink } from 'src/components/EtherscanLink'
|
import { EtherscanLink } from 'src/components/EtherscanLink'
|
||||||
import Block from 'src/components/layout/Block'
|
import Block from 'src/components/layout/Block'
|
||||||
|
@ -20,7 +20,7 @@ interface RemovedOwnerProps {
|
||||||
removedOwner: string
|
removedOwner: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const RemovedOwner = ({ removedOwner }: RemovedOwnerProps): React.ReactElement => {
|
const RemovedOwner = ({ removedOwner }: RemovedOwnerProps): ReactElement => {
|
||||||
const ownerChangedName = useSelector((state) => getNameFromAddressBookSelector(state, removedOwner))
|
const ownerChangedName = useSelector((state) => getNameFromAddressBookSelector(state, removedOwner))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -39,7 +39,7 @@ interface AddedOwnerProps {
|
||||||
addedOwner: string
|
addedOwner: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddedOwner = ({ addedOwner }: AddedOwnerProps): React.ReactElement => {
|
const AddedOwner = ({ addedOwner }: AddedOwnerProps): ReactElement => {
|
||||||
const ownerChangedName = useSelector((state) => getNameFromAddressBookSelector(state, addedOwner))
|
const ownerChangedName = useSelector((state) => getNameFromAddressBookSelector(state, addedOwner))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -58,7 +58,7 @@ interface NewThresholdProps {
|
||||||
newThreshold: string
|
newThreshold: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const NewThreshold = ({ newThreshold }: NewThresholdProps): React.ReactElement => (
|
const NewThreshold = ({ newThreshold }: NewThresholdProps): ReactElement => (
|
||||||
<Block data-testid={TRANSACTIONS_DESC_CHANGE_THRESHOLD_TEST_ID}>
|
<Block data-testid={TRANSACTIONS_DESC_CHANGE_THRESHOLD_TEST_ID}>
|
||||||
<Bold>Change required confirmations:</Bold>
|
<Bold>Change required confirmations:</Bold>
|
||||||
<Paragraph noMargin size="md">
|
<Paragraph noMargin size="md">
|
||||||
|
@ -71,7 +71,7 @@ interface AddModuleProps {
|
||||||
module: string
|
module: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddModule = ({ module }: AddModuleProps): React.ReactElement => (
|
const AddModule = ({ module }: AddModuleProps): ReactElement => (
|
||||||
<Block data-testid={TRANSACTIONS_DESC_ADD_MODULE_TEST_ID}>
|
<Block data-testid={TRANSACTIONS_DESC_ADD_MODULE_TEST_ID}>
|
||||||
<Bold>Add module:</Bold>
|
<Bold>Add module:</Bold>
|
||||||
<EtherscanLink value={module} knownAddress={false} />
|
<EtherscanLink value={module} knownAddress={false} />
|
||||||
|
@ -82,7 +82,7 @@ interface RemoveModuleProps {
|
||||||
module: string
|
module: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const RemoveModule = ({ module }: RemoveModuleProps): React.ReactElement => (
|
const RemoveModule = ({ module }: RemoveModuleProps): ReactElement => (
|
||||||
<Block data-testid={TRANSACTIONS_DESC_REMOVE_MODULE_TEST_ID}>
|
<Block data-testid={TRANSACTIONS_DESC_REMOVE_MODULE_TEST_ID}>
|
||||||
<Bold>Remove module:</Bold>
|
<Bold>Remove module:</Bold>
|
||||||
<EtherscanLink value={module} knownAddress={false} />
|
<EtherscanLink value={module} knownAddress={false} />
|
||||||
|
@ -90,7 +90,7 @@ const RemoveModule = ({ module }: RemoveModuleProps): React.ReactElement => (
|
||||||
)
|
)
|
||||||
|
|
||||||
interface SettingsDescriptionProps {
|
interface SettingsDescriptionProps {
|
||||||
action: SafeMethods
|
action?: SafeMethods
|
||||||
addedOwner?: string
|
addedOwner?: string
|
||||||
newThreshold?: string
|
newThreshold?: string
|
||||||
removedOwner?: string
|
removedOwner?: string
|
||||||
|
@ -103,7 +103,7 @@ const SettingsDescription = ({
|
||||||
newThreshold,
|
newThreshold,
|
||||||
removedOwner,
|
removedOwner,
|
||||||
module,
|
module,
|
||||||
}: SettingsDescriptionProps): React.ReactElement => {
|
}: SettingsDescriptionProps): ReactElement => {
|
||||||
if (action === SAFE_METHODS_NAMES.REMOVE_OWNER && removedOwner && newThreshold) {
|
if (action === SAFE_METHODS_NAMES.REMOVE_OWNER && removedOwner && newThreshold) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import { EtherscanLink } from 'src/components/EtherscanLink'
|
import { EtherscanLink } from 'src/components/EtherscanLink'
|
||||||
import Block from 'src/components/layout/Block'
|
import Block from 'src/components/layout/Block'
|
||||||
|
@ -11,7 +11,7 @@ import SendModal from 'src/routes/safe/components/Balances/SendModal'
|
||||||
|
|
||||||
interface TransferDescriptionProps {
|
interface TransferDescriptionProps {
|
||||||
amountWithSymbol: string
|
amountWithSymbol: string
|
||||||
recipient: string
|
recipient?: string
|
||||||
tokenAddress?: string
|
tokenAddress?: string
|
||||||
rawAmount?: string
|
rawAmount?: string
|
||||||
isTokenTransfer: boolean
|
isTokenTransfer: boolean
|
||||||
|
@ -23,7 +23,7 @@ const TransferDescription = ({
|
||||||
tokenAddress,
|
tokenAddress,
|
||||||
rawAmount,
|
rawAmount,
|
||||||
isTokenTransfer,
|
isTokenTransfer,
|
||||||
}: TransferDescriptionProps): React.ReactElement => {
|
}: TransferDescriptionProps): ReactElement | null => {
|
||||||
const recipientName = useSelector((state) => getNameFromAddressBookSelector(state, recipient))
|
const recipientName = useSelector((state) => getNameFromAddressBookSelector(state, recipient))
|
||||||
const [sendModalOpen, setSendModalOpen] = React.useState(false)
|
const [sendModalOpen, setSendModalOpen] = React.useState(false)
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ const TransferDescription = ({
|
||||||
setSendModalOpen(true)
|
setSendModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return recipient ? (
|
||||||
<>
|
<>
|
||||||
<Block data-testid={TRANSACTIONS_DESC_SEND_TEST_ID}>
|
<Block data-testid={TRANSACTIONS_DESC_SEND_TEST_ID}>
|
||||||
<Bold>Send {amountWithSymbol} to:</Bold>
|
<Bold>Send {amountWithSymbol} to:</Bold>
|
||||||
|
@ -60,7 +60,7 @@ const TransferDescription = ({
|
||||||
tokenAmount={rawAmount}
|
tokenAmount={rawAmount}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TransferDescription
|
export default TransferDescription
|
||||||
|
|
|
@ -7,59 +7,45 @@ import SettingsDescription from './SettingsDescription'
|
||||||
import CustomDescription from './CustomDescription'
|
import CustomDescription from './CustomDescription'
|
||||||
import TransferDescription from './TransferDescription'
|
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 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'
|
export const TRANSACTIONS_DESC_SEND_TEST_ID = 'tx-description-send'
|
||||||
|
|
||||||
const useStyles = makeStyles(styles)
|
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 TxDescription = ({ tx }: { tx: Transaction }): React.ReactElement => {
|
||||||
const classes = useStyles()
|
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 (
|
return (
|
||||||
<Block className={classes.txDataContainer}>
|
<Block className={classes.txDataContainer}>
|
||||||
{modifySettingsTx && action && (
|
{tx.type === TransactionTypes.SETTINGS && <SettingsDescriptionTx tx={tx} />}
|
||||||
<SettingsDescription
|
{tx.type === TransactionTypes.CUSTOM && <CustomDescriptionTx tx={tx} />}
|
||||||
action={action}
|
{tx.type === TransactionTypes.UPGRADE && <UpgradeDescriptionTx tx={tx} />}
|
||||||
addedOwner={addedOwner}
|
{[TransactionTypes.TOKEN, TransactionTypes.COLLECTIBLE].includes(tx.type) && <TransferDescriptionTx tx={tx} />}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Block>
|
</Block>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
|
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 { sameString } from 'src/utils/strings'
|
||||||
import { getNetworkInfo } from 'src/config'
|
import { getNetworkInfo } from 'src/config'
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ interface TxData {
|
||||||
data?: string | null
|
data?: string | null
|
||||||
recipient?: string
|
recipient?: string
|
||||||
module?: string
|
module?: string
|
||||||
action?: string
|
action?: SafeMethods
|
||||||
addedOwner?: string
|
addedOwner?: string
|
||||||
removedOwner?: string
|
removedOwner?: string
|
||||||
newThreshold?: string
|
newThreshold?: string
|
||||||
|
@ -97,7 +97,7 @@ const getTxDataForTxsWithDecodedParams = (tx: Transaction): TxData => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tx.isTokenTransfer) {
|
if (tx.isTokenTransfer) {
|
||||||
const { to } = tx.decodedParams.transfer || {}
|
const { to } = (tx.decodedParams as TokenDecodedParams).transfer || {}
|
||||||
txData.recipient = to
|
txData.recipient = to
|
||||||
txData.isTokenTransfer = true
|
txData.isTokenTransfer = true
|
||||||
txData.tokenAddress = tx.recipient
|
txData.tokenAddress = tx.recipient
|
||||||
|
@ -105,7 +105,7 @@ const getTxDataForTxsWithDecodedParams = (tx: Transaction): TxData => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tx.isCollectibleTransfer) {
|
if (tx.isCollectibleTransfer) {
|
||||||
const { safeTransferFrom, transfer, transferFrom } = tx.decodedParams
|
const { safeTransferFrom, transfer, transferFrom } = tx.decodedParams as TokenDecodedParams
|
||||||
const { to, value } = safeTransferFrom || transferFrom || transfer || {}
|
const { to, value } = safeTransferFrom || transferFrom || transfer || {}
|
||||||
txData.recipient = to
|
txData.recipient = to
|
||||||
txData.tokenId = value
|
txData.tokenId = value
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
|
||||||
import cn from 'classnames'
|
import cn from 'classnames'
|
||||||
import React, { useState } from 'react'
|
import React, { ReactElement, useMemo, useState } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
|
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
|
||||||
|
|
||||||
|
@ -11,8 +10,15 @@ import TxDescription from './TxDescription'
|
||||||
import { IncomingTx } from './IncomingTx'
|
import { IncomingTx } from './IncomingTx'
|
||||||
import { CreationTx } from './CreationTx'
|
import { CreationTx } from './CreationTx'
|
||||||
import { OutgoingTx } from './OutgoingTx'
|
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 Block from 'src/components/layout/Block'
|
||||||
import Bold from 'src/components/layout/Bold'
|
import Bold from 'src/components/layout/Bold'
|
||||||
import Col from 'src/components/layout/Col'
|
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 Span from 'src/components/layout/Span'
|
||||||
import { getWeb3 } from 'src/logic/wallets/getWeb3'
|
import { getWeb3 } from 'src/logic/wallets/getWeb3'
|
||||||
import { INCOMING_TX_TYPES } from 'src/logic/safe/store/models/incomingTransaction'
|
import { INCOMING_TX_TYPES } from 'src/logic/safe/store/models/incomingTransaction'
|
||||||
import { safeNonceSelector, safeThresholdSelector } from 'src/logic/safe/store/selectors'
|
import { Transaction, TransactionTypes, SafeModuleTransaction } from 'src/logic/safe/store/models/types/transaction'
|
||||||
import { Transaction, TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
|
|
||||||
import IncomingTxDescription from './IncomingTxDescription'
|
import IncomingTxDescription from './IncomingTxDescription'
|
||||||
import { getExplorerInfo, getNetworkInfo } from 'src/config'
|
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 {
|
const recipient = useMemo(() => {
|
||||||
cancelTx: Transaction
|
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
|
tx: Transaction
|
||||||
}
|
}
|
||||||
|
|
||||||
const { nativeCoin } = getNetworkInfo()
|
const { nativeCoin } = getNetworkInfo()
|
||||||
|
|
||||||
const ExpandedTx = ({ cancelTx, tx }: ExpandedTxProps): React.ReactElement => {
|
const ExpandedSafeTx = ({ cancelTx, tx }: ExpandedSafeTxProps): ReactElement => {
|
||||||
const { fromWei, toBN } = getWeb3().utils
|
const { fromWei, toBN } = getWeb3().utils
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const nonce = useSelector(safeNonceSelector)
|
const nonce = useSelector(safeNonceSelector)
|
||||||
|
@ -122,7 +180,7 @@ const ExpandedTx = ({ cancelTx, tx }: ExpandedTxProps): React.ReactElement => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{openModal === 'rejectTx' && <RejectTxModal isOpen onClose={closeModal} tx={tx} />}
|
{openModal === 'rejectTx' && <RejectTxModal isOpen onClose={closeModal} tx={tx} />}
|
||||||
{openModal === 'executeRejectTx' && (
|
{openModal === 'executeRejectTx' && cancelTx && (
|
||||||
<ApproveTxModal
|
<ApproveTxModal
|
||||||
canExecute={canExecuteCancel}
|
canExecute={canExecuteCancel}
|
||||||
isCancelTx
|
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'
|
import { border, lg, md } from 'src/theme/variables'
|
||||||
|
|
||||||
const cssStyles = {
|
export default makeStyles(
|
||||||
col: {
|
createStyles({
|
||||||
wordBreak: 'break-word',
|
col: {
|
||||||
whiteSpace: 'normal',
|
wordBreak: 'break-word',
|
||||||
},
|
whiteSpace: 'normal',
|
||||||
expandedTxBlock: {
|
},
|
||||||
borderBottom: `2px solid ${border}`,
|
expandedTxBlock: {
|
||||||
},
|
borderBottom: `2px solid ${border}`,
|
||||||
txDataContainer: {
|
},
|
||||||
padding: `${lg} ${md}`,
|
txDataContainer: {
|
||||||
},
|
padding: `${lg} ${md}`,
|
||||||
txHash: {
|
},
|
||||||
paddingRight: '3px',
|
txHash: {
|
||||||
},
|
paddingRight: '3px',
|
||||||
incomingTxBlock: {
|
},
|
||||||
borderRight: '2px solid rgb(232, 231, 230)',
|
incomingTxBlock: {
|
||||||
},
|
borderRight: '2px solid rgb(232, 231, 230)',
|
||||||
emptyRowDataContainer: {
|
},
|
||||||
paddingTop: lg,
|
emptyRowDataContainer: {
|
||||||
paddingLeft: md,
|
paddingTop: lg,
|
||||||
paddingBottom: md,
|
paddingLeft: md,
|
||||||
borderRight: '2px solid rgb(232, 231, 230)',
|
paddingBottom: md,
|
||||||
},
|
borderRight: '2px solid rgb(232, 231, 230)',
|
||||||
}
|
},
|
||||||
|
}),
|
||||||
export const styles = (): typeof cssStyles => cssStyles
|
)
|
||||||
|
|
|
@ -20,6 +20,8 @@ const typeToIcon = {
|
||||||
creation: SettingsTxIcon,
|
creation: SettingsTxIcon,
|
||||||
cancellation: SettingsTxIcon,
|
cancellation: SettingsTxIcon,
|
||||||
upgrade: SettingsTxIcon,
|
upgrade: SettingsTxIcon,
|
||||||
|
module: SettingsTxIcon,
|
||||||
|
spendingLimit: SettingsTxIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeToLabel = {
|
const typeToLabel = {
|
||||||
|
@ -32,6 +34,8 @@ const typeToLabel = {
|
||||||
creation: 'Safe created',
|
creation: 'Safe created',
|
||||||
cancellation: 'Cancellation transaction',
|
cancellation: 'Cancellation transaction',
|
||||||
upgrade: 'Contract Upgrade',
|
upgrade: 'Contract Upgrade',
|
||||||
|
module: 'Module transaction',
|
||||||
|
spendingLimit: 'Spending Limit',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TxTypeProps {
|
interface TxTypeProps {
|
||||||
|
|
|
@ -11,7 +11,8 @@ import { buildOrderFieldFrom } from 'src/components/Table/sorting'
|
||||||
import { TableColumn } from 'src/components/Table/types.d'
|
import { TableColumn } from 'src/components/Table/types.d'
|
||||||
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
|
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
|
||||||
import { INCOMING_TX_TYPES } from 'src/logic/safe/store/models/incomingTransaction'
|
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 { CancellationTransactions } from 'src/logic/safe/store/reducer/cancellationTransactions'
|
||||||
import { getNetworkInfo } from 'src/config'
|
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')
|
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 {
|
interface AmountData {
|
||||||
decimals?: number | string
|
decimals?: number | string
|
||||||
|
@ -59,7 +60,8 @@ export const getIncomingTxAmount = (tx: Transaction, formatted = true): string =
|
||||||
|
|
||||||
export const getTxAmount = (tx: Transaction, formatted = true): string => {
|
export const getTxAmount = (tx: Transaction, formatted = true): string => {
|
||||||
const { decimals = 18, decodedParams, isTokenTransfer, symbol } = tx
|
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) {
|
if (tx.isCollectibleTransfer) {
|
||||||
return `1 ${tx.symbol}`
|
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)
|
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 => {
|
export const getRawTxAmount = (tx: Transaction): string => {
|
||||||
const { decimals, decodedParams, isTokenTransfer } = tx
|
const { decimals, decodedParams, isTokenTransfer } = tx
|
||||||
const { nativeCoin } = getNetworkInfo()
|
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) {
|
if (tx.isCollectibleTransfer) {
|
||||||
return '1'
|
return '1'
|
||||||
|
@ -98,10 +122,20 @@ export interface TableData {
|
||||||
dateOrder?: number
|
dateOrder?: number
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
tx?: Transaction
|
tx: Transaction | SafeModuleTransaction
|
||||||
type: any
|
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 => ({
|
const getIncomingTxTableData = (tx: Transaction): TableData => ({
|
||||||
[TX_TABLE_ID]: tx.blockNumber?.toString() ?? '',
|
[TX_TABLE_ID]: tx.blockNumber?.toString() ?? '',
|
||||||
[TX_TABLE_TYPE_ID]: <TxType txType="incoming" origin={null} />,
|
[TX_TABLE_TYPE_ID]: <TxType txType="incoming" origin={null} />,
|
||||||
|
@ -112,12 +146,24 @@ const getIncomingTxTableData = (tx: Transaction): TableData => ({
|
||||||
[TX_TABLE_RAW_TX_ID]: tx,
|
[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 getTransactionTableData = (tx: Transaction, cancelTx?: Transaction): TableData => {
|
||||||
const txDate = tx.submissionDate
|
const txDate = tx.submissionDate
|
||||||
|
const txType = getTxType(tx)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[TX_TABLE_ID]: tx.blockNumber?.toString() ?? '',
|
[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) : '',
|
[TX_TABLE_DATE_ID]: txDate ? formatDate(txDate) : '',
|
||||||
[buildOrderFieldFrom(TX_TABLE_DATE_ID)]: txDate ? getTime(parseISO(txDate)) : null,
|
[buildOrderFieldFrom(TX_TABLE_DATE_ID)]: txDate ? getTime(parseISO(txDate)) : null,
|
||||||
[TX_TABLE_AMOUNT_ID]: getTxAmount(tx),
|
[TX_TABLE_AMOUNT_ID]: getTxAmount(tx),
|
||||||
|
@ -128,15 +174,22 @@ const getTransactionTableData = (tx: Transaction, cancelTx?: Transaction): Table
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTxTableData = (
|
export const getTxTableData = (
|
||||||
transactions: List<Transaction>,
|
transactions: List<Transaction | SafeModuleTransaction>,
|
||||||
cancelTxs: CancellationTransactions,
|
cancelTxs: CancellationTransactions,
|
||||||
): List<TableData> => {
|
): List<TableData> => {
|
||||||
return transactions.map((tx) => {
|
return transactions.map((tx) => {
|
||||||
if (INCOMING_TX_TYPES[tx.type] !== undefined) {
|
const isModuleTx = [TransactionTypes.SPENDING_LIMIT, TransactionTypes.MODULE].includes(tx.type)
|
||||||
return getIncomingTxTableData(tx)
|
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 React, { useState, useEffect } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
|
|
||||||
import ExpandedTxComponent from './ExpandedTx'
|
import { ExpandedTx } from './ExpandedTx'
|
||||||
import Status from './Status'
|
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 { styles } from './style'
|
||||||
|
|
||||||
import Table from 'src/components/Table'
|
import Table from 'src/components/Table'
|
||||||
|
@ -35,8 +35,8 @@ const TxsTable = ({ classes }) => {
|
||||||
trackEvent({ category: SAFE_NAVIGATION_EVENT, action: 'Transactions' })
|
trackEvent({ category: SAFE_NAVIGATION_EVENT, action: 'Transactions' })
|
||||||
}, [trackEvent])
|
}, [trackEvent])
|
||||||
|
|
||||||
const handleTxExpand = (safeTxHash) => {
|
const handleTxExpand = (rowId) => {
|
||||||
setExpandedTx((prevTx) => (prevTx === safeTxHash ? null : safeTxHash))
|
setExpandedTx((prevRowId) => (prevRowId === rowId ? null : rowId))
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = generateColumns()
|
const columns = generateColumns()
|
||||||
|
@ -44,8 +44,8 @@ const TxsTable = ({ classes }) => {
|
||||||
const filteredData = getTxTableData(transactions, cancellationTransactions)
|
const filteredData = getTxTableData(transactions, cancellationTransactions)
|
||||||
.sort((tx1, tx2) => {
|
.sort((tx1, tx2) => {
|
||||||
// First order by nonce
|
// First order by nonce
|
||||||
const aNonce = tx1.tx?.nonce
|
const aNonce = Number(tx1.tx?.nonce)
|
||||||
const bNonce = tx1.tx?.nonce
|
const bNonce = Number(tx1.tx?.nonce)
|
||||||
if (aNonce && bNonce) {
|
if (aNonce && bNonce) {
|
||||||
const difference = aNonce - bNonce
|
const difference = aNonce - bNonce
|
||||||
if (difference !== 0) {
|
if (difference !== 0) {
|
||||||
|
@ -82,54 +82,56 @@ const TxsTable = ({ classes }) => {
|
||||||
size={filteredData.size}
|
size={filteredData.size}
|
||||||
>
|
>
|
||||||
{(sortedData) =>
|
{(sortedData) =>
|
||||||
sortedData.map((row, index) => (
|
sortedData.map((row) => {
|
||||||
<React.Fragment key={index}>
|
const rowId = `${row.tx.safeTxHash}-${row.tx.type}`
|
||||||
<TableRow
|
return (
|
||||||
className={cn(classes.row, expandedTx === row.tx.safeTxHash && classes.expandedRow)}
|
<React.Fragment key={rowId}>
|
||||||
data-testid={TRANSACTION_ROW_TEST_ID}
|
<TableRow
|
||||||
onClick={() => handleTxExpand(row.tx.safeTxHash)}
|
className={cn(classes.row, expandedTx === rowId && classes.expandedRow)}
|
||||||
tabIndex={-1}
|
data-testid={TRANSACTION_ROW_TEST_ID}
|
||||||
>
|
onClick={() => handleTxExpand(rowId)}
|
||||||
{autoColumns.map((column) => (
|
tabIndex={-1}
|
||||||
<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 }}
|
|
||||||
>
|
>
|
||||||
<Collapse
|
{autoColumns.map((column) => (
|
||||||
component={() => (
|
<TableCell
|
||||||
<ExpandedTxComponent cancelTx={row[TX_TABLE_RAW_CANCEL_TX_ID]} tx={row[TX_TABLE_RAW_TX_ID]} />
|
align={column.align}
|
||||||
)}
|
className={cn(
|
||||||
in={expandedTx === row.tx.safeTxHash}
|
classes.cell,
|
||||||
timeout="auto"
|
['cancelled', 'failed'].includes(row.status) && classes.cancelledRow,
|
||||||
unmountOnExit
|
)}
|
||||||
/>
|
component="td"
|
||||||
</TableCell>
|
key={column.id}
|
||||||
</TableRow>
|
style={cellWidth(column.width)}
|
||||||
</React.Fragment>
|
>
|
||||||
))
|
{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>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|
|
@ -2,13 +2,27 @@ import { List, Map } from 'immutable'
|
||||||
import { createSelector } from 'reselect'
|
import { createSelector } from 'reselect'
|
||||||
|
|
||||||
import { Token } from 'src/logic/tokens/store/model/token'
|
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 { 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 { 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 { 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(
|
export const grantedSelector = createSelector(
|
||||||
userAccountSelector,
|
userAccountSelector,
|
||||||
|
@ -48,3 +62,67 @@ export const extendedSafeTokensSelector = createSelector(
|
||||||
return extendedTokens.toList()
|
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 = {
|
export type MultiSendDetails = {
|
||||||
operation: Operation
|
operation: Operation
|
||||||
to: string
|
to: string
|
||||||
data: DataDecoded | null
|
data: string | null
|
||||||
|
dataDecoded: DataDecoded | null
|
||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +49,8 @@ export const extractMultiSendDetails = (parameter: Parameter): MultiSendDetails[
|
||||||
operation: valueDecoded.operation,
|
operation: valueDecoded.operation,
|
||||||
to: valueDecoded.to,
|
to: valueDecoded.to,
|
||||||
value: valueDecoded.value,
|
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 { SafeReducerMap } from 'src/routes/safe/store/reducer/types/safe'
|
||||||
import allTransactions, { TRANSACTIONS, TransactionsState } from '../logic/safe/store/reducer/allTransactions'
|
import allTransactions, { TRANSACTIONS, TransactionsState } from '../logic/safe/store/reducer/allTransactions'
|
||||||
import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
|
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()
|
export const history = createHashHistory()
|
||||||
|
|
||||||
|
@ -68,6 +72,7 @@ const reducers = combineReducers({
|
||||||
[TRANSACTIONS_REDUCER_ID]: transactions,
|
[TRANSACTIONS_REDUCER_ID]: transactions,
|
||||||
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: cancellationTransactions,
|
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: cancellationTransactions,
|
||||||
[INCOMING_TRANSACTIONS_REDUCER_ID]: incomingTransactions,
|
[INCOMING_TRANSACTIONS_REDUCER_ID]: incomingTransactions,
|
||||||
|
[MODULE_TRANSACTIONS_REDUCER_ID]: moduleTransactions,
|
||||||
[NOTIFICATIONS_REDUCER_ID]: notifications,
|
[NOTIFICATIONS_REDUCER_ID]: notifications,
|
||||||
[CURRENCY_VALUES_KEY]: currencyValues,
|
[CURRENCY_VALUES_KEY]: currencyValues,
|
||||||
[COOKIES_REDUCER_ID]: cookies,
|
[COOKIES_REDUCER_ID]: cookies,
|
||||||
|
@ -85,6 +90,7 @@ export type AppReduxState = CombinedState<{
|
||||||
[TRANSACTIONS_REDUCER_ID]: Map<string, any>
|
[TRANSACTIONS_REDUCER_ID]: Map<string, any>
|
||||||
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: CancellationTxState
|
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: CancellationTxState
|
||||||
[INCOMING_TRANSACTIONS_REDUCER_ID]: Map<string, any>
|
[INCOMING_TRANSACTIONS_REDUCER_ID]: Map<string, any>
|
||||||
|
[MODULE_TRANSACTIONS_REDUCER_ID]: ModuleTransactionsState
|
||||||
[NOTIFICATIONS_REDUCER_ID]: Map<string, any>
|
[NOTIFICATIONS_REDUCER_ID]: Map<string, any>
|
||||||
[CURRENCY_VALUES_KEY]: CurrencyValuesState
|
[CURRENCY_VALUES_KEY]: CurrencyValuesState
|
||||||
[COOKIES_REDUCER_ID]: Map<string, any>
|
[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 = 'https://api.exchangeratesapi.io/latest'
|
||||||
export const EXCHANGE_RATE_URL_FALLBACK = 'https://api.coinbase.com/v2/exchange-rates'
|
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 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