Skip to content

Commit

Permalink
Wallet fee options (#217)
Browse files Browse the repository at this point in the history
* WIP

* Check for collection logo image

* Make collection detail view image loader match container width

* Disable back button in conformation screen

* Add missing border radius for collection detail list item

* Update fee option hook to allow last registered handler to be triggered

* Show txn pending state in confirmation view

* Fix zeroAddress and null case for native token fee option

* Remove unnecessary id

* Remove logs

* Update transaction confirmation and fee option UIs
  • Loading branch information
tolgahan-arikan authored Dec 17, 2024
1 parent ac58f51 commit 7fe369c
Show file tree
Hide file tree
Showing 15 changed files with 973 additions and 373 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
ProviderDisconnectedError,
TransactionRejectedRpcError,
UserRejectedRequestError,
getAddress
getAddress,
zeroAddress
} from 'viem'
import { createConnector } from 'wagmi'

Expand Down Expand Up @@ -322,7 +323,13 @@ export class SequenceWaasProvider extends ethers.AbstractProvider implements EIP
throw new UserRejectedRequestError(new Error('User confirmation ids do not match'))
}

selectedFeeOption = feeOptions.find(feeOption => feeOption.token.contractAddress === confirmation.feeTokenAddress)
selectedFeeOption = feeOptions.find(feeOption => {
// Handle the case where feeTokenAddress is ZeroAddress and contractAddress is null
if (confirmation.feeTokenAddress === zeroAddress && feeOption.token.contractAddress === null) {
return true
}
return feeOption.token.contractAddress === confirmation.feeTokenAddress
})
}

if (this.requestConfirmationHandler && this.showConfirmation) {
Expand Down
26 changes: 26 additions & 0 deletions packages/kit/src/hooks/useCheckWaasFeeOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client'

import { FeeOption, Transaction } from '@0xsequence/waas'
import { useConnections } from 'wagmi'

export function useCheckWaasFeeOptions(): (params: { transactions: Transaction[]; chainId: number }) => Promise<{
feeQuote: string | undefined
feeOptions: FeeOption[] | undefined
isSponsored: boolean
}> {
const connections = useConnections()
const waasConnector = connections.find(c => c.connector.id.includes('waas'))?.connector

return async ({ transactions, chainId }) => {
if (!waasConnector) {
throw new Error('WaaS connector not found')
}

const waasProvider = (waasConnector as any).sequenceWaasProvider
if (!waasProvider) {
throw new Error('WaaS provider not found')
}

return waasProvider.checkTransactionFeeOptions({ transactions, chainId })
}
}
69 changes: 36 additions & 33 deletions packages/kit/src/hooks/useWaasFeeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@

import { FeeOption } from '@0xsequence/waas'
import { ethers } from 'ethers'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Connector, useConnections } from 'wagmi'

import { Deferred } from '../utils/deferred'

// null means it's native token
let _pendingFeeConfirmation: Deferred<{ id: string; feeTokenAddress?: string | null; confirmed: boolean }> | undefined

