feat: pending approval ui (#3186)

* feat: track approval txs

* refactor: update transactions

* feat: pending approval ui

* chore: fix pending approval doc

* fix: clarify optimized trade

* fix: use relative path for data uri assets
This commit is contained in:
Zach Pomerantz 2022-01-25 16:24:36 -08:00 committed by GitHub
parent c7633d910b
commit 8d145b908e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 97 additions and 41 deletions

@ -1,9 +1,9 @@
import ethereumLogoUrl from 'assets/images/ethereum-logo.png'
import arbitrumLogoUrl from 'assets/svg/arbitrum_logo.svg'
import optimismLogoUrl from 'assets/svg/optimistic_ethereum.svg'
import polygonMaticLogo from 'assets/svg/polygon-matic-logo.svg'
import ms from 'ms.macro' import ms from 'ms.macro'
import ethereumLogoUrl from '../assets/images/ethereum-logo.png'
import arbitrumLogoUrl from '../assets/svg/arbitrum_logo.svg'
import optimismLogoUrl from '../assets/svg/optimistic_ethereum.svg'
import polygonMaticLogo from '../assets/svg/polygon-matic-logo.svg'
import { SupportedChainId, SupportedL1ChainId, SupportedL2ChainId } from './chains' import { SupportedChainId, SupportedL1ChainId, SupportedL2ChainId } from './chains'
import { ARBITRUM_LIST, OPTIMISM_LIST } from './lists' import { ARBITRUM_LIST, OPTIMISM_LIST } from './lists'

@ -1,4 +1,4 @@
import { AlertTriangle, LargeIcon } from 'lib/icons' import { AlertTriangle, Icon, LargeIcon } from 'lib/icons'
import styled, { Color, css, keyframes, ThemedText } from 'lib/theme' import styled, { Color, css, keyframes, ThemedText } from 'lib/theme'
import { ReactNode } from 'react' import { ReactNode } from 'react'
@ -9,6 +9,10 @@ const StyledButton = styled(Button)`
border-radius: ${({ theme }) => theme.borderRadius}em; border-radius: ${({ theme }) => theme.borderRadius}em;
flex-grow: 1; flex-grow: 1;
transition: background-color 0.25s ease-out, flex-grow 0.25s ease-out, padding 0.25s ease-out; transition: background-color 0.25s ease-out, flex-grow 0.25s ease-out, padding 0.25s ease-out;
:disabled {
margin: -1px;
}
` `
const UpdateRow = styled(Row)`` const UpdateRow = styled(Row)``
@ -24,7 +28,7 @@ const grow = keyframes`
} }
` `
const updatedCss = css` const updateCss = css`
border: 1px solid ${({ theme }) => theme.outline}; border: 1px solid ${({ theme }) => theme.outline};
padding: calc(0.25em - 1px); padding: calc(0.25em - 1px);
padding-left: calc(0.75em - 1px); padding-left: calc(0.75em - 1px);
@ -41,19 +45,19 @@ const updatedCss = css`
} }
` `
export const Overlay = styled(Row)<{ updated?: boolean }>` export const Overlay = styled(Row)<{ update?: boolean }>`
border-radius: ${({ theme }) => theme.borderRadius}em; border-radius: ${({ theme }) => theme.borderRadius}em;
flex-direction: row-reverse; flex-direction: row-reverse;
min-height: 3.5em; min-height: 3.5em;
transition: padding 0.25s ease-out; transition: padding 0.25s ease-out;
${({ updated }) => updated && updatedCss} ${({ update }) => update && updateCss}
` `
export interface ActionButtonProps { export interface ActionButtonProps {
color?: Color color?: Color
disabled?: boolean disabled?: boolean
updated?: { message: ReactNode; action: ReactNode } update?: { message: ReactNode; action: ReactNode; icon?: Icon }
onClick: () => void onClick: () => void
onUpdate?: () => void onUpdate?: () => void
children: ReactNode children: ReactNode
@ -62,22 +66,22 @@ export interface ActionButtonProps {
export default function ActionButton({ export default function ActionButton({
color = 'accent', color = 'accent',
disabled, disabled,
updated, update,
onClick, onClick,
onUpdate, onUpdate,
children, children,
}: ActionButtonProps) { }: ActionButtonProps) {
return ( return (
<Overlay updated={Boolean(updated)} flex align="stretch"> <Overlay update={Boolean(update)} flex align="stretch">
<StyledButton color={color} disabled={disabled} onClick={updated ? onUpdate : onClick}> <StyledButton color={color} disabled={disabled} onClick={update ? onUpdate : onClick}>
<ThemedText.TransitionButton buttonSize={updated ? 'medium' : 'large'} color="currentColor"> <ThemedText.TransitionButton buttonSize={update ? 'medium' : 'large'} color="currentColor">
{updated ? updated.action : children} {update ? update.action : children}
</ThemedText.TransitionButton> </ThemedText.TransitionButton>
</StyledButton> </StyledButton>
{updated && ( {update && (
<UpdateRow gap={0.5}> <UpdateRow gap={0.5}>
<LargeIcon icon={AlertTriangle} /> <LargeIcon color="currentColor" icon={update.icon || AlertTriangle} />
<ThemedText.Subhead2>{updated?.message}</ThemedText.Subhead2> <ThemedText.Subhead2>{update?.message}</ThemedText.Subhead2>
</UpdateRow> </UpdateRow>
)} )}
</Overlay> </Overlay>

@ -147,7 +147,7 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial
<ActionButton <ActionButton
onClick={onConfirm} onClick={onConfirm}
onUpdate={() => setConfirmedTrade(trade)} onUpdate={() => setConfirmedTrade(trade)}
updated={doesTradeDiffer ? priceUpdate : undefined} update={doesTradeDiffer ? priceUpdate : undefined}
> >
<Trans>Confirm swap</Trans> <Trans>Confirm swap</Trans>
</ActionButton> </ActionButton>

@ -1,29 +1,50 @@
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { Token } from '@uniswap/sdk-core'
import { CHAIN_INFO } from 'constants/chainInfo'
import { useSwapInfo } from 'lib/hooks/swap' import { useSwapInfo } from 'lib/hooks/swap'
import useSwapApproval, { ApprovalState, useSwapApprovalOptimizedTrade } from 'lib/hooks/swap/useSwapApproval' import useSwapApproval, {
ApprovalState,
useSwapApprovalOptimizedTrade,
useSwapRouterAddress,
} from 'lib/hooks/swap/useSwapApproval'
import { useAddTransaction } from 'lib/hooks/transactions' import { useAddTransaction } from 'lib/hooks/transactions'
import { useIsPendingApproval } from 'lib/hooks/transactions' import { usePendingApproval } from 'lib/hooks/transactions'
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
import { Link, Spinner } from 'lib/icons'
import { Field } from 'lib/state/swap' import { Field } from 'lib/state/swap'
import { TransactionType } from 'lib/state/transactions' import { TransactionType } from 'lib/state/transactions'
import styled from 'lib/theme'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import ActionButton from '../ActionButton' import ActionButton from '../ActionButton'
import Dialog from '../Dialog' import Dialog from '../Dialog'
import Row from '../Row'
import { SummaryDialog } from './Summary' import { SummaryDialog } from './Summary'
interface SwapButtonProps { interface SwapButtonProps {
disabled?: boolean disabled?: boolean
} }
const EtherscanA = styled.a`
color: currentColor;
text-decoration: none;
`
function useIsPendingApproval(token?: Token, spender?: string): boolean {
return Boolean(usePendingApproval(token, spender))
}
export default function SwapButton({ disabled }: SwapButtonProps) { export default function SwapButton({ disabled }: SwapButtonProps) {
const { chainId } = useActiveWeb3React()
const { const {
trade, trade,
allowedSlippage, allowedSlippage,
currencies: { [Field.INPUT]: inputCurrency },
currencyBalances: { [Field.INPUT]: inputCurrencyBalance }, currencyBalances: { [Field.INPUT]: inputCurrencyBalance },
currencyAmounts: { [Field.INPUT]: inputCurrencyAmount }, currencyAmounts: { [Field.INPUT]: inputCurrencyAmount },
} = useSwapInfo() } = useSwapInfo()
const [activeTrade, setActiveTrade] = useState<typeof trade.trade | undefined>(undefined) const [activeTrade, setActiveTrade] = useState<typeof trade.trade | undefined>()
useEffect(() => { useEffect(() => {
setActiveTrade((activeTrade) => activeTrade && trade.trade) setActiveTrade((activeTrade) => activeTrade && trade.trade)
}, [trade]) }, [trade])
@ -33,6 +54,10 @@ export default function SwapButton({ disabled }: SwapButtonProps) {
// Use trade.trade if there is no swap optimized trade. This occurs if approvals are still pending. // Use trade.trade if there is no swap optimized trade. This occurs if approvals are still pending.
useSwapApprovalOptimizedTrade(trade.trade, allowedSlippage, useIsPendingApproval) || trade.trade useSwapApprovalOptimizedTrade(trade.trade, allowedSlippage, useIsPendingApproval) || trade.trade
const [approval, getApproval] = useSwapApproval(optimizedTrade, allowedSlippage, useIsPendingApproval) const [approval, getApproval] = useSwapApproval(optimizedTrade, allowedSlippage, useIsPendingApproval)
const approvalHash = usePendingApproval(
inputCurrency?.isToken ? inputCurrency : undefined,
useSwapRouterAddress(optimizedTrade)
)
const addTransaction = useAddTransaction() const addTransaction = useAddTransaction()
const addApprovalTransaction = useCallback(() => { const addApprovalTransaction = useCallback(() => {
@ -46,13 +71,27 @@ export default function SwapButton({ disabled }: SwapButtonProps) {
const actionProps = useMemo(() => { const actionProps = useMemo(() => {
if (disabled) return { disabled: true } if (disabled) return { disabled: true }
if (inputCurrencyAmount && inputCurrencyBalance?.greaterThan(inputCurrencyAmount)) { if (chainId && inputCurrencyAmount && inputCurrencyBalance?.greaterThan(inputCurrencyAmount)) {
// TODO(zzmp): Update UI for pending approvals.
if (approval === ApprovalState.PENDING) { if (approval === ApprovalState.PENDING) {
return { disabled: true } return {
disabled: true,
update: {
message: (
<EtherscanA href={approvalHash && `${CHAIN_INFO[chainId].explorer}tx/${approvalHash}`} target="_blank">
<Row gap={0.25}>
<Trans>
Approval pending <Link />
</Trans>
</Row>
</EtherscanA>
),
action: <Trans>Approve</Trans>,
icon: Spinner,
},
}
} else if (approval === ApprovalState.NOT_APPROVED) { } else if (approval === ApprovalState.NOT_APPROVED) {
return { return {
updated: { update: {
message: <Trans>Approve {inputCurrencyAmount.currency.symbol} first</Trans>, message: <Trans>Approve {inputCurrencyAmount.currency.symbol} first</Trans>,
action: <Trans>Approve</Trans>, action: <Trans>Approve</Trans>,
}, },
@ -62,7 +101,7 @@ export default function SwapButton({ disabled }: SwapButtonProps) {
} }
return { disabled: true } return { disabled: true }
}, [approval, disabled, inputCurrencyAmount, inputCurrencyBalance]) }, [approval, approvalHash, chainId, disabled, inputCurrencyAmount, inputCurrencyBalance])
const onConfirm = useCallback(() => { const onConfirm = useCallback(() => {
// TODO(zzmp): Transact the trade. // TODO(zzmp): Transact the trade.

@ -34,22 +34,15 @@ function useSwapApprovalStates(
return useMemo(() => ({ v2, v3, v2V3 }), [v2, v2V3, v3]) return useMemo(() => ({ v2, v3, v2V3 }), [v2, v2V3, v3])
} }
// wraps useApproveCallback in the context of a swap export function useSwapRouterAddress(
export default function useSwapApproval(
trade: trade:
| V2Trade<Currency, Currency, TradeType> | V2Trade<Currency, Currency, TradeType>
| V3Trade<Currency, Currency, TradeType> | V3Trade<Currency, Currency, TradeType>
| Trade<Currency, Currency, TradeType> | Trade<Currency, Currency, TradeType>
| undefined, | undefined
allowedSlippage: Percent,
useIsPendingApproval: (token?: Token, spender?: string) => boolean
) { ) {
const { chainId } = useActiveWeb3React() const { chainId } = useActiveWeb3React()
const amountToApprove = useMemo( return useMemo(
() => (trade && trade.inputAmount.currency.isToken ? trade.maximumAmountIn(allowedSlippage) : undefined),
[trade, allowedSlippage]
)
const spender = useMemo(
() => () =>
chainId chainId
? trade instanceof V2Trade ? trade instanceof V2Trade
@ -60,6 +53,23 @@ export default function useSwapApproval(
: undefined, : undefined,
[chainId, trade] [chainId, trade]
) )
}
// wraps useApproveCallback in the context of a swap
export default function useSwapApproval(
trade:
| V2Trade<Currency, Currency, TradeType>
| V3Trade<Currency, Currency, TradeType>
| Trade<Currency, Currency, TradeType>
| undefined,
allowedSlippage: Percent,
useIsPendingApproval: (token?: Token, spender?: string) => boolean
) {
const amountToApprove = useMemo(
() => (trade && trade.inputAmount.currency.isToken ? trade.maximumAmountIn(allowedSlippage) : undefined),
[trade, allowedSlippage]
)
const spender = useSwapRouterAddress(trade)
const approval = useApproval(amountToApprove, spender, useIsPendingApproval) const approval = useApproval(amountToApprove, spender, useIsPendingApproval)
if (trade instanceof V2Trade || trade instanceof V3Trade) { if (trade instanceof V2Trade || trade instanceof V3Trade) {

@ -40,15 +40,16 @@ export function useAddTransaction() {
) )
} }
export function useIsPendingApproval(token?: Token, spender?: string) { /** Returns the hash of a pending approval transaction, if it exists. */
export function usePendingApproval(token?: Token, spender?: string): string | undefined {
const { chainId } = useActiveWeb3React() const { chainId } = useActiveWeb3React()
const txs = useAtomValue(transactionsAtom) const txs = useAtomValue(transactionsAtom)
if (!chainId || !token || !spender) return false if (!chainId || !token || !spender) return undefined
const chainTxs = txs[chainId] const chainTxs = txs[chainId]
if (!chainTxs) return false if (!chainTxs) return undefined
return Object.values(chainTxs).some( return Object.values(chainTxs).find(
(tx) => (tx) =>
tx && tx &&
tx.receipt === undefined && tx.receipt === undefined &&
@ -56,7 +57,7 @@ export function useIsPendingApproval(token?: Token, spender?: string) {
tx.info.tokenAddress === token.address && tx.info.tokenAddress === token.address &&
tx.info.spenderAddress === spender && tx.info.spenderAddress === spender &&
isTransactionRecent(tx) isTransactionRecent(tx)
) )?.info.response.hash
} }
export function TransactionsUpdater() { export function TransactionsUpdater() {

@ -11,6 +11,7 @@ import {
ChevronDown as ChevronDownIcon, ChevronDown as ChevronDownIcon,
Clock as ClockIcon, Clock as ClockIcon,
CreditCard as CreditCardIcon, CreditCard as CreditCardIcon,
ExternalLink as LinkIcon,
HelpCircle as HelpCircleIcon, HelpCircle as HelpCircleIcon,
Info as InfoIcon, Info as InfoIcon,
Settings as SettingsIcon, Settings as SettingsIcon,
@ -77,6 +78,7 @@ export const Clock = icon(ClockIcon)
export const CreditCard = icon(CreditCardIcon) export const CreditCard = icon(CreditCardIcon)
export const HelpCircle = icon(HelpCircleIcon) export const HelpCircle = icon(HelpCircleIcon)
export const Info = icon(InfoIcon) export const Info = icon(InfoIcon)
export const Link = icon(LinkIcon)
export const Settings = icon(SettingsIcon) export const Settings = icon(SettingsIcon)
export const Slash = icon(SlashIcon) export const Slash = icon(SlashIcon)
export const Trash2 = icon(Trash2Icon) export const Trash2 = icon(Trash2Icon)