diff --git a/app/tray/Account/Requests/SignPermitRequest/index.js b/app/tray/Account/Requests/SignPermitRequest/index.js index 9b5d490db..41f054b34 100644 --- a/app/tray/Account/Requests/SignPermitRequest/index.js +++ b/app/tray/Account/Requests/SignPermitRequest/index.js @@ -8,7 +8,7 @@ import { ClusterBox, Cluster, ClusterRow, ClusterValue } from '../../../../../re import Countdown from '../../../../../resources/Components/Countdown' import RequestHeader from '../../../../../resources/Components/RequestHeader' import RequestItem from '../../../../../resources/Components/RequestItem' -import CustomAmountInput from '../../../../../resources/Components/CustomAmountInput' +import EditTokenSpend from '../../../../../resources/Components/EditTokenSpend' import { SimpleTypedData as TypedSignatureOverview } from '../../../../../resources/Components/SimpleTypedData' import { getSignatureRequestClass } from '../../../../../resources/domain/request' import useCopiedMessage from '../../../../../resources/Hooks/useCopiedMessage' @@ -193,7 +193,7 @@ const EditPermit = ({ req }) => { } return ( - this.setState({ copiedSpenderAddress: false }), 1000) - } - - copyTokenAddress(data) { - link.send('tray:clipboardData', data) - this.setState({ copiedTokenAddress: true }) - setTimeout(() => this.setState({ copiedTokenAddress: false }), 1000) - } - - render() { - const { approval, updateApproval, requestedAmountHex } = this.props - - const { data } = approval - const decimals = data.decimals || 0 - const requestedAmount = requestedAmountHex - const customInput = - '0x' + new BigNumber(this.state.customInput).shiftedBy(decimals).integerValue().toString(16) - const value = new BigNumber(data.amount) - const revoke = value.eq(0) - - const displayAmount = isUnlimited(data.amount) ? 'unlimited' : formatDisplayInteger(data.amount, decimals) - - const symbol = data.symbol || '???' - const name = data.name || 'Unknown Token' - - const inputLock = !data.symbol || !data.name || !decimals - - const spenderEns = data.spenderEns - const spender = data.spender - - const tokenAddress = data.contract - - return ( -
- - - - { - this.copySpenderAddress(spender) - }} - > -
- {spenderEns ? ( - {spenderEns} - ) : ( - - {spender.substring(0, 8)} - {svg.octicon('kebab-horizontal', { height: 15 })} - {spender.substring(spender.length - 6)} - - )} -
- {this.state.copiedSpenderAddress ? ( - {'Address Copied'} - ) : ( - {spender} - )} -
-
-
-
- - -
- {this.state.mode === 'custom' && !this.state.customInput ? ( - {'set approval to spend'} - ) : revoke ? ( - {'revoke approval to spend'} - ) : ( - {'grant approval to spend'} - )} -
-
-
- - { - this.copyTokenAddress(tokenAddress) - }} - > -
- - {name} - -
- {this.state.copiedTokenAddress ? ( - {'Address Copied'} - ) : ( - {tokenAddress} - )} -
-
-
-
-
- - - -
{symbol}
-
-
- - -
- {this.state.mode === 'custom' && data.amount !== customInput ? ( -
{ - if (this.state.customInput === '') { - this.setState({ mode: 'requested', amount: requestedAmount }) - updateApproval(requestedAmount) - } else { - updateApproval(this.state.amount) - } - }} - > - {'update'} -
- ) : ( -
- {svg.check(20)} -
- )} - {this.state.mode === 'custom' ? ( - { - e.preventDefault() - e.stopPropagation() - this.setCustomAmount(e.target.value, decimals) - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.target.blur() - if (this.state.customInput === '') { - this.setState({ mode: 'requested', amount: requestedAmount }) - updateApproval(requestedAmount) - } else { - updateApproval(this.state.amount) - } - } - }} - /> - ) : ( -
{ - this.setCustomAmount(this.state.customInput, decimals) - } - } - > - {displayAmount} -
- )} -
-
-
- - -
Set Token Approval Spend Limit
-
-
- - - { - this.setState({ mode: 'requested', amount: requestedAmount }) - updateApproval(requestedAmount) - }} - role='button' - > -
- {'Requested'} -
-
-
- - { - const amount = MAX_HEX - this.setState({ mode: 'unlimited', amount }) - updateApproval(amount) - }} - role='button' - > -
- {'Unlimited'} -
-
-
- {!inputLock && ( - - { - this.setCustomAmount(this.state.customInput, decimals) - }} - role='button' - > -
- Custom -
-
-
- )} -
-
-
- ) - } -} - -export default Restore.connect(TokenSpend) diff --git a/app/tray/Account/Requests/TransactionRequest/TxAction/index.js b/app/tray/Account/Requests/TransactionRequest/TxAction/index.js index 51101c941..13596ffff 100644 --- a/app/tray/Account/Requests/TransactionRequest/TxAction/index.js +++ b/app/tray/Account/Requests/TransactionRequest/TxAction/index.js @@ -5,7 +5,7 @@ import BigNumber from 'bignumber.js' import svg from '../../../../../../resources/svg' import link from '../../../../../../resources/link' import { ClusterBox, Cluster, ClusterRow, ClusterValue } from '../../../../../../resources/Components/Cluster' -import { formatDisplayInteger, isUnlimited } from '../../../../../../resources/utils/numbers' +import { formatDisplayDecimal, isUnlimited } from '../../../../../../resources/utils/numbers' import { DisplayValue, DisplayCoinBalance } from '../../../../../../resources/Components/DisplayValue' import { getAddress } from '../../../../../../resources/utils' @@ -36,21 +36,12 @@ class TxSending extends React.Component { amount, decimals, name, - recipient: recipientAddress, - symbol, - recipientType, - recipientEns + recipient: { address: recipientAddress, type: recipientType, ens: recipientEns }, + symbol } = action.data || {} const address = getAddress(recipientAddress) const ensName = recipientEns - // const ensName = (recipientEns && recipientEns.length < 25) ? recipientEns : '' - const value = new BigNumber(amount) - const displayValue = value - .dividedBy('1e' + decimals) - .decimalPlaces(6) - .toFormat() - const isTestnet = this.store('main.networks', this.props.chain.type, this.props.chain.id, 'isTestnet') const rate = this.store('main.rates', contract) @@ -120,14 +111,19 @@ class TxSending extends React.Component { ) } else if (actionType === 'approve') { - const { amount, decimals, spender: recipientAddress, symbol, spenderEns } = action.data || {} + const { + amount, + decimals, + spender: { address: recipientAddress, ens: spenderEns }, + symbol + } = action.data || {} const address = recipientAddress const ensName = spenderEns const value = new BigNumber(amount) const revoke = value.eq(0) const displayAmount = isUnlimited(this.state.amount) ? 'unlimited' - : formatDisplayInteger(amount, decimals) + : formatDisplayDecimal(amount, decimals) const isSubmitted = req.status !== undefined return ( diff --git a/app/tray/Account/Requests/TransactionRequest/TxMainNew/overview.js b/app/tray/Account/Requests/TransactionRequest/TxMainNew/overview.js index a85f39c46..eff2a24e4 100644 --- a/app/tray/Account/Requests/TransactionRequest/TxMainNew/overview.js +++ b/app/tray/Account/Requests/TransactionRequest/TxMainNew/overview.js @@ -9,6 +9,7 @@ import { isNonZeroHex } from '../../../../../../resources/utils' import { Cluster, ClusterRow, ClusterValue } from '../../../../../../resources/Components/Cluster' import { DisplayValue } from '../../../../../../resources/Components/DisplayValue' import RequestHeader from '../../../../../../resources/Components/RequestHeader' +import BigNumber from 'bignumber.js' const SimpleContractCallOverview = ({ method }) => { const body = method ? `Calling Contract Method ${method}` : 'Calling Contract' @@ -17,16 +18,23 @@ const SimpleContractCallOverview = ({ method }) => { } const ApproveOverview = ({ amount, decimals, symbol }) => { + const isRevoke = BigNumber(amount).isZero() return (
- {'Approve Spending'} - + {isRevoke ? ( + {`Revoke Approval for ${symbol}`} + ) : ( + <> + {'Approve Spending'} + + + )}
) } diff --git a/app/tray/Account/Requests/TransactionRequest/index.js b/app/tray/Account/Requests/TransactionRequest/index.js index 33024262e..00e400344 100644 --- a/app/tray/Account/Requests/TransactionRequest/index.js +++ b/app/tray/Account/Requests/TransactionRequest/index.js @@ -1,5 +1,6 @@ import React from 'react' import Restore from 'react-restore' +import BigNumber from 'bignumber.js' // New Tx import TxMain from './TxMainNew' @@ -9,8 +10,9 @@ import TxAction from './TxAction' import TxRecipient from './TxRecipient' import AdjustFee from './AdjustFee' import ViewData from './ViewData' -import TokenSpend from './TokenSpend' +import EditTokenSpend from '../../../../../resources/Components/EditTokenSpend' import link from '../../../../../resources/link' +import { erc20Interface } from '../../../../../resources/contracts' class TransactionRequest extends React.Component { constructor(props, context) { @@ -35,21 +37,32 @@ class TransactionRequest extends React.Component { return } + decodeRequested(req) { + const calldata = req.payload.params[0].data + const [spender, amount] = erc20Interface.decodeFunctionData('approve', calldata) + return { spender, amount: BigNumber(amount.toString()) } + } + renderTokenSpend() { const crumb = this.store('windows.panel.nav')[0] || {} - const { actionId, requestedAmountHex } = crumb.data + const { actionId } = crumb.data const { req } = this.props - const { handlerId } = req if (!req) return null + + const { handlerId } = req const approval = (req.recognizedActions || []).find((action) => action.id === actionId) if (!approval) return null + + const { data } = approval + + const { amount: requestedAmount } = this.decodeRequested(req) + return ( - { - link.rpc('updateRequest', handlerId, { amount }, actionId, () => {}) - }} + link.rpc('updateRequest', handlerId, { amount }, actionId, () => {})} + canRevoke={true} /> ) } diff --git a/main/accounts/types.ts b/main/accounts/types.ts index 83c3f6888..2811db292 100644 --- a/main/accounts/types.ts +++ b/main/accounts/types.ts @@ -49,7 +49,7 @@ interface Request { handlerId: string } -type Identity = { +export type Identity = { address: Address ens: string type: string diff --git a/main/contracts/erc20.ts b/main/contracts/erc20.ts index 7a406544c..c68f9753e 100644 --- a/main/contracts/erc20.ts +++ b/main/contracts/erc20.ts @@ -2,10 +2,9 @@ import { TransactionDescription } from '@ethersproject/abi' import { Contract } from '@ethersproject/contracts' import { Web3Provider } from '@ethersproject/providers' import { addHexPrefix } from '@ethereumjs/util' -import erc20Abi from '../externalData/balances/erc-20-abi' import provider from '../provider' import { BigNumber } from 'ethers' - +import { erc20Interface } from '../../resources/contracts' export interface TokenData { decimals?: number name: string @@ -41,7 +40,7 @@ export default class Erc20Contract { constructor(address: Address, chainId: number) { const web3Provider = new Web3Provider(createWeb3ProviderWrapper(chainId)) - this.contract = new Contract(address, erc20Abi, web3Provider) + this.contract = new Contract(address, erc20Interface, web3Provider) } static isApproval(data: TransactionDescription) { @@ -66,16 +65,16 @@ export default class Erc20Contract { ) } - decodeCallData(calldata: string) { + static decodeCallData(calldata: string) { try { - return this.contract.interface.parseTransaction({ data: calldata }) + return erc20Interface.parseTransaction({ data: calldata }) } catch (e) { // call does not match ERC-20 interface } } - encodeCallData(fn: string, params: any[]) { - return this.contract.interface.encodeFunctionData(fn, params) + static encodeCallData(fn: string, params: any[]) { + return erc20Interface.encodeFunctionData(fn, params) } async getTokenData(): Promise { diff --git a/main/reveal/index.ts b/main/reveal/index.ts index 745b037f4..c4706b9bf 100644 --- a/main/reveal/index.ts +++ b/main/reveal/index.ts @@ -73,57 +73,65 @@ async function recogErc20( chainId: number, calldata: string ): Promise | undefined> { - if (contractAddress) { + const decoded = Erc20Contract.decodeCallData(calldata) + if (contractAddress && decoded) { try { const contract = new Erc20Contract(contractAddress, chainId) - const decoded = contract.decodeCallData(calldata) - if (decoded) { - const { decimals, name, symbol } = await contract.getTokenData() - if (Erc20Contract.isApproval(decoded)) { - const spender = decoded.args[0].toLowerCase() - const amount = decoded.args[1].toHexString() - const { ens, type } = await surface.identity(spender, chainId) - const data = { - spender, - amount, - decimals, - name, - symbol, - spenderEns: ens, - spenderType: type, - contract: contractAddress + + const { decimals, name, symbol } = await contract.getTokenData() + if (Erc20Contract.isApproval(decoded)) { + const spenderAddress = decoded.args[0].toLowerCase() + const amount = decoded.args[1].toHexString() + + const [spenderIdentity, contractIdentity] = await Promise.all([ + surface.identity(spenderAddress, chainId), + surface.identity(contractAddress, chainId) + ]) + + const data = { + amount, + decimals, + name, + symbol, + spender: { + ...spenderIdentity, + address: spenderAddress + }, + contract: { + address: contractAddress, + ...contractIdentity } + } - return { - id: 'erc20:approve', - data, - update: (request, { amount }) => { - // amount is a hex string - const approvedAmount = new BigNumber(amount || '').toString() + return { + id: 'erc20:approve', + data, + update: (request, { amount }) => { + // amount is a hex string + const approvedAmount = new BigNumber(amount || '').toString() - log.verbose( - `Updating Erc20 approve amount to ${approvedAmount} for contract ${contractAddress} and spender ${spender}` - ) + log.verbose( + `Updating Erc20 approve amount to ${approvedAmount} for contract ${contractAddress} and spender ${spenderAddress}` + ) - const txRequest = request as TransactionRequest + const txRequest = request as TransactionRequest - data.amount = amount - txRequest.data.data = contract.encodeCallData('approve', [spender, amount]) + data.amount = amount + txRequest.data.data = Erc20Contract.encodeCallData('approve', [spenderAddress, amount]) - if (txRequest.decodedData) { - txRequest.decodedData.args[1].value = amount === MAX_HEX ? 'unlimited' : approvedAmount - } + if (txRequest.decodedData) { + txRequest.decodedData.args[1].value = amount === MAX_HEX ? 'unlimited' : approvedAmount } - } as Erc20Approval - } else if (Erc20Contract.isTransfer(decoded)) { - const recipient = decoded.args[0].toLowerCase() - const amount = decoded.args[1].toHexString() - const { ens, type } = await surface.identity(recipient, chainId) - return { - id: 'erc20:transfer', - data: { recipient, amount, decimals, name, symbol, recipientEns: ens, recipientType: type } - } as Erc20Transfer - } + } + } as Erc20Approval + } else if (Erc20Contract.isTransfer(decoded)) { + const recipient = decoded.args[0].toLowerCase() + const amount = decoded.args[1].toHexString() + const identity = await surface.identity(recipient, chainId) + return { + id: 'erc20:transfer', + data: { recipient: { address: recipient, ...identity }, amount, decimals, name, symbol } + } as Erc20Transfer } } catch (e) { log.warn(e) diff --git a/main/transaction/actions/erc20.ts b/main/transaction/actions/erc20.ts index fc18ea322..58eb0e812 100644 --- a/main/transaction/actions/erc20.ts +++ b/main/transaction/actions/erc20.ts @@ -1,4 +1,5 @@ import type { Action, EntityType } from '.' +import { Identity } from '../../accounts/types' export type ActionType = 'erc20:approve' | 'erc20:revoke' | 'erc20:transfer' @@ -11,15 +12,12 @@ type Erc20Spend = { } type Erc20Approve = Erc20Spend & { - spender: Address - spenderEns?: string - spenderType: EntityType + spender: Identity + contract: Identity } type Erc20Transfer = Erc20Spend & { - recipient: Address - recipientEns?: string - recipientType: EntityType + recipient: Identity } export type ApproveAction = Action diff --git a/resources/Components/CustomAmountInput/index.js b/resources/Components/EditTokenSpend/index.js similarity index 91% rename from resources/Components/CustomAmountInput/index.js rename to resources/Components/EditTokenSpend/index.js index a94ec6e05..05ddb19d9 100644 --- a/resources/Components/CustomAmountInput/index.js +++ b/resources/Components/EditTokenSpend/index.js @@ -6,8 +6,8 @@ import svg from '../../svg' import { ClusterBox, Cluster, ClusterRow, ClusterValue } from '../Cluster' import Countdown from '../Countdown' -import { MAX_HEX } from '../../constants' import useCopiedMessage from '../../Hooks/useCopiedMessage' +import { DisplayValue } from '../DisplayValue' const isMax = (value) => max.isEqualTo(value) @@ -16,6 +16,11 @@ const getMode = (requestedAmount, amount) => { return isMax(amount) ? 'unlimited' : 'custom' } +const isValidInput = (value, decimals) => { + const strValue = value.toString() + return !isNaN(value) && value > 0 && (!strValue.includes('.') || strValue.split('.')[1].length <= decimals) +} + const Details = ({ address, name }) => { const [showCopiedMessage, copyAddress] = useCopiedMessage(address) @@ -70,7 +75,7 @@ const Description = ({ mode, custom, isRevoke }) => ( ) -const CustomAmountInput = ({ +const EditTokenSpend = ({ data, updateRequest: updateHandlerRequest, requestedAmount, @@ -79,8 +84,8 @@ const CustomAmountInput = ({ }) => { const { decimals = 0, symbol = '???', name = 'Unknown Token', spender, contract, amount } = data - const toDecimal = (baseAmount) => new BigNumber(baseAmount).shiftedBy(-1 * decimals).toString() - const fromDecimal = (decimalAmount) => new BigNumber(decimalAmount).shiftedBy(decimals).toString() + const toDecimal = (baseAmount) => new BigNumber(baseAmount).shiftedBy(-1 * decimals).toString(10) + const fromDecimal = (decimalAmount) => new BigNumber(decimalAmount).shiftedBy(decimals).toString(10) const [mode, setMode] = useState(getMode(requestedAmount, amount)) const [custom, setCustom] = useState('') @@ -91,13 +96,13 @@ const CustomAmountInput = ({ const value = new BigNumber(amount) - const updateCustomAmount = (value) => { + const updateCustomAmount = (value, decimals) => { if (!value) { setCustom('0') return setMode('custom') } - if (isNaN(value) || value < 0) return + if (!isValidInput(value, decimals)) return setMode('custom') setCustom(value) } @@ -105,13 +110,12 @@ const CustomAmountInput = ({ const resetToRequestAmount = () => { setCustom(toDecimal(requestedAmount)) setMode('requested') - updateHandlerRequest(requestedAmount) + updateHandlerRequest(requestedAmount.toString(10)) } const setToMax = () => { - console.log('setting to max') setMode('unlimited') - updateHandlerRequest(max.toString()) + updateHandlerRequest(max.toString(10)) } const isRevoke = canRevoke && value.eq(0) @@ -119,7 +123,7 @@ const CustomAmountInput = ({ const displayAmount = isMax(amount) ? 'unlimited' : toDecimal(amount) - const inputLock = !symbol || !name || !decimals + const inputLock = !data.symbol || !data.name || !data.decimals return (
@@ -195,7 +199,7 @@ const CustomAmountInput = ({ onChange={(e) => { e.preventDefault() e.stopPropagation() - updateCustomAmount(e.target.value) + updateCustomAmount(e.target.value, decimals) }} onKeyDown={(e) => { if (e.key === 'Enter') { @@ -276,4 +280,4 @@ const CustomAmountInput = ({ ) } -export default CustomAmountInput +export default EditTokenSpend diff --git a/resources/contracts/index.ts b/resources/contracts/index.ts new file mode 100644 index 000000000..7bee62fed --- /dev/null +++ b/resources/contracts/index.ts @@ -0,0 +1,4 @@ +import { utils } from 'ethers' +import erc20Abi from '../../main/externalData/balances/erc-20-abi' + +export const erc20Interface = new utils.Interface(erc20Abi) diff --git a/resources/utils/numbers.ts b/resources/utils/numbers.ts index ad7da65e3..32ee984f9 100644 --- a/resources/utils/numbers.ts +++ b/resources/utils/numbers.ts @@ -15,13 +15,18 @@ const digitsLookup = [ { value: 1e18, symbol: 'quintillion' } ] -export function formatNumber(n: number, digits = 2) { +export function formatNumber(n: number, digits = 4) { const num = Number(n) const item = digitsLookup .slice() .reverse() .find((item) => num >= item.value) || { value: 0, symbol: '?' } - const formatted = (value: number) => `${value.toFixed(digits).replace(numberRegex, '$1')} ${item.symbol}` + + const formatted = (value: number) => { + const isAproximate = value.toFixed(digits) !== value.toString(10) + const prefix = isAproximate ? '~' : '' + return `${prefix}${value.toFixed(digits).replace(numberRegex, '$1')} ${item.symbol}` + } return item ? formatted(num / item.value) : '0' } @@ -30,12 +35,10 @@ export function isUnlimited(amount: string) { return max.eq(amount) } -export function formatDisplayInteger(amount: number, decimals: number) { - const displayInt = new BigNumber(amount).shiftedBy(-decimals).integerValue().toNumber() +export function formatDisplayDecimal(amount: string | number, decimals: number) { + const bn = BigNumber(amount).shiftedBy(-decimals) - if (displayInt > 9e12) { - return decimals ? '~unlimited' : 'unknown' - } + if (bn.gt(9e12)) return decimals ? '~unlimited' : 'unknown' - return formatNumber(displayInt) + return formatNumber(bn.toNumber()) } diff --git a/test/app/tray/Account/Requests/TransactionRequest/TokenSpend/index.test.js b/test/app/tray/Account/Requests/TransactionRequest/TokenSpend/index.test.js deleted file mode 100644 index 76619af6b..000000000 --- a/test/app/tray/Account/Requests/TransactionRequest/TokenSpend/index.test.js +++ /dev/null @@ -1,245 +0,0 @@ -import React from 'react' -import Restore from 'react-restore' -import { addHexPrefix } from '@ethereumjs/util' - -import { render, screen } from '../../../../../../componentSetup' -import store from '../../../../../../../main/store' -import ApproveTokenSpendComponent from '../../../../../../../app/tray/Account/Requests/TransactionRequest/TokenSpend' - -jest.mock('../../../../../../../main/store/persist') - -const TokenSpend = Restore.connect(ApproveTokenSpendComponent, store) - -describe('changing approval amounts', () => { - it('allows the user to set the token approval to a custom amount', async () => { - const onUpdate = jest.fn() - const requestedAmountHex = '0x011170' - const approval = { - id: 'erc20:approve', - data: { - spender: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', - amount: requestedAmountHex, - decimals: 4, - name: 'TST', - symbol: 'TST', - spenderEns: '', - spenderType: 'external', - contract: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698' - } - } - - const { user } = render( - - ) - - const custom = screen.queryByRole('button', { name: 'Custom' }) - await user.click(custom) - - const enterAmount = screen.queryByRole('textbox', { label: 'Custom Amount' }) - await user.type(enterAmount, '50') - - const updateCustom = screen.getByText('update') - await user.click(updateCustom) - - expect(onUpdate).toHaveBeenCalledWith('0x7a120') - }) - - it('does not allows the user to set the token approval to a custom amount for an unknown token', () => { - const requestedAmountHex = addHexPrefix((100e6).toString(16)) - const approval = { - id: 'erc20:approve', - data: { - spender: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', - amount: requestedAmountHex, - decimals: 6, - symbol: 'aUSDC', - spenderEns: '', - spenderType: 'external', - contract: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698' - } - } - - render( - {}} /> - ) - - const custom = screen.queryByRole('button', { name: 'Custom' }) - expect(custom).toBe(null) - }) - - it('allows the user to set the token approval to unlimited', async () => { - const onUpdate = jest.fn() - const requestedAmountHex = '0x011170' - - const approval = { - id: 'erc20:approve', - data: { - spender: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', - amount: requestedAmountHex, - decimals: 4, - name: 'TST', - symbol: 'TST', - spenderEns: '', - spenderType: 'external', - contract: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698' - } - } - - const { user } = render( - - ) - - const custom = screen.queryByRole('button', { name: 'Custom' }) - await user.click(custom) - - const setUnlimited = screen.queryByRole('button', { name: 'Unlimited' }) - await user.click(setUnlimited) - - expect(onUpdate).toHaveBeenCalledWith( - '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' - ) - }) - - it('allows the user to revert the token approval back to the original request', async () => { - const onUpdate = jest.fn() - const requestedAmountHex = '0x011170' - const approval = { - id: 'erc20:approve', - data: { - spender: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', - amount: requestedAmountHex, - decimals: 4, - name: 'TST', - symbol: 'TST', - spenderEns: '', - spenderType: 'external', - contract: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698' - } - } - - const { user } = render( - - ) - - const setUnlimited = screen.queryByRole('button', { name: 'Unlimited' }) - await user.click(setUnlimited) - - const setRequested = screen.queryByRole('button', { name: 'Requested' }) - await user.click(setRequested) - - expect(onUpdate).toHaveBeenNthCalledWith( - 1, - '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' - ) - expect(onUpdate).toHaveBeenNthCalledWith(2, '0x011170') - }) - - it('allows the user to revert the token approval back to the original amount when no decimal data is present', async () => { - const onUpdate = jest.fn() - const requestedAmountHex = '0x011170' - const approval = { - id: 'erc20:approve', - data: { - spender: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', - amount: requestedAmountHex, - name: 'TST', - symbol: 'TST', - spenderEns: '', - spenderType: 'external', - contract: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698' - } - } - - const { user } = render( - - ) - - const setUnlimited = screen.queryByRole('button', { name: 'Unlimited' }) - await user.click(setUnlimited) - - const setRequested = screen.queryByRole('button', { name: 'Requested' }) - await user.click(setRequested) - - expect(onUpdate).toHaveBeenNthCalledWith( - 1, - '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' - ) - expect(onUpdate).toHaveBeenNthCalledWith(2, '0x011170') - }) - - const requiredApprovalData = ['decimals', 'symbol', 'name'] - - requiredApprovalData.forEach((field) => { - it(`does not allow the user to edit the amount if ${field} is not present in approval data`, async () => { - const requestedAmountHex = addHexPrefix((100e6).toString(16)) - const approval = { - id: 'erc20:approve', - data: { - spender: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', - amount: requestedAmountHex, - decimals: 6, - name: 'TST', - symbol: 'TST', - spenderEns: '', - spenderType: 'external', - contract: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698' - } - } - - delete approval.data[field] - - const { user } = render( - {}} /> - ) - - const custom = screen.queryByRole('button', { name: 'Custom' }) - expect(custom).toBeNull() - - const requestedAmount = screen.queryByRole('textbox') - const displayedContent = requestedAmount.textContent.trim() - expect(displayedContent).toBe(approval.data.decimals ? '100' : '100 million') - - // ensure click on requested amount textbox doesn't allow user to enter a custom amount - await user.click(requestedAmount) - expect(screen.queryByRole('textbox', { name: 'Custom Amount' })).toBeNull() - }) - }) -}) - -describe('formatting amounts', () => { - const formattedAmounts = [ - { amount: 1e5, formatted: '100000' }, - { amount: 92e5, formatted: '9.2 million' }, - { amount: 100e9, formatted: '100 billion' }, - { amount: 2e12, formatted: '2 trillion' }, - { amount: 1e13, formatted: '~unlimited' } - ] - - formattedAmounts.forEach((spec) => { - it(`formats a requested amount of ${spec.amount} as ${spec.formatted}`, () => { - const amount = addHexPrefix((spec.amount * 1e6).toString(16)) - const requestedAmountHex = amount - const approval = { - id: 'erc20:approve', - data: { - spender: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', - amount: amount, - decimals: 6, - name: 'TST', - symbol: 'TST', - spenderEns: '', - spenderType: 'external', - contract: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698' - } - } - - render( - {}} /> - ) - - const requestedAmount = screen.queryByRole('textbox') - const displayedContent = requestedAmount.textContent.trim() - expect(displayedContent).toBe(spec.formatted) - }) - }) -}) diff --git a/test/resources/Components/EditTokenSpend/index.test.js b/test/resources/Components/EditTokenSpend/index.test.js new file mode 100644 index 000000000..7996e2b43 --- /dev/null +++ b/test/resources/Components/EditTokenSpend/index.test.js @@ -0,0 +1,301 @@ +import React from 'react' +import { render, screen } from '../../../componentSetup' +import EditTokenSpend from '../../../../resources/Components/EditTokenSpend' +import BigNumber from 'bignumber.js' +import { max } from '../../../../resources/utils/numbers' + +const maxIntStr = max.toString(10) + +describe('changing approval amounts', () => { + it('allows the user to set the token approval to a custom amount', async () => { + const onUpdate = jest.fn() + const requestedAmount = BigNumber('0x011170') + const approval = { + id: 'erc20:approve', + data: { + spender: { + address: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', + ens: '', + type: 'external' + }, + amount: '0x' + requestedAmount.toString(16), + decimals: 4, + name: 'TST', + symbol: 'TST', + contract: { + address: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698', + ens: '', + type: 'contract' + } + } + } + + const { user } = render( + + ) + + const custom = screen.queryByRole('button', { name: 'Custom' }) + await user.click(custom) + + const enterAmount = screen.queryByRole('textbox', { label: 'Custom Amount' }) + await user.type(enterAmount, '50') + + const updateCustom = screen.getByText('update') + await user.click(updateCustom) + + expect(onUpdate).toHaveBeenCalledWith('500000') + }) + + it('allows users to input custom amounts which are decimal', async () => { + const onUpdate = jest.fn() + const requestedAmount = BigNumber('0x011170') + const approval = { + id: 'erc20:approve', + data: { + spender: { + address: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', + ens: '', + type: 'external' + }, + amount: '0x' + requestedAmount.toString(16), + decimals: 4, + name: 'TST', + symbol: 'TST', + contract: { + address: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698', + ens: '', + type: 'contract' + } + } + } + + const { user } = render( + + ) + + const custom = screen.queryByRole('button', { name: 'Custom' }) + await user.click(custom) + + const enterAmount = screen.queryByRole('textbox', { label: 'Custom Amount' }) + await user.type(enterAmount, '50.1') + + const updateCustom = screen.getByText('update') + await user.click(updateCustom) + + expect(onUpdate).toHaveBeenCalledWith('501000') + }) + + it('does not allow users to input a custom amount with more decimals than allowed by the contract', async () => { + const onUpdate = jest.fn() + const requestedAmount = BigNumber('0x011170') + const approval = { + id: 'erc20:approve', + data: { + spender: { + address: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', + ens: '', + type: 'external' + }, + amount: '0x' + requestedAmount.toString(16), + decimals: 4, + name: 'TST', + symbol: 'TST', + contract: { + address: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698', + ens: '', + type: 'contract' + } + } + } + + const { user } = render( + + ) + + const custom = screen.queryByRole('button', { name: 'Custom' }) + await user.click(custom) + + const enterAmount = screen.queryByRole('textbox', { label: 'Custom Amount' }) + await user.type(enterAmount, '50.00001') + + const updateCustom = screen.getByText('update') + await user.click(updateCustom) + + expect(onUpdate).toHaveBeenCalledWith('500000') + }) + + it('does not allows the user to set the token approval to a custom amount for an unknown token', () => { + const requestedAmount = BigNumber('0x100e6') + const approval = { + id: 'erc20:approve', + data: { + spender: { + address: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', + ens: '', + type: 'external' + }, + amount: '0x' + requestedAmount.toString(16), + decimals: 6, + symbol: 'aUSDC', + contract: { + address: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698', + type: 'contract', + ens: '' + } + } + } + + render( {}} />) + + const custom = screen.queryByRole('button', { name: 'Custom' }) + expect(custom).toBe(null) + }) + + it('allows the user to set the token approval to unlimited', async () => { + const onUpdate = jest.fn() + const requestedAmount = BigNumber('0x011170') + + const approval = { + id: 'erc20:approve', + data: { + spender: { address: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', ens: '', type: 'external' }, + amount: '0x' + requestedAmount.toString(16), + decimals: 4, + name: 'TST', + symbol: 'TST', + contract: { address: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698', ens: '', type: 'contract' } + } + } + + const { user } = render( + + ) + + const custom = screen.queryByRole('button', { name: 'Custom' }) + await user.click(custom) + + const setUnlimited = screen.queryByRole('button', { name: 'Unlimited' }) + await user.click(setUnlimited) + + expect(onUpdate).toHaveBeenCalledWith(maxIntStr) + }) + + it('allows the user to revert the token approval back to the original request', async () => { + const onUpdate = jest.fn() + const requestedAmountHex = '0x011170' + const requestedAmount = BigNumber(requestedAmountHex) + const approval = { + id: 'erc20:approve', + data: { + spender: { address: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', ens: '', type: 'external' }, + amount: requestedAmountHex, + decimals: 4, + name: 'TST', + symbol: 'TST', + contract: { + address: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698', + ens: '', + type: 'contract' + } + } + } + + const { user } = render( + + ) + + const setUnlimited = screen.queryByRole('button', { name: 'Unlimited' }) + await user.click(setUnlimited) + + const setRequested = screen.queryByRole('button', { name: 'Requested' }) + await user.click(setRequested) + + expect(onUpdate).toHaveBeenNthCalledWith(1, maxIntStr) + expect(onUpdate).toHaveBeenNthCalledWith(2, '70000') + }) + + it('allows the user to revert the token approval back to the original amount when no decimal data is present', async () => { + const onUpdate = jest.fn() + const requestedAmountHex = '0x011170' + const requestedAmount = BigNumber(requestedAmountHex) + const approval = { + id: 'erc20:approve', + data: { + spender: { + address: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', + ens: '', + type: 'external' + }, + amount: requestedAmountHex, + name: 'TST', + symbol: 'TST', + contract: { + address: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698', + ens: '', + type: 'contract' + } + } + } + + const { user } = render( + + ) + + const setUnlimited = screen.queryByRole('button', { name: 'Unlimited' }) + await user.click(setUnlimited) + + const setRequested = screen.queryByRole('button', { name: 'Requested' }) + await user.click(setRequested) + + expect(onUpdate).toHaveBeenNthCalledWith(1, maxIntStr) + expect(onUpdate).toHaveBeenNthCalledWith(2, BigNumber('0x011170').toString(10)) + }) + + const requiredApprovalData = ['decimals', 'symbol', 'name'] + + requiredApprovalData.forEach((field) => { + it(`does not allow the user to edit the amount if ${field} is not present in approval data`, async () => { + const requestedAmountHex = '0x' + (100e6).toString(16) + const approval = { + id: 'erc20:approve', + data: { + spender: { + address: '0x9bc5baf874d2da8d216ae9f137804184ee5afef4', + ens: '', + type: 'external' + }, + amount: requestedAmountHex, + decimals: 6, + name: 'TST', + symbol: 'TST', + contract: { + address: '0x1eba19f260421142AD9Bf5ba193f6d4A0825e698', + ens: '', + type: 'contract' + } + } + } + + delete approval.data[field] + + const { user } = render( + {}} + /> + ) + + const custom = screen.queryByRole('button', { name: 'Custom' }) + expect(custom).toBeNull() + + const requestedAmount = screen.queryByRole('textbox') + const displayedContent = requestedAmount.textContent.trim() + expect(displayedContent).toBe(approval.data.decimals ? '100' : '100000000') + + // ensure click on requested amount textbox doesn't allow user to enter a custom amount + await user.click(requestedAmount) + expect(screen.queryByRole('textbox', { name: 'Custom Amount' })).toBeNull() + }) + }) +})