export type WaasFeeOptionConfirmation = {
id: string
options: FeeOption[]
Expand All @@ -23,49 +20,55 @@ export function useWaasFeeOptions(): [
] {
const connections = useConnections()
const waasConnector: Connector | undefined = connections.find(c => c.connector.id.includes('waas'))?.connector

const [pendingFeeOptionConfirmation, setPendingFeeOptionConfirmation] = useState<WaasFeeOptionConfirmation | undefined>()
const pendingConfirmationRef = useRef<Deferred<{ id: string; feeTokenAddress?: string | null; confirmed: boolean }>>()

function confirmPendingFeeOption(id: string, feeTokenAddress: string | null) {
_pendingFeeConfirmation?.resolve({ id, feeTokenAddress, confirmed: true })
setPendingFeeOptionConfirmation(undefined)
_pendingFeeConfirmation = undefined
if (pendingConfirmationRef.current) {
pendingConfirmationRef.current.resolve({ id, feeTokenAddress, confirmed: true })
setPendingFeeOptionConfirmation(undefined)
pendingConfirmationRef.current = undefined
}
}

function rejectPendingFeeOption(id: string) {
_pendingFeeConfirmation?.resolve({ id, feeTokenAddress: undefined, confirmed: false })
setPendingFeeOptionConfirmation(undefined)
_pendingFeeConfirmation = undefined
if (pendingConfirmationRef.current) {
pendingConfirmationRef.current.resolve({ id, feeTokenAddress: undefined, confirmed: false })
setPendingFeeOptionConfirmation(undefined)
pendingConfirmationRef.current = undefined
}
}

useEffect(() => {
async function setup() {
if (!waasConnector) {
return
}
if (!waasConnector) {
return
}

const waasProvider = (waasConnector as any).sequenceWaasProvider
const waasProvider = (waasConnector as any).sequenceWaasProvider
if (!waasProvider) {
return
}

if (!waasProvider) {
return
}
const originalHandler = waasProvider.feeConfirmationHandler

waasProvider.feeConfirmationHandler = {
confirmFeeOption(
id: string,
options: FeeOption[],
txs: ethers.Transaction[],
chainId: number
): Promise<{ id: string; feeTokenAddress?: string | null; confirmed: boolean }> {
const pending = new Deferred<{ id: string; confirmed: boolean }>()
setPendingFeeOptionConfirmation({ id, options, chainId })
_pendingFeeConfirmation = pending
return pending.promise
}
waasProvider.feeConfirmationHandler = {
confirmFeeOption(
id: string,
options: FeeOption[],
txs: ethers.Transaction[],
chainId: number
): Promise<{ id: string; feeTokenAddress?: string | null; confirmed: boolean }> {
const pending = new Deferred<{ id: string; feeTokenAddress?: string | null; confirmed: boolean }>()
pendingConfirmationRef.current = pending
setPendingFeeOptionConfirmation({ id, options, chainId })
return pending.promise
}
}
setup()
})

return () => {
waasProvider.feeConfirmationHandler = originalHandler
}
}, [waasConnector])

return [pendingFeeOptionConfirmation, confirmPendingFeeOption, rejectPendingFeeOption]
}
1 change: 1 addition & 0 deletions packages/kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export { useOpenConnectModal } from './hooks/useOpenConnectModal'
export { useTheme } from './hooks/useTheme'
export { useWalletSettings } from './hooks/useWalletSettings'
export { useWaasFeeOptions } from './hooks/useWaasFeeOptions'
export { useCheckWaasFeeOptions } from './hooks/useCheckWaasFeeOptions'
export { useWaasSignInEmail } from './hooks/useWaasSignInEmail'
export { useSignInEmail } from './hooks/useSignInEmail'
export { useProjectAccessKey } from './hooks/useProjectAccessKey'
Expand Down
2 changes: 2 additions & 0 deletions packages/wallet/src/contexts/Navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export type History = Navigation[]
type NavigationContext = {
setHistory: (history: History) => void
history: History
isBackButtonEnabled: boolean
setIsBackButtonEnabled: (enabled: boolean) => void
}

export const [useNavigationContext, NavigationContextProvider] = createGenericContext<NavigationContext>()
53 changes: 53 additions & 0 deletions packages/wallet/src/shared/Alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Box, Button, Text } from '@0xsequence/design-system'
import React, { ComponentProps } from 'react'

export type AlertProps = {
title: string
description: string
secondaryDescription?: string
variant: 'negative' | 'warning' | 'positive'
buttonProps?: ComponentProps<typeof Button>
children?: React.ReactNode
}

export const Alert = ({ title, description, secondaryDescription, variant, buttonProps, children }: AlertProps) => {
return (
<Box borderRadius="md" background={variant}>
<Box
background="backgroundOverlay"
borderRadius="md"
paddingX={{ sm: '4', md: '5' }}
paddingY="4"
width="full"
flexDirection="column"
gap="3"
>
<Box width="full" flexDirection={{ sm: 'column', md: 'row' }} gap="2" justifyContent="space-between">
<Box flexDirection="column" gap="1">
<Text variant="normal" color="text100" fontWeight="medium">
{title}
</Text>

<Text variant="normal" color="text50" fontWeight="medium">
{description}
</Text>

{secondaryDescription && (
<Text variant="normal" color="text80" fontWeight="medium">
{secondaryDescription}
</Text>
)}
</Box>

{buttonProps ? (
<Box background={variant} borderRadius="sm" width={'min'} height={'min'}>
<Button variant="emphasis" shape="square" flexShrink="0" {...buttonProps} />
</Box>
) : null}
</Box>

{children}
</Box>
</Box>
)
}
138 changes: 138 additions & 0 deletions packages/wallet/src/shared/FeeOptionSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { Box, Text, TokenImage } from '@0xsequence/design-system'
import { ZeroAddress, formatUnits, parseUnits } from 'ethers'
import React from 'react'

