Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wallet fee options #217

Merged
merged 11 commits into from
Dec 17, 2024
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
Loading