import { Alert, AlertProps } from './Alert'

export interface FeeOption {
token: FeeToken
to: string
value: string
gasLimit: number
}
export interface FeeToken {
chainId: number
name: string
symbol: string
decimals?: number
logoURL: string
contractAddress?: string
tokenID?: string
}

export interface FeeOptionBalance {
tokenName: string
decimals: number
balance: string
}

export interface FeeOptionSelectorProps {
txnFeeOptions: FeeOption[]
feeOptionBalances: FeeOptionBalance[]
selectedFeeOptionAddress: string | undefined
setSelectedFeeOptionAddress: (address: string) => void
}

const isBalanceSufficient = (balance: string, fee: string, decimals: number) => {
const balanceBN = parseUnits(balance, decimals)
const feeBN = parseUnits(fee, decimals)
return balanceBN >= feeBN
}

export const FeeOptionSelector: React.FC<FeeOptionSelectorProps> = ({
txnFeeOptions,
feeOptionBalances,
selectedFeeOptionAddress,
setSelectedFeeOptionAddress
}) => {
const [feeOptionAlert, setFeeOptionAlert] = React.useState<AlertProps | undefined>()

const sortedOptions = [...txnFeeOptions].sort((a, b) => {
const balanceA = feeOptionBalances.find(balance => balance.tokenName === a.token.name)
const balanceB = feeOptionBalances.find(balance => balance.tokenName === b.token.name)
const isSufficientA = balanceA ? isBalanceSufficient(balanceA.balance, a.value, a.token.decimals || 0) : false
const isSufficientB = balanceB ? isBalanceSufficient(balanceB.balance, b.value, b.token.decimals || 0) : false
return isSufficientA === isSufficientB ? 0 : isSufficientA ? -1 : 1
})

return (
<Box marginTop="3" width="full">
<Text variant="normal" color="text100" fontWeight="bold">
Select a fee option
</Text>
<Box flexDirection="column" marginTop="2" gap="2">
{sortedOptions.map((option, index) => {
const isSelected = selectedFeeOptionAddress === (option.token.contractAddress ?? ZeroAddress)
const balance = feeOptionBalances.find(b => b.tokenName === option.token.name)
const isSufficient = isBalanceSufficient(balance?.balance || '0', option.value, option.token.decimals || 0)
return (
<Box
key={index}
paddingX="3"
paddingY="2"
borderRadius="md"
borderColor={isSelected ? 'borderFocus' : 'transparent'}
borderWidth="thick"
borderStyle="solid"
background="backgroundRaised"
onClick={() => {
if (isSufficient) {
setSelectedFeeOptionAddress(option.token.contractAddress ?? ZeroAddress)
setFeeOptionAlert(undefined)
} else {
setFeeOptionAlert({
title: `Insufficient ${option.token.name} balance`,
description: `Please select another fee option or add funds to your wallet.`,
variant: 'warning'
})
}
}}
cursor={isSufficient ? 'pointer' : 'default'}
opacity={isSufficient ? '100' : '50'}
>
<Box flexDirection="row" justifyContent="space-between" alignItems="center">
<Box flexDirection="row" alignItems="center" gap="2">
<TokenImage src={option.token.logoURL} symbol={option.token.name} />
<Box flexDirection="column">
<Text variant="small" color="text100" fontWeight="bold">
{option.token.name}
</Text>
<Text variant="xsmall" color="text80">
Fee:{' '}
{parseFloat(formatUnits(BigInt(option.value), option.token.decimals || 0)).toLocaleString(undefined, {
maximumFractionDigits: 6
})}
</Text>
</Box>
</Box>
<Box flexDirection="column" alignItems="flex-end">
<Text variant="xsmall" color="text80">
Balance:
</Text>
<Text variant="xsmall" color="text100">
{parseFloat(formatUnits(BigInt(balance?.balance || '0'), option.token.decimals || 0)).toLocaleString(
undefined,
{ maximumFractionDigits: 6 }
)}
</Text>
</Box>
</Box>
</Box>
)
})}
</Box>
<Box marginTop="3" alignItems="flex-end" justifyContent="center" flexDirection="column">
{feeOptionAlert && (
<Box marginTop="3">
<Alert
title={feeOptionAlert.title}
description={feeOptionAlert.description}
secondaryDescription={feeOptionAlert.secondaryDescription}
variant={feeOptionAlert.variant}
/>
</Box>
)}
</Box>
</Box>
)
}
Loading

0 comments on commit 7fe369c

Please sign in to comment.