diff --git a/CLAUDE.md b/CLAUDE.md index f782a89f..f113399a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,7 @@ The `/issue` skill applies these labels automatically when creating issues via C - Path aliases: `@/src/*` and `@packageJSON` - All env vars prefixed with `PUBLIC_` and validated in `src/env.ts` - JSDoc comments on exported functions/components (follow existing patterns) +- Use `@precondition` in JSDoc ONLY for conditions enforced at runtime with a throw/guard. Use `@expects` for documented assumptions the caller is responsible for (TypeScript-enforced, gracefully handled, or downstream-validated). If you write `@precondition`, the function MUST validate and throw on violation. ## Styling diff --git a/docs/architecture/adapter-architecture-spec.md b/docs/architecture/adapter-architecture-spec.md index 8e7bc4f4..ff7f8ff7 100644 --- a/docs/architecture/adapter-architecture-spec.md +++ b/docs/architecture/adapter-architecture-spec.md @@ -1804,6 +1804,115 @@ const bridge = useTransactionFlow([ --- +## Consumer Error Handling Guide + +This section maps every user-facing action to the exact SDK errors it can produce, documents whether the hook handles those errors internally or propagates them, and clarifies the two distinct error handling patterns consumers encounter. + +### Per-action error table + +Each row lists a user action, the SDK error classes that can be thrown, and how the hook layer handles them. "Propagates" means the consumer must `try/catch`. "Catches + re-throws" means the hook sets its `error` state AND re-throws — the consumer can read `error` reactively or `try/catch` the async call. + +| User Action | Possible Errors | Hook Behavior | +|---|---|---| +| Connect wallet | `WalletNotInstalledError`, `WalletConnectionRejectedError`, `ChainNotSupportedError` | `useWallet().connect()` propagates — consumer must try/catch | +| Disconnect wallet | _(none — no-op if already disconnected)_ | `useWallet().disconnect()` propagates (but does not throw in practice) | +| Sign message | `WalletNotConnectedError`, `SigningRejectedError` | `useWallet().signMessage()` propagates — consumer must try/catch | +| Sign typed data | `WalletNotConnectedError`, `SigningRejectedError`, `CapabilityNotSupportedError` | `useWallet().signTypedData()` propagates — consumer must try/catch. `CapabilityNotSupportedError` is thrown synchronously if the adapter does not support the capability. | +| Switch chain | `WalletNotConnectedError`, `ChainNotSupportedError` | `useWallet().switchChain()` propagates — consumer must try/catch | +| Execute transaction | `AdapterNotFoundError`, `WalletNotConnectedError`, `TransactionNotReadyError`, `PreStepsNotExecutedError`, `InsufficientFundsError`, `InvalidSignerError`, `ChainNotSupportedError` | `useTransaction().execute()` catches all, sets `error` state, fires `lifecycle.onError`, AND re-throws | +| Prepare transaction | `AdapterNotFoundError`, `TransactionNotReadyError`, `InsufficientFundsError` | `useTransaction().prepare()` catches all, sets `error` state AND re-throws | +| Execute single pre-step | `Error` (prepare not called), `RangeError` (index out of bounds), `AdapterNotFoundError`, `WalletNotConnectedError` | `useTransaction().executePreStep()` — precondition errors (`Error`, `RangeError`, `AdapterNotFoundError`, `WalletNotConnectedError`) propagate directly without setting `error` state. Execution errors (from adapter `execute`/`confirm`) are caught, set `error` state, and re-thrown. | +| Execute all pre-steps | Same as single pre-step (delegates to `executePreStep`) | `useTransaction().executeAllPreSteps()` — errors propagate from `executePreStep` | +| Resolve wallet adapter | `AdapterNotFoundError`, `AmbiguousAdapterError` | `useWallet()` throws synchronously during render — React error boundary catches | +| Provider initialization | `ChainRegistryConflictError`, `Error` (chainType mismatch) | `DAppBoosterProvider` throws synchronously during render — React error boundary catches | + +### Error semantics: `ChainNotSupportedError` vs `AdapterNotFoundError` + +Both errors mean "this chain doesn't work" to a consumer, but they occur at different levels: + +- **`AdapterNotFoundError`** — thrown by the hook resolution layer. No registered adapter's `supportedChains` includes the requested `chainId`. This means the SDK has no adapter at all for this chain. + - **Throw sites:** `useWallet()` (via `resolveAdapter()`), `useTransaction().execute()`, `useTransaction().prepare()`, `useTransaction().executePreStep()` + +- **`ChainNotSupportedError`** — thrown by the adapter itself. An adapter was found but its own validation rejects the `chainId`. This occurs inside adapter methods like `connect()`, `switchChain()`, and `execute()`. + - **Throw sites:** `WalletAdapter.connect()` (when `options.chainId` is not in `supportedChains`), `WalletAdapter.switchChain()`, `TransactionAdapter.execute()` + +In practice, `AdapterNotFoundError` fires first (during resolution) if no adapter matches. `ChainNotSupportedError` fires later (during execution) if the adapter was resolved via a different chain but the target chain is not supported. + +### Error semantics: `InvalidSignerError` + +`InvalidSignerError` is an internal safety check at the adapter boundary. The `TransactionAdapter.execute()` method validates that the signer it receives is the correct type (e.g., a viem `WalletClient` for EVM). This guards against passing an SVM signer to an EVM adapter. Consumers should not normally encounter this error — it indicates a misconfigured adapter stack, not a user action failure. + +### Hook error handling patterns + +The SDK uses two distinct patterns for error handling at the hook layer: + +#### Pattern 1: `useWallet` methods — raw passthrough + +All `useWallet()` methods (`connect`, `disconnect`, `signMessage`, `signTypedData`, `switchChain`, `getSigner`) delegate directly to the adapter. Errors propagate unmodified to the consumer. There is no `error` state on the return object. + +```ts +const { connect, signMessage } = useWallet({ chainType: 'evm' }) + +try { + await connect() +} catch (err) { + if (err instanceof WalletConnectionRejectedError) { + // User cancelled — show a dismissable message + } + if (err instanceof WalletNotInstalledError) { + // Wallet not found — show install instructions + } +} + +try { + const result = await signMessage({ message: 'Hello' }) +} catch (err) { + if (err instanceof SigningRejectedError) { + // User cancelled signing + } +} +``` + +The `signMessage` and `signTypedData` wrappers fire `walletLifecycle.onSignError` before re-throwing, but the error still propagates to the consumer. + +#### Pattern 2: `useTransaction` methods — catch, set state, re-throw + +All `useTransaction()` async methods (`execute`, `prepare`, `executePreStep`, `executeAllPreSteps`) catch errors, set the `error` property on the hook return, and re-throw. Consumers can handle errors in two ways: + +**Reactive (read `error` state):** + +```ts +const { execute, error, phase } = useTransaction() + +// In a click handler: +execute(params).catch(() => {}) // swallow — read error reactively + +// In JSX: +{error && } +``` + +**Imperative (try/catch):** + +```ts +const { execute } = useTransaction() + +try { + const result = await execute(params) + // success +} catch (err) { + if (err instanceof TransactionNotReadyError) { + // preparation failed + } + if (err instanceof PreStepsNotExecutedError) { + // manual pre-steps not completed + } +} +``` + +**Important nuance for `executePreStep`:** Precondition checks (prepare not called, index out of bounds, adapter not found, wallet not connected) throw directly without setting `error` state. Only errors during the actual on-chain execution (adapter `execute`/`confirm` calls) go through the catch-set-rethrow pattern. This means the reactive `error` state only captures execution failures, not programmer errors. + +--- + ## 12. Migration Path ### From current codebase to adapter architecture diff --git a/src/chakra/ConnectWalletButton.tsx b/src/chakra/ConnectWalletButton.tsx new file mode 100644 index 00000000..98413199 --- /dev/null +++ b/src/chakra/ConnectWalletButton.tsx @@ -0,0 +1,29 @@ +import type { FC } from 'react' +import { ConnectWalletButton as HeadlessConnectWalletButton } from '@/src/sdk/react/components/ConnectWalletButton' +import type { UseWalletOptions } from '@/src/sdk/react/hooks/useWallet' +import ConnectButton from '@/src/wallet/components/ConnectButton' + +/** + * Chakra-styled connect/account button. + * + * Composes the headless ConnectWalletButton with the Chakra ConnectButton + * for styled rendering. + */ +export const ConnectWalletButton: FC = ({ + label = 'Connect', + ...walletOptions +}) => { + return ( + ( + + {status.connected && truncatedAddress ? truncatedAddress : label} + + )} + /> + ) +} diff --git a/src/chakra/WalletGuard.tsx b/src/chakra/WalletGuard.tsx new file mode 100644 index 00000000..b7343abb --- /dev/null +++ b/src/chakra/WalletGuard.tsx @@ -0,0 +1,50 @@ +import type { FC, ReactNode } from 'react' +import { + WalletGuard as HeadlessWalletGuard, + type WalletRequirement, +} from '@/src/sdk/react/components/WalletGuard' +import SwitchChainButton from '@/src/wallet/components/SwitchChainButton' +import { ConnectWalletButton } from './ConnectWalletButton' + +interface ChakraWalletGuardProps { + chainId?: string | number + chainType?: string + children?: ReactNode + require?: WalletRequirement[] + switchChainLabel?: string +} + +/** + * Chakra-styled WalletGuard. + * + * Composes the headless WalletGuard with Chakra-styled ConnectWalletButton + * and SwitchChainButton as default render props. + */ +export const WalletGuard: FC = ({ + chainId, + chainType, + children, + require: requirements, + switchChainLabel = 'Switch to', +}) => { + return ( + ( + + )} + renderSwitchChain={({ chainName, onSwitch }) => ( + + {switchChainLabel} {chainName} + + )} + > + {children} + + ) +} diff --git a/src/chakra/index.ts b/src/chakra/index.ts new file mode 100644 index 00000000..bd7a5379 --- /dev/null +++ b/src/chakra/index.ts @@ -0,0 +1,2 @@ +export { ConnectWalletButton } from './ConnectWalletButton' +export { WalletGuard } from './WalletGuard' diff --git a/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx b/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx index 90201aae..9281bf3c 100644 --- a/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx @@ -9,7 +9,7 @@ import { OptionsDropdown } from '@/src/components/pageComponents/home/Examples/d import { Spinner } from '@/src/core/components' const EnsNameSearch = ({ address }: { address?: Address }) => { - const { data, error, status } = useEnsName({ + const { data, status } = useEnsName({ address: address, chainId: mainnet.id, }) @@ -19,7 +19,7 @@ const EnsNameSearch = ({ address }: { address?: Address }) => { {status === 'pending' ? ( ) : status === 'error' ? ( - `Error fetching ENS name (${error.message})` + 'ENS resolution unavailable' ) : data === undefined || data === null ? ( 'Not available' ) : ( @@ -39,10 +39,7 @@ const EnsName = () => { }, debounceTime) const onChange = (e: ChangeEvent) => { - const value = e.target.value as Address - - setValue(value) - debouncedSearch(value) + setValue(e.target.value as Address) } const addresses = [ diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx index cfe50ea5..c29537cd 100644 --- a/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx +++ b/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx @@ -44,7 +44,7 @@ const Hash: FC = ({ chain, hash, truncatedHashLength }) => { borderRadius="8px" color="var(--theme-hash-color)" cursor="default" - explorerURL={getExplorerLink({ chain, hashOrAddress: hash })} + explorerURL={getExplorerLink({ chain, hashOrAddress: hash }) ?? ''} fontSize="14px" hash={hash} minHeight="64px" diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx index 5dcfd3e0..45d80096 100644 --- a/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx @@ -5,10 +5,9 @@ import hashHandling from './index' const system = createSystem(defaultConfig) -vi.mock('@/src/wallet/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(() => ({ - isWalletConnected: false, - walletChainId: undefined, +vi.mock('@/src/sdk/react/hooks', () => ({ + useWallet: vi.fn(() => ({ + status: { connected: false, activeAccount: null, connectedChainIds: [], connecting: false }, })), })) diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx index 4854ba23..b0eb7cd5 100644 --- a/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx @@ -7,7 +7,7 @@ import Icon from '@/src/components/pageComponents/home/Examples/demos/HashHandli import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper' import { HashInput, Spinner } from '@/src/core/components' import type { DetectedHash } from '@/src/core/utils' -import { useWeb3Status } from '@/src/wallet/hooks' +import { useWallet } from '@/src/sdk/react/hooks' const AlertIcon = () => ( { const [loading, setLoading] = useState() const notFound = searchResult && searchResult.type === null const found = searchResult && searchResult.type !== null - const { isWalletConnected, walletChainId } = useWeb3Status() + const { status } = useWallet() + const isWalletConnected = status.connected + const walletChainId = status.connectedChainIds[0] as number | undefined const onLoading = (isLoading: boolean) => { setLoading(isLoading) @@ -185,7 +187,14 @@ const HashHandling = ({ ...restProps }) => { )} diff --git a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx index 756ff68f..fc08badd 100644 --- a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx @@ -1,38 +1,73 @@ import { Flex, Span } from '@chakra-ui/react' import { useState } from 'react' -import type { Address } from 'viem' +import type { Address, TransactionReceipt } from 'viem' import { parseEther } from 'viem' import { optimismSepolia, sepolia } from 'viem/chains' import { extractTransactionDepositedLogs, getL2TransactionHash } from 'viem/op-stack' +import { WalletGuard } from '@/src/chakra' import Icon from '@/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/Icon' import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper' import { getContract } from '@/src/contracts/definitions' -import { useL1CrossDomainMessengerProxy } from '@/src/contracts/hooks/useOPL1CrossDomainMessengerProxy' -import { Hash } from '@/src/core/components' -import { getExplorerLink, withSuspenseAndRetry } from '@/src/core/utils' -import { LegacyTransactionButton as TransactionButton } from '@/src/transactions/components' -import { useWeb3StatusConnected, WalletStatusVerifier } from '@/src/wallet/components' +import { buildCrossDomainMessageParams } from '@/src/contracts/hooks/useOPL1CrossDomainMessengerProxy' +import { Hash, PrimaryButton, Spinner } from '@/src/core/components' +import { withSuspenseAndRetry } from '@/src/core/utils' +import { getExplorerUrl } from '@/src/sdk/core/chain/explorer' +import { formatErrorMessage } from '@/src/sdk/core/errors/format' +import { useChainRegistry, useTransaction, useWallet } from '@/src/sdk/react/hooks' +/** + * Cross-chain deposit demo using the adapter architecture. + * + * Level 5 (buildCrossDomainMessageParams) for OP-specific gas estimation, + * Level 2 (useTransaction) for lifecycle + execution. + */ const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => { - // https://sepolia-optimism.etherscan.io/address/0xb50201558b00496a145fe76f7424749556e326d8 const AAVEProxy = '0xb50201558b00496a145fe76f7424749556e326d8' - const { address: walletAddress, readOnlyClient } = useWeb3StatusConnected() + const wallet = useWallet({ chainId: sepolia.id }) + const walletAddress = wallet.status.activeAccount as Address + const registry = useChainRegistry() const contract = getContract('AAVEWeth', optimismSepolia.id) const depositValue = parseEther('0.01') const [l2Hash, setL2Hash] = useState
(null) - const sendCrossChainMessage = useL1CrossDomainMessengerProxy({ - fromChain: sepolia, - contractName: 'AAVEWeth', - functionName: 'depositETH', - l2ContractAddress: contract.address, - args: [AAVEProxy, walletAddress, 0], - value: depositValue, - walletAddress, + const tx = useTransaction({ + lifecycle: { + onConfirm: (result) => { + const receipt = result.receipt as TransactionReceipt + const [log] = extractTransactionDepositedLogs(receipt) + if (log) { + setL2Hash(getL2TransactionHash({ log })) + } + }, + }, }) + const l2ExplorerUrl = l2Hash + ? getExplorerUrl(registry, { chainId: optimismSepolia.id, tx: l2Hash }) + : null + + const handleDeposit = async () => { + setL2Hash(null) + try { + const params = await buildCrossDomainMessageParams({ + fromChain: sepolia, + contractName: 'AAVEWeth', + functionName: 'depositETH', + l2ContractAddress: contract.address, + args: [AAVEProxy, walletAddress, 0], + value: depositValue, + walletAddress, + }) + await tx.execute(params) + } catch { + // useTransaction sets tx.error internally — no additional handling needed + } + } + + const isPending = tx.phase !== 'idle' + return (

@@ -46,20 +81,23 @@ const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => { {' '} from Sepolia.

- { - setL2Hash(null) - const hash = await sendCrossChainMessage() - const receipt = await readOnlyClient.waitForTransactionReceipt({ hash }) - const [log] = extractTransactionDepositedLogs(receipt) - const l2Hash = getL2TransactionHash({ log }) - setL2Hash(l2Hash) - return hash - }} + - Deposit ETH - + {isPending ? ( + + + {tx.phase === 'prepare' ? 'Estimating...' : 'Sending...'} + + ) : ( + 'Deposit ETH' + )} + + {tx.error && {formatErrorMessage(tx.error)}} {l2Hash && ( { > OpSepolia tx @@ -79,9 +117,9 @@ const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => { const optimismCrossdomainMessenger = { demo: ( - + - + ), href: 'https://bootnodedev.github.io/dAppBooster/functions/hooks_useOPL1CrossDomainMessengerProxy.useL1CrossDomainMessengerProxy.html', icon: , diff --git a/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx index a39e295d..a67007ae 100644 --- a/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx @@ -5,18 +5,46 @@ import signMessage from './index' const system = createSystem(defaultConfig) -vi.mock('@/src/wallet/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(() => ({ - isWalletConnected: false, - isWalletSynced: false, - walletChainId: undefined, - appChainId: 11155420, +vi.mock('@/src/sdk/react/hooks', () => ({ + useWallet: vi.fn(() => ({ + adapter: {} as never, + adapterKey: 'evm', + status: { + connected: false, + activeAccount: null, + connectedChainIds: [], + connecting: false, + }, + isReady: false, + needsConnect: true, + needsChainSwitch: false, + connect: vi.fn(), + disconnect: vi.fn(), + signMessage: vi.fn(), + getSigner: vi.fn(), switchChain: vi.fn(), + openConnectModal: vi.fn(), + openAccountModal: vi.fn(), + })), + useChainRegistry: vi.fn(() => ({ + getChain: vi.fn(() => null), + getChainByCaip2: vi.fn(() => null), + getChainType: vi.fn(() => null), + getChainsByType: vi.fn(() => []), + getAllChains: vi.fn(() => []), + })), + useMultiWallet: vi.fn(() => ({ + wallets: {}, + getWallet: vi.fn(() => undefined), + getWalletByChainId: vi.fn(() => undefined), + connectedAddresses: {}, })), })) -vi.mock('@/src/wallet/providers', () => ({ - ConnectWalletButton: () => , +vi.mock('@/src/chakra', () => ({ + WalletGuard: ({ children: _children }: { children: React.ReactNode }) => ( + + ), })) describe('SignMessage demo', () => { diff --git a/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx b/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx index a2c0ff95..95a6b47e 100644 --- a/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx @@ -1,8 +1,8 @@ +import { WalletGuard } from '@/src/chakra' import Icon from '@/src/components/pageComponents/home/Examples/demos/SignMessage/Icon' import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper' import { PrimaryButton } from '@/src/core/components' import { SignButton } from '@/src/transactions/components' -import { WalletStatusVerifier } from '@/src/wallet/components' const message = ` 👻🚀 Welcome to dAppBooster! 🚀👻 @@ -18,7 +18,7 @@ dAppBooster Team 💪 const SignMessage = () => { return ( - +

When pressing the button, your connected wallet will display a prompt asking you to sign @@ -33,7 +33,7 @@ const SignMessage = () => { paddingX={6} /> - + ) } diff --git a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx index 9f930c9f..10d44eea 100644 --- a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx @@ -5,9 +5,9 @@ import switchNetwork from './index' const system = createSystem(defaultConfig) -vi.mock('@/src/wallet/hooks', () => ({ - useWeb3Status: vi.fn(() => ({ - isWalletConnected: false, +vi.mock('@/src/sdk/react/hooks', () => ({ + useWallet: vi.fn(() => ({ + status: { connected: false, activeAccount: null, connectedChainIds: [], connecting: false }, })), })) diff --git a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx index 243f1685..fe02f8cc 100644 --- a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx @@ -6,13 +6,15 @@ import { } from '@web3icons/react' import { arbitrum, mainnet, optimism, polygon } from 'viem/chains' import Icon from '@/src/components/pageComponents/home/Examples/demos/SwitchNetwork/Icon' +import { useWallet } from '@/src/sdk/react/hooks' import { SwitchNetwork as BaseSwitchNetwork } from '@/src/wallet/components' -import { useWeb3Status } from '@/src/wallet/hooks' +import type { Networks } from '@/src/wallet/components/SwitchNetwork' import { ConnectWalletButton } from '@/src/wallet/providers' -import type { Networks } from '@/src/wallet/types' const SwitchNetwork = () => { - const { isWalletConnected } = useWeb3Status() + const { + status: { connected: isWalletConnected }, + } = useWallet() const networks: Networks = [ { icon: ( diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx index eb6848cf..beac18bc 100644 --- a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx @@ -1,11 +1,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils' +import { renderWithProviders } from '@/src/test-utils' import tokenInput from './index' -vi.mock('@/src/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(() => createMockWeb3Status()), +vi.mock('@/src/sdk/react/hooks', () => ({ + useWallet: vi.fn(() => ({ + status: { connected: false, activeAccount: null, connectedChainIds: [], connecting: false }, + })), })) vi.mock('@/src/hooks/useTokenLists', () => ({ diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx index 32f27b44..e9d6377a 100644 --- a/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx @@ -10,10 +10,10 @@ import { arbitrum, mainnet, optimism, polygon } from 'viem/chains' import OptionsDropdown from '@/src/components/pageComponents/home/Examples/demos/OptionsDropdown' import Icon from '@/src/components/pageComponents/home/Examples/demos/TokenInput/Icon' import { withSuspenseAndRetry } from '@/src/core/utils' +import { useWallet } from '@/src/sdk/react/hooks' import { TokenInput as BaseTokenInput } from '@/src/tokens/components' import { useTokenInput, useTokenLists, useTokenSearch } from '@/src/tokens/hooks' import type { Networks } from '@/src/tokens/types' -import { useWeb3Status } from '@/src/wallet/hooks' type Options = 'single' | 'multi' @@ -48,7 +48,9 @@ const SkeletonLoadingTokenInput = () => ( */ const TokenInputMode = withSuspenseAndRetry( ({ currentTokenInput }: { currentTokenInput: Options }) => { - const { isWalletConnected } = useWeb3Status() + const { + status: { connected: isWalletConnected }, + } = useWallet() const [currentNetworkId, setCurrentNetworkId] = useState() const { tokensByChainId } = useTokenLists() const { searchResult } = useTokenSearch({ diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx index b4503c1b..e70a4c46 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx @@ -1,42 +1,30 @@ import type { FC } from 'react' -import { type Address, erc20Abi, type Hash, type TransactionReceipt } from 'viem' -import * as chains from 'viem/chains' -import { useWriteContract } from 'wagmi' -import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper' +import { type Abi, type Address, erc20Abi } from 'viem' +import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper' import { useSuspenseReadErc20Allowance } from '@/src/contracts/generated' -import { getExplorerLink } from '@/src/core/utils' +import type { TransactionParams } from '@/src/sdk/core' +import { getExplorerUrl } from '@/src/sdk/core/chain/explorer' +import type { EvmContractCall } from '@/src/sdk/core/evm/types' +import { useChainRegistry, useWallet } from '@/src/sdk/react/hooks' import type { Token } from '@/src/tokens/types' -import { LegacyTransactionButton as TransactionButton } from '@/src/transactions/components' -import { useWeb3StatusConnected } from '@/src/wallet/components' -import { useWeb3Status } from '@/src/wallet/hooks' +import { TransactionButton } from '@/src/transactions/components' interface Props { amount: bigint disabled?: boolean label?: string labelSending?: string - onSuccess?: (receipt: TransactionReceipt) => void + onSuccess?: () => void spender: Address token: Token - transaction: () => Promise + transferParams: TransactionParams } /** * Dynamically renders either an approval button or a transaction button based on the user's current token allowance. * After the approval, the transaction button will be rendered. * - * @dev Use with to add an skeleton loader while fetching the allowance. - * - * @param {Props} - * @param {Token} props.token - The token to be approved. - * @param {Address} props.spender - The address of the spender to be approved. - * @param {bigint} props.amount - The amount of tokens to approve (or send). - * @param {Function} props.onMined - The callback function to be called when transaction is mined. - * @param {boolean} props.disabled - The flag to disable the button. - * @param {Function} props.transaction - The transaction function that send after approval. - * @param {string} props.label - The label for the button. - * @param {string} props.labelSending - The label for the button when the transaction is pending. - * + * @dev Use with to add a skeleton loader while fetching the allowance. */ const ERC20ApproveAndTransferButton: FC = ({ amount, @@ -46,34 +34,36 @@ const ERC20ApproveAndTransferButton: FC = ({ onSuccess, spender, token, - transaction, + transferParams, }) => { - const { address } = useWeb3StatusConnected() - const { writeContractAsync } = useWriteContract() - const { isWalletConnected, walletChainId } = useWeb3Status() + const wallet = useWallet({ chainId: token.chainId }) + const address = wallet.status.activeAccount as Address | null + const registry = useChainRegistry() - const { data: allowance, refetch: getAllowance } = useSuspenseReadErc20Allowance({ - address: token.address as Address, // TODO: token.address should be Address type - args: [address, spender], + const { data: allowance = BigInt(0), refetch: refetchAllowance } = useSuspenseReadErc20Allowance({ + address: token.address as Address, + args: [address ?? ('0x0000000000000000000000000000000000000000' as Address), spender], }) + if (!address) { + return null + } + const isApprovalRequired = allowance < amount - const handleApprove = () => { - return writeContractAsync({ - abi: erc20Abi, - address: token.address as Address, - functionName: 'approve', - args: [spender, amount], - }) + const approveParams: TransactionParams = { + chainId: token.chainId, + payload: { + contract: { + address: token.address as Address, + abi: erc20Abi as Abi, + functionName: 'approve', + args: [spender, amount], + }, + } satisfies EvmContractCall, } - handleApprove.methodId = 'Approve USDC' - - const findChain = (chainId: number) => Object.values(chains).find((chain) => chain.id === chainId) - // mainnet is the default chain if not connected or the chain is not found - const currentChain = - isWalletConnected && walletChainId ? findChain(walletChainId) || chains.mainnet : chains.mainnet + const explorerUrl = getExplorerUrl(registry, { chainId: token.chainId, address: spender }) return isApprovalRequired ? ( = ({ disabled={disabled} key="approve" labelSending={`Approving ${token.symbol}`} - onMined={() => getAllowance()} - transaction={handleApprove} + lifecycle={{ onConfirm: () => refetchAllowance() }} + params={approveParams} > Approve @@ -96,7 +86,7 @@ const ERC20ApproveAndTransferButton: FC = ({ <> Supply {token.symbol} to the{' '} @@ -108,12 +98,11 @@ const ERC20ApproveAndTransferButton: FC = ({ title="Execute the transaction" > onSuccess?.() }} + params={transferParams} > {label} diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx index 368e6c6f..3ad06132 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx @@ -1,33 +1,36 @@ -import { sepolia } from 'viem/chains' -import { useWriteContract } from 'wagmi' +import type { Abi, Address } from 'viem' +import { baseSepolia } from 'viem/chains' import { AaveFaucetABI } from '@/src/contracts/abis/AaveFaucet' import { getContract } from '@/src/contracts/definitions' -import { LegacyTransactionButton as TransactionButton } from '@/src/transactions/components' -import { useWeb3StatusConnected } from '@/src/wallet/components' +import type { TransactionParams } from '@/src/sdk/core' +import type { EvmContractCall } from '@/src/sdk/core/evm/types' +import { useWallet } from '@/src/sdk/react/hooks' +import { TransactionButton } from '@/src/transactions/components' export default function MintUSDC({ onSuccess }: { onSuccess: () => void }) { - const { address } = useWeb3StatusConnected() - const { writeContractAsync } = useWriteContract() - const aaveContract = getContract('AaveFaucet', sepolia.id) - const aaveUSDC = '0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8' + const wallet = useWallet({ chainId: baseSepolia.id }) + const address = wallet.status.activeAccount as Address + const aaveContract = getContract('AaveFaucet', baseSepolia.id) + const aaveUSDC = '0xba50cd2a20f6da35d788639e581bca8d0b5d4d5f' - const handleMint = () => { - return writeContractAsync({ - abi: AaveFaucetABI, - address: aaveContract.address, - functionName: 'mint', - args: [aaveUSDC, address, 10000000000n], - }) + const mintParams: TransactionParams = { + chainId: baseSepolia.id, + payload: { + contract: { + address: aaveContract.address, + abi: AaveFaucetABI as Abi, + functionName: 'mint', + args: [aaveUSDC, address, BigInt(10000000000)], + }, + } satisfies EvmContractCall, } - handleMint.methodId = 'Mint USDC' return ( onSuccess() }} + params={mintParams} > Mint USDC diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx index 786d0415..e0426ead 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx @@ -1,18 +1,19 @@ -import { type Address, formatUnits } from 'viem' -import { sepolia } from 'viem/chains' -import { useWriteContract } from 'wagmi' +import { type Abi, type Address, formatUnits } from 'viem' +import { baseSepolia } from 'viem/chains' import BaseERC20ApproveAndTransferButton from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton' import MintUSDC from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC' -import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper' +import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper' import { useSuspenseReadErc20BalanceOf } from '@/src/contracts/generated' import { formatNumberOrString, NumberType, withSuspense } from '@/src/core/utils' +import type { TransactionParams } from '@/src/sdk/core' +import type { EvmContractCall } from '@/src/sdk/core/evm/types' +import { useWallet } from '@/src/sdk/react/hooks' import type { Token } from '@/src/tokens/types' -import { useWeb3StatusConnected } from '@/src/wallet/components' -// USDC token on Sepolia chain -const tokenUSDC_sepolia: Token = { - address: '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8', - chainId: sepolia.id, +// USDC token on Base Sepolia (Aave V3 testnet) +const tokenUSDC: Token = { + address: '0xba50cd2a20f6da35d788639e581bca8d0b5d4d5f', + chainId: baseSepolia.id, decimals: 6, name: 'USD Coin', symbol: 'USDC', @@ -53,39 +54,46 @@ const ABIExample = [ /** * This demo shows how to approve and send an ERC20 token transaction using the `TransactionButton` component. * - * Works only on Sepolia chain. + * Works only on Base Sepolia chain. */ const ERC20ApproveAndTransferButton = withSuspense(() => { - const { address } = useWeb3StatusConnected() - const { writeContractAsync } = useWriteContract() + const wallet = useWallet({ chainId: baseSepolia.id }) + const address = wallet.status.activeAccount as Address | null - const { data: balance, refetch: refetchBalance } = useSuspenseReadErc20BalanceOf({ - address: tokenUSDC_sepolia.address as Address, - args: [address], + const { data: balance = BigInt(0), refetch: refetchBalance } = useSuspenseReadErc20BalanceOf({ + address: tokenUSDC.address as Address, + args: [address ?? ('0x0000000000000000000000000000000000000000' as Address)], }) - // AAVE staging contract pool address - const spender = '0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951' + if (!address) { + return null + } - const amount = 10000000000n // 10,000.00 USDC + // AAVE V3 Pool on Base Sepolia + const spender = '0x8bAB6d1b75f19e9eD9fCe8b9BD338844fF79aE27' - const handleTransaction = () => - writeContractAsync({ - abi: ABIExample, - address: spender, - functionName: 'supply', - args: [tokenUSDC_sepolia.address as Address, amount, address, 0], - }) - handleTransaction.methodId = 'Supply USDC' + const amount = BigInt(10000000000) // 10,000.00 USDC + + const transferParams: TransactionParams = { + chainId: baseSepolia.id, + payload: { + contract: { + address: spender, + abi: ABIExample as Abi, + functionName: 'supply', + args: [tokenUSDC.address as Address, amount, address, 0], + }, + } satisfies EvmContractCall, + } const formattedAmount = formatNumberOrString( - formatUnits(amount, tokenUSDC_sepolia.decimals), + formatUnits(amount, tokenUSDC.decimals), NumberType.TokenTx, ) return balance < amount ? ( @@ -97,8 +105,8 @@ const ERC20ApproveAndTransferButton = withSuspense(() => { labelSending="Sending..." onSuccess={() => refetchBalance()} spender={spender} - token={tokenUSDC_sepolia} - transaction={handleTransaction} + token={tokenUSDC} + transferParams={transferParams} /> ) }) diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx index afabae8a..4b34124f 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx @@ -1,24 +1,28 @@ import { Dialog } from '@chakra-ui/react' import { type ReactElement, useState } from 'react' -import { type Hash, parseEther, type TransactionReceipt } from 'viem' -import { useSendTransaction } from 'wagmi' -import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper' +import type { Address, TransactionReceipt } from 'viem' +import { parseEther } from 'viem' +import { baseSepolia } from 'viem/chains' +import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper' import { GeneralMessage, PrimaryButton } from '@/src/core/components' -import { LegacyTransactionButton as TransactionButton } from '@/src/transactions/components' -import { useWeb3StatusConnected } from '@/src/wallet/components' +import type { TransactionParams, TransactionResult } from '@/src/sdk/core' +import type { EvmRawTransaction } from '@/src/sdk/core/evm/types' +import { useWallet } from '@/src/sdk/react/hooks' +import { TransactionButton } from '@/src/transactions/components' /** * This demo shows how to send a native token transaction. * - * Works only on Sepolia chain. + * Works only on Base Sepolia chain. */ const NativeToken = () => { const [isModalOpen, setIsModalOpen] = useState(false) - const { address } = useWeb3StatusConnected() - const { sendTransactionAsync } = useSendTransaction() + const wallet = useWallet({ chainId: baseSepolia.id }) + const address = wallet.status.activeAccount as Address const [minedMessage, setMinedMessage] = useState() - const handleOnMined = (receipt: TransactionReceipt) => { + const handleConfirm = (result: TransactionResult) => { + const receipt = result.receipt as TransactionReceipt setMinedMessage( <> Hash: {receipt.transactionHash} @@ -27,14 +31,13 @@ const NativeToken = () => { setIsModalOpen(true) } - const handleSendTransaction = (): Promise => { - // Send native token - return sendTransactionAsync({ + const sendParams: TransactionParams = { + chainId: baseSepolia.id, + payload: { to: address, value: parseEther('0.1'), - }) + } satisfies EvmRawTransaction, } - handleSendTransaction.methodId = 'sendTransaction' return ( { > Send 0.1 Sepolia ETH diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx index 346fcf8b..c98aeec1 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx @@ -11,6 +11,12 @@ vi.mock('@/src/wallet/providers', () => ({ ConnectWalletButton: () => , })) +vi.mock('@/src/chakra', () => ({ + WalletGuard: ({ children: _children }: { children: React.ReactNode }) => ( + + ), +})) + vi.mock('@/src/sdk/react/hooks', () => ({ useWallet: vi.fn(() => ({ needsConnect: true, diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx index d5a1eb23..a9305d2c 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx @@ -1,11 +1,11 @@ import { Flex } from '@chakra-ui/react' import { useState } from 'react' -import { sepolia } from 'wagmi/chains' +import { baseSepolia } from 'viem/chains' +import { WalletGuard } from '@/src/chakra' import { OptionsDropdown } from '@/src/components/pageComponents/home/Examples/demos/OptionsDropdown' import ERC20ApproveAndTransferButton from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton' import Icon from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Icon' import NativeToken from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken' -import { WalletStatusVerifier } from '@/src/wallet/components' type Options = 'erc20' | 'native' @@ -17,23 +17,20 @@ const TransactionButton = () => { ] return ( - - {/* biome-ignore lint/complexity/noUselessFragments: WalletStatusVerifier expects a single ReactElement child */} - <> - - - {currentTokenInput === 'erc20' && } - {currentTokenInput === 'native' && } - - - + + + + {currentTokenInput === 'erc20' && } + {currentTokenInput === 'native' && } + + ) } diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper.tsx b/src/components/pageComponents/home/Examples/wrapper.tsx similarity index 100% rename from src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper.tsx rename to src/components/pageComponents/home/Examples/wrapper.tsx diff --git a/src/components/sharedComponents/ExplorerLink.tsx b/src/components/sharedComponents/ExplorerLink.tsx index f342e87d..384e2254 100644 --- a/src/components/sharedComponents/ExplorerLink.tsx +++ b/src/components/sharedComponents/ExplorerLink.tsx @@ -34,7 +34,7 @@ export const ExplorerLink: FC = ({ }: ExplorerLinkProps) => { return ( diff --git a/src/components/sharedComponents/NotificationToast.tsx b/src/components/sharedComponents/NotificationToast.tsx deleted file mode 100644 index 1d5595aa..00000000 --- a/src/components/sharedComponents/NotificationToast.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client' - -import { Toaster as ChakraToaster, createToaster, Portal, Stack, Toast } from '@chakra-ui/react' -import Spinner from '@/src/components/sharedComponents/ui/Spinner' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' - -export const notificationToaster = createToaster({ - placement: 'bottom-end', - pauseOnPageIdle: true, - max: 1, - overlap: false, -}) - -export const NotificationToast = () => { - const { readOnlyClient } = useWeb3Status() - const chain = readOnlyClient?.chain - return !chain ? null : ( - - - {(toast) => ( - - {toast.type === 'loading' ? : } - - {toast.title && {toast.title}} - {toast.description && ( - - {toast.description} - - )} - - {toast.meta?.closable && } - - )} - - - ) -} diff --git a/src/components/sharedComponents/SignButton.test.tsx b/src/components/sharedComponents/SignButton.test.tsx deleted file mode 100644 index 30a8f99b..00000000 --- a/src/components/sharedComponents/SignButton.test.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { createElement, type ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import SignButton from './SignButton' - -const mockSwitchChain = vi.fn() -const mockSignMessage = vi.fn() - -vi.mock('@/src/sdk/react/hooks', () => ({ - useWallet: vi.fn(() => ({ - adapter: {} as never, - needsConnect: true, - needsChainSwitch: false, - isReady: false, - status: { connected: false, connecting: false, activeAccount: null, connectedChainIds: [] }, - switchChain: mockSwitchChain, - connect: vi.fn(), - disconnect: vi.fn(), - signMessage: mockSignMessage, - getSigner: vi.fn(), - adapterKey: 'evm', - openConnectModal: vi.fn(), - openAccountModal: vi.fn(), - })), - useChainRegistry: vi.fn(() => ({ - getChain: vi.fn(() => null), - getChainByCaip2: vi.fn(() => null), - getChainType: vi.fn(() => null), - getChainsByType: vi.fn(() => []), - getAllChains: vi.fn(() => []), - })), -})) - -vi.mock('@/src/wallet/providers', () => ({ - ConnectWalletButton: () => - createElement( - 'button', - { type: 'button', 'data-testid': 'connect-wallet-button' }, - 'Connect Wallet', - ), -})) - -vi.mock('@/src/components/sharedComponents/ui/SwitchChainButton', () => ({ - default: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => - createElement( - 'button', - { type: 'button', 'data-testid': 'switch-chain-button', onClick }, - children, - ), -})) - -const { useWallet, useChainRegistry } = await import('@/src/sdk/react/hooks') -const mockedUseWallet = vi.mocked(useWallet) -const mockedUseChainRegistry = vi.mocked(useChainRegistry) - -const system = createSystem(defaultConfig) -const renderWithChakra = (ui: ReactNode) => - render({ui}) - -const makeWalletReady = () => - mockedUseWallet.mockReturnValue({ - adapter: {} as never, - needsConnect: false, - needsChainSwitch: false, - isReady: true, - status: { connected: true, connecting: false, activeAccount: '0xabc', connectedChainIds: [1] }, - switchChain: mockSwitchChain, - connect: vi.fn(), - disconnect: vi.fn(), - signMessage: mockSignMessage, - getSigner: vi.fn(), - adapterKey: 'evm', - openConnectModal: vi.fn(), - openAccountModal: vi.fn(), - }) - -describe('SignButton', () => { - beforeEach(() => { - vi.clearAllMocks() - mockSignMessage.mockResolvedValue({ signature: '0xsig', address: '0xabc' }) - }) - - it('renders connect button when wallet needsConnect', () => { - renderWithChakra() - expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument() - expect(screen.queryByText('Sign Message')).toBeNull() - }) - - it('renders custom fallback when provided and wallet needsConnect', () => { - renderWithChakra( - , - ) - expect(screen.getByTestId('custom-fallback')).toBeInTheDocument() - expect(screen.queryByText('Sign Message')).toBeNull() - }) - - it('renders switch chain button when wallet needsChainSwitch', () => { - mockedUseWallet.mockReturnValue({ - adapter: {} as never, - needsConnect: false, - needsChainSwitch: true, - isReady: false, - status: { connected: true, connecting: false, activeAccount: '0xabc', connectedChainIds: [] }, - switchChain: mockSwitchChain, - connect: vi.fn(), - disconnect: vi.fn(), - signMessage: mockSignMessage, - getSigner: vi.fn(), - adapterKey: 'evm', - openConnectModal: vi.fn(), - openAccountModal: vi.fn(), - }) - mockedUseChainRegistry.mockReturnValue({ - getChain: vi.fn(() => ({ - name: 'OP Mainnet', - chainId: 10, - chainType: 'evm', - caip2Id: 'eip155:10', - nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, - addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' }, - })), - getChainByCaip2: vi.fn(() => null), - getChainType: vi.fn(() => null), - getChainsByType: vi.fn(() => []), - getAllChains: vi.fn(() => []), - }) - renderWithChakra( - , - ) - expect(screen.getByTestId('switch-chain-button')).toBeInTheDocument() - expect(screen.getByText(/Switch to/)).toBeInTheDocument() - expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument() - expect(screen.queryByText('Sign Message')).toBeNull() - }) - - it('renders custom switchChainLabel when wallet needsChainSwitch', () => { - mockedUseWallet.mockReturnValue({ - adapter: {} as never, - needsConnect: false, - needsChainSwitch: true, - isReady: false, - status: { connected: true, connecting: false, activeAccount: '0xabc', connectedChainIds: [] }, - switchChain: mockSwitchChain, - connect: vi.fn(), - disconnect: vi.fn(), - signMessage: mockSignMessage, - getSigner: vi.fn(), - adapterKey: 'evm', - openConnectModal: vi.fn(), - openAccountModal: vi.fn(), - }) - mockedUseChainRegistry.mockReturnValue({ - getChain: vi.fn(() => ({ - name: 'OP Mainnet', - chainId: 10, - chainType: 'evm', - caip2Id: 'eip155:10', - nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, - addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' }, - })), - getChainByCaip2: vi.fn(() => null), - getChainType: vi.fn(() => null), - getChainsByType: vi.fn(() => []), - getAllChains: vi.fn(() => []), - }) - renderWithChakra( - , - ) - expect(screen.getByText(/Change to/)).toBeInTheDocument() - }) - - it('renders sign button when wallet is ready', () => { - makeWalletReady() - renderWithChakra() - expect(screen.getByText('Sign Message')).toBeInTheDocument() - }) - - it('calls onSign with signature when signing succeeds', async () => { - makeWalletReady() - const onSign = vi.fn() - renderWithChakra( - , - ) - await userEvent.click(screen.getByText('Sign Message')) - expect(onSign).toHaveBeenCalledWith('0xsig') - }) - - it('calls onError when signing fails', async () => { - makeWalletReady() - mockSignMessage.mockRejectedValue(new Error('sign failed')) - const onError = vi.fn() - renderWithChakra( - , - ) - await userEvent.click(screen.getByText('Sign Message')) - expect(onError).toHaveBeenCalledWith(expect.any(Error)) - }) -}) diff --git a/src/components/sharedComponents/SignButton.tsx b/src/components/sharedComponents/SignButton.tsx deleted file mode 100644 index e209d8d9..00000000 --- a/src/components/sharedComponents/SignButton.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { type ButtonProps, chakra } from '@chakra-ui/react' -import type { FC, ReactElement } from 'react' -import { useState } from 'react' -import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton' -import { useChainRegistry, useWallet } from '@/src/sdk/react/hooks' -import { ConnectWalletButton } from '@/src/wallet/providers' - -interface SignButtonProps extends Omit { - /** Target chain ID for wallet status verification. */ - chainId?: string | number - /** Custom fallback when wallet needs connection. Defaults to ConnectWalletButton. */ - fallback?: ReactElement - /** Button label while signing. Defaults to 'Signing...'. */ - labelSigning?: string - /** The message to sign. */ - message: string - /** Callback function called when an error occurs. */ - onError?: (error: Error) => void - /** Callback function called when the message is signed. */ - onSign?: (signature: string) => void - /** Label for the switch chain button. Defaults to 'Switch to'. */ - switchChainLabel?: string -} - -/** - * Self-contained message signing button with wallet verification. - * - * Handles wallet connection status internally — shows a connect button if not connected, - * a switch chain button if on the wrong chain, or the sign button when ready. - * - * @example - * ```tsx - * console.error(error)} - * onSign={(signature) => console.log(signature)} - * /> - * ``` - */ -const SignButton: FC = ({ - chainId, - children = 'Sign Message', - disabled, - fallback = , - labelSigning = 'Signing...', - message, - onError, - onSign, - switchChainLabel = 'Switch to', - ...restProps -}) => { - const wallet = useWallet({ chainId }) - const registry = useChainRegistry() - const [isPending, setIsPending] = useState(false) - - if (wallet.needsConnect) { - return fallback - } - - if (wallet.needsChainSwitch && chainId !== undefined) { - const targetChain = registry.getChain(chainId) - return ( - wallet.switchChain(chainId)}> - {switchChainLabel} {targetChain?.name ?? String(chainId)} - - ) - } - - const handleSign = async () => { - setIsPending(true) - try { - const result = await wallet.signMessage({ message }) - onSign?.(result.signature) - } catch (error) { - const errorObj = error instanceof Error ? error : new Error(String(error)) - onError?.(errorObj) - } finally { - setIsPending(false) - } - } - - return ( - - {isPending ? labelSigning : children} - - ) -} - -export default SignButton diff --git a/src/components/sharedComponents/SwitchNetwork.test.tsx b/src/components/sharedComponents/SwitchNetwork.test.tsx deleted file mode 100644 index 9cfad6a4..00000000 --- a/src/components/sharedComponents/SwitchNetwork.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import SwitchNetwork, { type Networks } from './SwitchNetwork' - -const system = createSystem(defaultConfig) - -vi.mock('@/src/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(), -})) - -vi.mock('wagmi', () => ({ - useSwitchChain: vi.fn(), -})) - -import * as wagmiModule from 'wagmi' -import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status' - -const mockNetworks: Networks = [ - { id: 1, label: 'Ethereum', icon: ETH }, - { id: 137, label: 'Polygon', icon: MATIC }, -] - -function defaultWeb3Status(overrides = {}) { - return { - isWalletConnected: true, - walletChainId: undefined as number | undefined, - walletClient: undefined, - ...overrides, - } -} - -function defaultSwitchChain() { - return { - chains: [ - { id: 1, name: 'Ethereum' }, - { id: 137, name: 'Polygon' }, - ], - switchChain: vi.fn(), - } -} - -function renderSwitchNetwork(networks = mockNetworks) { - return render( - - - , - ) -} - -describe('SwitchNetwork', () => { - it('shows "Select a network" when wallet chain does not match any network', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - defaultWeb3Status({ walletChainId: 999 }) as any, - ) - vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - defaultSwitchChain() as any, - ) - renderSwitchNetwork() - expect(screen.getByText('Select a network')).toBeDefined() - }) - - it('shows current network label when wallet is on a listed chain', async () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - defaultWeb3Status({ walletChainId: 1 }) as any, - ) - vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - defaultSwitchChain() as any, - ) - renderSwitchNetwork() - expect(screen.getByText('Ethereum')).toBeDefined() - }) - - it('trigger button is disabled when wallet not connected', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - defaultWeb3Status({ isWalletConnected: false }) as any, - ) - vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - defaultSwitchChain() as any, - ) - renderSwitchNetwork() - const button = screen.getByRole('button') - expect(button).toBeDefined() - expect(button.hasAttribute('disabled') || button.getAttribute('data-disabled') !== null).toBe( - true, - ) - }) - - it('shows all network options in the menu after opening it', async () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - defaultWeb3Status({ isWalletConnected: true }) as any, - ) - vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - defaultSwitchChain() as any, - ) - renderSwitchNetwork() - - // Open the menu by clicking the trigger - const trigger = screen.getByRole('button') - fireEvent.click(trigger) - - await waitFor(() => { - expect(screen.getByText('Ethereum')).toBeDefined() - expect(screen.getByText('Polygon')).toBeDefined() - }) - }) -}) diff --git a/src/components/sharedComponents/SwitchNetwork.tsx b/src/components/sharedComponents/SwitchNetwork.tsx deleted file mode 100644 index 7e1ac77e..00000000 --- a/src/components/sharedComponents/SwitchNetwork.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { Box, Flex, Menu } from '@chakra-ui/react' -import { - type ComponentPropsWithoutRef, - type FC, - type ReactElement, - useEffect, - useState, -} from 'react' -import * as chains from 'viem/chains' -import { useSwitchChain } from 'wagmi' -import DropdownButton from '@/src/components/sharedComponents/ui/DropdownButton' -import { MenuContent, MenuItem } from '@/src/components/sharedComponents/ui/Menu' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' - -type NetworkItem = { - icon: ReactElement - id: number - label: string -} - -export type Networks = Array - -interface SwitchNetworkProps extends ComponentPropsWithoutRef<'div'> { - networks: Networks -} - -/** - * SwitchNetwork component for selecting and switching blockchain networks. - * - * This component renders a dropdown menu that allows users to select from a list of - * blockchain networks and switch the connected wallet to the selected network. - * - * @param {SwitchNetworkProps} props - SwitchNetwork component props. - * @param {Networks} props.networks - List of networks to display in the dropdown. - * @param {ReactElement} props.networks[].icon - Icon representing the network. - * @param {number} props.networks[].id - Chain ID of the network. - * @param {string} props.networks[].label - Display name of the network. - * @param {ComponentPropsWithoutRef<'div'>} [props.restProps] - Additional props inherited from div element. - * - * @example - * ```tsx - * }, - * { id: 10, label: "Optimism", icon: } - * ]} - * /> - * ``` - */ -const SwitchNetwork: FC = ({ networks }: SwitchNetworkProps) => { - const findChain = (chainId: number) => Object.values(chains).find((chain) => chain.id === chainId) - - const { chains: configuredChains, switchChain } = useSwitchChain() - const { isWalletConnected, walletChainId, walletClient } = useWeb3Status() - const [networkItem, setNetworkItem] = useState() - - const handleClick = (chainId: number) => { - /** - * First, attempt to switch to the chain if it's already configured - */ - if (configuredChains.some((chain) => chain.id === chainId)) { - switchChain({ chainId }) - } else { - /** - * If the chain isn't configured, allow to switch to it based on the chain id - */ - const selectedChain = findChain(chainId) - if (selectedChain) { - walletClient?.addChain({ chain: selectedChain }) - } - } - } - - useEffect(() => { - setNetworkItem(networks.find((networkItem) => networkItem.id === walletChainId)) - }, [walletChainId, networks]) - - return ( - - - - {networkItem ? ( - <> - - - {networkItem?.icon} - - {' '} - {networkItem?.label} - - ) : ( - 'Select a network' - )} - - - - - {networks.map(({ icon, id, label }) => ( - handleClick(id)} - value={label} - > - - {icon} - - {label} - - ))} - - - - ) -} - -export default SwitchNetwork diff --git a/src/components/sharedComponents/TokenDropdown.tsx b/src/components/sharedComponents/TokenDropdown.tsx deleted file mode 100644 index 5e68f989..00000000 --- a/src/components/sharedComponents/TokenDropdown.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Flex, Menu } from '@chakra-ui/react' -import type { ComponentPropsWithoutRef, FC } from 'react' -import { useState } from 'react' -import TokenLogo from '@/src/components/sharedComponents/TokenLogo' -import TokenSelect, { type TokenSelectProps } from '@/src/components/sharedComponents/TokenSelect' -import DropdownButton from '@/src/components/sharedComponents/ui/DropdownButton' -import { MenuContent } from '@/src/components/sharedComponents/ui/Menu' -import type { Token } from '@/src/types/token' - -export interface TokenDropdownProps extends TokenSelectProps { - currentToken?: Token | undefined - iconSize?: number -} - -/** @ignore */ -type Props = ComponentPropsWithoutRef<'span'> & TokenDropdownProps - -/** - * A dropdown component that allows users to select a token. - * - * @param {TokenDropdownProps & ComponentPropsWithoutRef<'span'>} props - TokenDropdown component props. - * @param {Token} [props.currentToken] - The currently selected token. - * @param {number} [props.iconSize=24] - The size of the token icon in the dropdown button. - * @param {(token: Token | undefined) => void} props.onTokenSelect - Callback function called when a token is selected. - * @param {boolean} [props.showAddTokenButton] - Whether to show a button to add a custom token. - * @param {number} [props.currentNetworkId] - The current network id to filter tokens. - * @param {Networks} [props.networks] - List of networks to display in the dropdown. - * @param {string} [props.placeholder] - Placeholder text for the search input. - * @param {number} [props.containerHeight] - Height of the virtualized tokens list. - * @param {number} [props.itemHeight] - Height of each item in the tokens list. - * @param {boolean} [props.showBalance] - Whether to show the token balance in the list. - * @param {boolean} [props.showTopTokens] - Whether to show the top tokens section in the list. - * @param {ComponentPropsWithoutRef<'span'>} props.restProps - Additional props for the span element. - * - * @example - * ```tsx - * setSelectedToken(token)} - * showAddTokenButton={true} - * showBalance={true} - * /> - * ``` - */ -const TokenDropdown: FC = ({ - className, - currentToken, - iconSize = 24, - onTokenSelect, - showAddTokenButton, - style, - ...restProps -}: Props) => { - const [isOpen, setIsOpen] = useState(false) - - /** - * Handle token selection and close the dropdown - * @param {Token} [token=undefined] - The selected token. Default is undefined. - */ - const handleTokenSelect = (token: Token | undefined) => { - onTokenSelect(token) - setIsOpen(false) - } - - return ( - setIsOpen(state.open)} - positioning={{ placement: 'bottom' }} - > - - - {currentToken ? ( - <> - - - - {currentToken.symbol} - - ) : ( - 'Select token' - )} - - - - - - - - - ) -} - -export default TokenDropdown diff --git a/src/components/sharedComponents/TokenInput/Components.tsx b/src/components/sharedComponents/TokenInput/Components.tsx deleted file mode 100644 index 51be2218..00000000 --- a/src/components/sharedComponents/TokenInput/Components.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { - type ButtonProps, - chakra, - Flex, - type FlexProps, - Heading, - type HeadingProps, - Input, - type InputProps, - Span, - type SpanProps, -} from '@chakra-ui/react' -import type { FC } from 'react' - -const BaseChevronDown = ({ ...restProps }) => ( - - Chevron down - - -) - -const CloseIcon = ({ ...restProps }) => ( - - Close - - -) - -export const Wrapper: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const Title: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const TopRow: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const Textfield: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const ChevronDown = chakra(BaseChevronDown, { - base: { - marginLeft: 2, - }, -}) - -const buttonCSS = { - alignItems: 'center', - backgroundColor: 'var(--dropdown-button-background-color)', - borderColor: 'var(--dropdown-button-border-color)', - borderRadius: 4, - color: 'var(--dropdown-button-color)', - columnGap: 2, - cursor: 'pointer', - display: 'flex', - flexShrink: 0, - fontFamily: '{fonts.body}', - fontSize: { base: '12px', lg: '16px' }, - fontWeight: 500, - height: 'auto', - minWidth: '100px', - padding: { base: 2, lg: 4 }, - _hover: { - backgroundColor: 'var(--dropdown-button-background-color-hover)', - borderColor: 'var(--dropdown-button-border-color-hover)', - color: 'var(--dropdown-button-color-hover)', - }, - _active: { - backgroundColor: 'var(--dropdown-button-background-color-active)', - borderColor: 'var(--dropdown-button-border-color-active)', - color: 'var(--dropdown-button-color-active)', - }, -} - -export const DropdownButton: FC = ({ children, ...restProps }) => ( - - {children} - - -) - -export const SingleToken: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const ErrorComponent: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const BottomRow: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const EstimatedUSDValue: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const Balance: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const BalanceValue: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const MaxButton: FC = ({ children, ...restProps }) => ( - - {children} - -) - -export const Icon: FC<{ $iconSize?: number } & FlexProps> = ({ - $iconSize, - children, - ...restProps -}) => ( - - {children} - -) - -export const CloseButton: FC = ({ children, ...restProps }) => ( - - - -) diff --git a/src/components/sharedComponents/TokenInput/index.tsx b/src/components/sharedComponents/TokenInput/index.tsx deleted file mode 100644 index 85d8a409..00000000 --- a/src/components/sharedComponents/TokenInput/index.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { Dialog, type FlexProps, Portal } from '@chakra-ui/react' -import { type FC, useMemo, useState } from 'react' -import { type NumberFormatValues, NumericFormat } from 'react-number-format' -import { formatUnits } from 'viem' -import { - BigNumberInput, - type BigNumberInputProps, - type RenderInputProps, -} from '@/src/components/sharedComponents/BigNumberInput' -import { - Balance, - BalanceValue, - BottomRow, - CloseButton, - DropdownButton, - ErrorComponent, - EstimatedUSDValue, - Icon, - MaxButton, - SingleToken, - Textfield, - Title, - TopRow, - Wrapper, -} from '@/src/components/sharedComponents/TokenInput/Components' -import type { UseTokenInputReturnType } from '@/src/components/sharedComponents/TokenInput/useTokenInput' -import TokenLogo from '@/src/components/sharedComponents/TokenLogo' -import TokenSelect, { type TokenSelectProps } from '@/src/components/sharedComponents/TokenSelect' -import Spinner from '@/src/components/sharedComponents/ui/Spinner' -import type { Token } from '@/src/types/token' -import styles from './styles' - -interface TokenInputProps extends Omit { - singleToken?: boolean - thousandSeparator?: boolean - title?: string - tokenInput: UseTokenInputReturnType -} - -/** @ignore */ -type Props = FlexProps & TokenInputProps - -/** - * TokenInput component allows users to input token amounts and select tokens from a list. - * It displays the token input field, token balance, and a dropdown list of available tokens. - * - * @param {TokenInputProps} props - TokenInput component props. - * @param {boolean} [props.thousandSeparator=true] - Optional flag to enable thousands separator. Default is true. - * @param {string} props.title - The title of the token input. - * @param {number} [props.currentNetworkId=mainnet.id] - The current network id. Default is mainnet's id. - * @param {function} props.onTokenSelect - Callback function to be called when a token is selected. - * @param {Networks} [props.networks] - Optional list of networks to display in the dropdown. The dropdown won't show up if undefined. Default is undefined. - * @param {string} [props.placeholder='Search by name or address'] - Optional placeholder text for the search input. Default is 'Search by name or address'. - * @param {number} [props.containerHeight=320] - Optional height of the virtualized tokens list. Default is 320. - * @param {number} [props.iconSize=32] - Optional size of the token icon in the list. Default is 32. - * @param {number} [props.itemHeight=64] - Optional height of each item in the list. Default is 64. - * @param {boolean} [props.showAddTokenButton=false] - Optional flag to allow adding a token. Default is false. - * @param {boolean} [props.showBalance=false] - Optional flag to show the token balance in the list. Default is false. - * @param {boolean} [props.showTopTokens=false] - Optional flag to show the top tokens in the list. Default is false. - */ -const TokenInput: FC = ({ - containerHeight, - currentNetworkId, - css, - iconSize, - itemHeight, - networks, - placeholder, - showAddTokenButton, - showBalance, - showTopTokens, - singleToken, - thousandSeparator = true, - title, - tokenInput, - ...restProps -}: Props) => { - const [isOpen, setIsOpen] = useState(false) - const { - amount, - amountError, - balance, - balanceError, - isLoadingBalance, - selectedToken, - setAmount, - setAmountError, - setTokenSelected, - } = tokenInput - - const max = useMemo( - () => (balance && selectedToken ? balance : BigInt(0)), - [balance, selectedToken], - ) - const selectIconSize = 24 - const decimals = selectedToken ? selectedToken.decimals : 2 - - const handleSelectedToken = (token: Token | undefined) => { - setAmount(BigInt(0)) - setTokenSelected(token) - setIsOpen(false) - } - - const handleSetMax = () => { - setAmount(max) - } - - const handleError: BigNumberInputProps['onError'] = (error) => { - setAmountError(error?.message) - } - - const CurrentToken = () => - selectedToken ? ( - <> - - - - {selectedToken.symbol} - - ) : ( - 'Select' - ) - - return singleToken && !selectedToken ? ( -

- ) : ( - setIsOpen(state.open)} - > - - {title && {title}} - - ( - - )} - value={amount} - /> - {singleToken ? ( - - - - ) : ( - - - - - - )} - - - ~$0.00 - - - {balanceError && 'Error...'} - {isLoadingBalance ? ( - - ) : ( - `Balance: ${formatUnits(balance ?? 0n, selectedToken?.decimals ?? 0)}` - )} - - - Max - - - - {amountError && {amountError}} - - - - - - - setIsOpen(false)} - /> - - - - - - ) -} - -function TokenAmountField({ - amountError, - decimals, - renderInputProps, - thousandSeparator, -}: { - amountError?: string | null - decimals: number - renderInputProps: RenderInputProps - thousandSeparator: boolean -}) { - const { onChange, inputRef, ...restProps } = renderInputProps - - const isAllowed = ({ value }: NumberFormatValues) => { - const [inputDecimals] = value.toString().split('.') - - if (!inputDecimals) { - return true - } - - return decimals >= inputDecimals?.length - } - - return ( - onChange?.(value)} - thousandSeparator={thousandSeparator} - // biome-ignore lint/suspicious/noExplicitAny: NumericFormat has defaultValue prop overwritten and is not compatible with the standard - {...(restProps as any)} - /> - ) -} - -export default TokenInput diff --git a/src/components/sharedComponents/TokenInput/styles.ts b/src/components/sharedComponents/TokenInput/styles.ts deleted file mode 100644 index c4ca608e..00000000 --- a/src/components/sharedComponents/TokenInput/styles.ts +++ /dev/null @@ -1,70 +0,0 @@ -export const styles = { - 'html.light &': { - '--title-color': '#2e3048', - '--background': '#fff', - '--textfield-background-color': '#fff', - '--textfield-background-color-active': 'rgb(0 0 0 / 5%)', - '--textfield-border-color': '#e2e0e7', - '--textfield-border-color-active': '#e2e0e7', - '--textfield-color': '#2e3048', - '--textfield-color-active': '#2e3048', - '--textfield-placeholder-color': 'rgb(22 29 26 / 60%)', - '--dropdown-button-background-color': '#fff', - '--dropdown-button-background-color-hover': 'rgb(0 0 0 / 5%)', - '--dropdown-button-border-color': '#e2e0e7', - '--dropdown-button-border-color-hover': '#e2e0e7', - '--dropdown-button-border-color-active': '#e2e0e7', - '--dropdown-button-color': '#2e3048', - '--dropdown-button-color-hover': '#2e3048', - '--dropdown-button-background-color-disabled': '#fff', - '--dropdown-button-border-color-disabled': '#e2e0e7', - '--dropdown-button-color-disabled': '#2e3048', - '--max-button-background-color': '#fff', - '--max-button-background-color-hover': 'rgb(0 0 0 / 5%)', - '--max-button-border-color': '#e2e0e7', - '--max-button-border-color-hover': '#e2e0e7', - '--max-button-border-color-active': '#e2e0e7', - '--max-button-color': '#8b46a4', - '--max-button-color-hover': '#8b46a4', - '--max-button-background-color-disabled': '#fff', - '--max-button-border-color-disabled': '#e2e0e7', - '--max-button-color-disabled': '#8b46a4', - '--estimated-usd-color': '#4b4d60', - '--balance-color': '#4b4d60', - }, - 'html.dark &': { - '--title-color': '#fff', - '--background': '#373954', - '--textfield-background-color': '#373954', - '--textfield-background-color-active': 'rgb(255 255 255 / 5%)', - '--textfield-border-color': '#5f6178', - '--textfield-border-color-active': '#5f6178', - '--textfield-color': 'rgb(255 255 255 / 80%)', - '--textfield-color-active': 'rgb(255 255 255 / 80%)', - '--textfield-placeholder-color': 'rgb(255 255 255 / 50%)', - '--dropdown-button-background-color': '#373954', - '--dropdown-button-background-color-hover': 'rgb(255 255 255 / 5%)', - '--dropdown-button-border-color': '#5f6178', - '--dropdown-button-border-color-hover': '#5f6178', - '--dropdown-button-border-color-active': '#5f6178', - '--dropdown-button-color': '#fff', - '--dropdown-button-color-hover': '#fff', - '--dropdown-button-background-color-disabled': '#373954', - '--dropdown-button-border-color-disabled': '#5f6178', - '--dropdown-button-color-disabled': '#fff', - '--max-button-background-color': '#373954', - '--max-button-background-color-hover': 'rgb(255 255 255 / 5%)', - '--max-button-border-color': '#c5c2cb', - '--max-button-border-color-hover': '#c5c2cb', - '--max-button-border-color-active': '#c5c2cb', - '--max-button-color': '#c5c2cb', - '--max-button-color-hover': '#fff', - '--max-button-background-color-disabled': '#373954', - '--max-button-border-color-disabled': '#5f6178', - '--max-button-color-disabled': '#fff', - '--estimated-usd-color': '#e2e0e7', - '--balance-color': '#e2e0e7', - }, -} - -export default styles diff --git a/src/components/sharedComponents/TokenInput/useTokenInput.tsx b/src/components/sharedComponents/TokenInput/useTokenInput.tsx deleted file mode 100644 index 7bc881a2..00000000 --- a/src/components/sharedComponents/TokenInput/useTokenInput.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useEffect, useState } from 'react' -import { getAddress } from 'viem' -import { useAccount, usePublicClient } from 'wagmi' -import { useErc20Balance } from '@/src/hooks/useErc20Balance' -import type { Token } from '@/src/types/token' -import { isNativeToken } from '@/src/utils/address' - -export type UseTokenInputReturnType = ReturnType - -/** - * Token Input Hook - * - * Manages state and logic for token input components, handling: - * - Token amount and validation errors - * - Selected token state - * - Balance fetching for ERC20 and native tokens - * - * @param {Token} [token] - Optional initial token to select - * @returns {Object} Hook return object - * @returns {bigint} returns.amount - The current input amount as a bigint - * @returns {function} returns.setAmount - Function to update the amount - * @returns {string|null} returns.amountError - Error message for invalid amount - * @returns {function} returns.setAmountError - Function to update amount errors - * @returns {bigint} returns.balance - Current token balance (ERC20 or native) - * @returns {Error|null} returns.balanceError - Error from balance fetching - * @returns {boolean} returns.isLoadingBalance - Loading state for balance - * @returns {Token|undefined} returns.selectedToken - Currently selected token - * @returns {function} returns.setTokenSelected - Function to update selected token - * - * @example - * ```tsx - * const { - * amount, - * balance, - * selectedToken, - * setAmount, - * setTokenSelected - * } = useTokenInput(defaultToken); - * ``` - */ -export function useTokenInput(token?: Token) { - const [amount, setAmount] = useState(BigInt(0)) - const [amountError, setAmountError] = useState() - const [selectedToken, setTokenSelected] = useState(token) - - useEffect(() => { - setTokenSelected(token) - }, [token]) - - const { address: userWallet } = useAccount() - const { balance, balanceError, isLoadingBalance } = useErc20Balance({ - address: userWallet ? getAddress(userWallet) : undefined, - token: selectedToken, - }) - - const publicClient = usePublicClient({ chainId: token?.chainId }) - - const isNative = selectedToken?.address ? isNativeToken(selectedToken.address) : false - const { - data: nativeBalance, - error: nativeBalanceError, - isLoading: isLoadingNativeBalance, - } = useQuery({ - queryKey: ['nativeBalance', selectedToken?.address, selectedToken?.chainId, userWallet], - queryFn: () => publicClient?.getBalance({ address: getAddress(userWallet ?? '') }), - enabled: isNative, - }) - - return { - amount, - setAmount, - amountError, - setAmountError, - balance: isNative ? nativeBalance : balance, - balanceError: isNative ? nativeBalanceError : balanceError, - isLoadingBalance: isNative ? isLoadingNativeBalance : isLoadingBalance, - selectedToken, - setTokenSelected, - } -} diff --git a/src/components/sharedComponents/TokenSelect/List/AddERC20TokenButton.tsx b/src/components/sharedComponents/TokenSelect/List/AddERC20TokenButton.tsx deleted file mode 100644 index f1501a15..00000000 --- a/src/components/sharedComponents/TokenSelect/List/AddERC20TokenButton.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { chakra } from '@chakra-ui/react' -import type { ComponentPropsWithoutRef, FC, MouseEventHandler } from 'react' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' -import type { Token } from '@/src/types/token' -import { isNativeToken } from '@/src/utils/address' - -interface AddERC20TokenButtonProps extends ComponentPropsWithoutRef<'button'> { - $token: Token -} - -/** - * Renders a button that adds an ERC20 token to the wallet. - * - * @param {AddERC20TokenButtonProps} props - AddERC20TokenButton component props. - * @param {Token} props.$token - The ERC20 token object. - */ -const AddERC20TokenButton: FC = ({ - $token, - children, - onClick, - ...restProps -}) => { - const { isWalletConnected, walletChainId, walletClient } = useWeb3Status() - const { address, chainId, decimals, logoURI, symbol } = $token - const disabled = !isWalletConnected || walletChainId !== chainId - - const handleClick: MouseEventHandler = (e) => { - e.stopPropagation() - - walletClient?.watchAsset({ - options: { - address: address, - decimals: decimals, - image: logoURI, - symbol: symbol, - }, - type: 'ERC20', - }) - - onClick?.(e) - } - - return isNativeToken(address) ? null : ( - - {children} - - ) -} - -export default AddERC20TokenButton diff --git a/src/components/sharedComponents/TokenSelect/List/Row.tsx b/src/components/sharedComponents/TokenSelect/List/Row.tsx deleted file mode 100644 index 5e981137..00000000 --- a/src/components/sharedComponents/TokenSelect/List/Row.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Box, Flex, type FlexProps, Skeleton } from '@chakra-ui/react' -import type { FC } from 'react' -import TokenLogo from '@/src/components/sharedComponents/TokenLogo' -import AddERC20TokenButton from '@/src/components/sharedComponents/TokenSelect/List/AddERC20TokenButton' -import TokenBalance from '@/src/components/sharedComponents/TokenSelect/List/TokenBalance' -import type { Token } from '@/src/types/token' - -const Icon: FC<{ size: number } & FlexProps> = ({ size, children, ...restProps }) => ( - - {children} - -) - -const BalanceLoading: FC = ({ ...restProps }) => ( - - - - -) - -interface TokenSelectRowProps extends Omit { - iconSize: number - isLoadingBalances?: boolean - onClick: (token: Token) => void - showAddTokenButton?: boolean - showBalance?: boolean - token: Token -} - -/** - * A row in the token select list. - * - * @param {object} props - TokenSelect List's Row props. - * @param {Token} prop.token - The token to display. - * @param {number} prop.iconSize - The size of the token icon. - * @param {(token: Token) => void} prop.onClick - Callback function to be called when the row is clicked. - * @param {boolean} prop.showAddTokenButton - Whether to display an add token button. - * @param {boolean} [prop.showBalance=false] - Optional flag to show the token balance. Default is false. - * @param {boolean} [prop.showBalance=false] - Optional flag to inform the balances are being loaded. Default is false. - */ -const Row: FC = ({ - iconSize, - isLoadingBalances, - onClick, - showAddTokenButton, - showBalance, - token, - ...restProps -}) => { - const { name } = token - - return ( - onClick(token)} - {...restProps} - > - - - - - {name} - - {showAddTokenButton && Add token} - {showBalance && ( - - } - token={token} - /> - - )} - - ) -} - -export default Row diff --git a/src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx b/src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx deleted file mode 100644 index 4a0fec9e..00000000 --- a/src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Box, Flex } from '@chakra-ui/react' -import { formatUnits } from 'viem' -import type { Token } from '@/src/types/token' -import { withSuspenseAndRetry } from '@/src/utils/suspenseWrapper' - -interface TokenBalanceProps { - isLoading?: boolean - token: Token -} - -/** - * Renders the token balance in the token list row. - * - * @param {object} props - The component props. - * @param {boolean} props.isLoading - Indicates if the token balance is currently being loaded. - * @param {Token} props.token - The token object containing the amount, decimals, and price in USD. - * - * @throws {Promise} If the token balance is still loading or if the token does not have balance information. - * @returns {JSX.Element} The rendered token balance component. - * - * @example - * ```tsx - * - * ``` - */ -const TokenBalance = withSuspenseAndRetry(({ isLoading, token }) => { - const tokenHasBalanceInfo = !!token.extensions - - if (isLoading || !tokenHasBalanceInfo) { - throw Promise.reject() - } - - const balance = formatUnits((token.extensions?.balance ?? 0n) as bigint, token.decimals) - const value = ( - Number.parseFloat((token.extensions?.priceUSD ?? '0') as string) * Number.parseFloat(balance) - ).toFixed(2) - - return ( - - - {balance} - - - $ {value} - - - ) -}) - -export default TokenBalance diff --git a/src/components/sharedComponents/TokenSelect/List/VirtualizedList.tsx b/src/components/sharedComponents/TokenSelect/List/VirtualizedList.tsx deleted file mode 100644 index 1e1b3ad5..00000000 --- a/src/components/sharedComponents/TokenSelect/List/VirtualizedList.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Box } from '@chakra-ui/react' -import { useVirtualizer } from '@tanstack/react-virtual' -import { type ReactNode, useRef } from 'react' - -type VirtualizedListProps = { - containerHeight: number - itemHeight: number - items: Array - renderItem: (item: Item) => ReactNode -} - -const VirtualizedList = ({ - containerHeight, - itemHeight, - items, - renderItem, - ...restProps -}: VirtualizedListProps) => { - const parentRef = useRef(null) - - const rowVirtualizer = useVirtualizer({ - count: items.length, - estimateSize: () => itemHeight, - getScrollElement: () => parentRef.current, - overscan: 5, - }) - - return ( - - - {rowVirtualizer.getVirtualItems().map(({ index, key, size, start }) => ( - - {renderItem(items[index])} - - ))} - - - ) -} - -export default VirtualizedList diff --git a/src/components/sharedComponents/TokenSelect/List/index.tsx b/src/components/sharedComponents/TokenSelect/List/index.tsx deleted file mode 100644 index 5aea0c56..00000000 --- a/src/components/sharedComponents/TokenSelect/List/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Flex, type FlexProps } from '@chakra-ui/react' -import type { FC } from 'react' -import Row from '@/src/components/sharedComponents/TokenSelect/List/Row' -import VirtualizedList from '@/src/components/sharedComponents/TokenSelect/List/VirtualizedList' -import type { Token, Tokens } from '@/src/types/token' - -interface TokenSelectListProps extends FlexProps { - containerHeight: number - iconSize: number - isLoadingBalances: boolean - itemHeight: number - onTokenSelect: (token: Token | undefined) => void - showAddTokenButton?: boolean - showBalance: boolean - tokenList?: Tokens -} - -/** - * List component for TokenSelect. Displays a list of tokens. - * - * @param {object} props - TokenSelect List props. - * @param {number} props.containerHeight - The height of the virtualized list container. - * @param {number} props.iconSize - The size of the token icon for each item in the list. - * @param {number} props.itemHeight - The height of each item in the list. - * @param {function} props.onTokenSelect - Callback function to be called when a token is selected. - * @param {boolean} props.showAddTokenButton - Whether to display an add token button. - * @param {boolean} props.showBalance - Flag to show the token balance in the list. - * @param {boolean} props.isLoadingBalances - Flag to inform the balances are loading. - * @param {Tokens} props.tokenList - The list of tokens to display. - */ -const List: FC = ({ - className, - containerHeight, - iconSize, - isLoadingBalances, - itemHeight, - onTokenSelect, - showAddTokenButton, - showBalance, - tokenList, - ...restProps -}) => { - return ( - - {tokenList?.length ? ( - - containerHeight={containerHeight} - itemHeight={itemHeight} - items={tokenList} - renderItem={(token) => ( - onTokenSelect(token)} - showAddTokenButton={showAddTokenButton} - showBalance={showBalance} - token={token} - /> - )} - {...restProps} - /> - ) : ( - - No tokens - - )} - - ) -} - -export default List diff --git a/src/components/sharedComponents/TokenSelect/Search/Input.tsx b/src/components/sharedComponents/TokenSelect/Search/Input.tsx deleted file mode 100644 index 021406f2..00000000 --- a/src/components/sharedComponents/TokenSelect/Search/Input.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Input as BaseInput, Flex, type InputProps } from '@chakra-ui/react' -import type { FC } from 'react' - -const SearchIcon = () => ( - - Chevron down - - - -) - -/** - * A search input with a search icon - */ -const Input: FC = ({ className, ...inputProps }) => { - return ( - - - - - ) -} - -export default Input diff --git a/src/components/sharedComponents/TokenSelect/Search/NetworkButton.tsx b/src/components/sharedComponents/TokenSelect/Search/NetworkButton.tsx deleted file mode 100644 index 3df92a36..00000000 --- a/src/components/sharedComponents/TokenSelect/Search/NetworkButton.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { chakra } from '@chakra-ui/react' -import type { ComponentProps, FC } from 'react' - -const ChevronDown = () => ( - - Chevron down - - -) - -/** - * A button to select a network from a dropdown - */ -const NetworkButton: FC> = ({ children, ...restProps }) => ( - - {children} - -) - -export default NetworkButton diff --git a/src/components/sharedComponents/TokenSelect/Search/index.tsx b/src/components/sharedComponents/TokenSelect/Search/index.tsx deleted file mode 100644 index ea841685..00000000 --- a/src/components/sharedComponents/TokenSelect/Search/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Box, Flex, type FlexProps, Menu } from '@chakra-ui/react' -import type { Dispatch, FC, SetStateAction } from 'react' -import SearchInput from '@/src/components/sharedComponents/TokenSelect/Search/Input' -import NetworkButton from '@/src/components/sharedComponents/TokenSelect/Search/NetworkButton' -import type { Networks } from '@/src/components/sharedComponents/TokenSelect/types' -import { MenuContent, MenuItem } from '@/src/components/sharedComponents/ui/Menu' - -interface SearchProps extends FlexProps { - currentNetworkId: number - disabled?: boolean - networks?: Networks - placeholder?: string - searchTerm: string - setSearchTerm: Dispatch> -} - -/** - * Search component for TokenSelect. Includes a search input and a networks dropdown. - * - * @param {SearchProps} props - Search component props. - * @param {number} props.currentNetworkId - The current network id. - * @param {boolean} [props.disabled] - Optional flag to disable the search input. - * @param {Networks} [props.networks] - Optional list of networks to display in the dropdown. - * @param {string} [props.placeholder] - Optional placeholder text for the search input. - * @param {string} props.searchTerm - The current search term. - * @param {Function} props.setSearchTerm - Callback function to set the search term. - */ -const Search: FC = ({ - currentNetworkId, - disabled, - networks, - placeholder, - searchTerm, - setSearchTerm, - ...restProps -}: SearchProps) => { - return ( - - setSearchTerm(e.target.value)} - placeholder={placeholder} - value={searchTerm} - /> - {networks && networks.length > 1 && ( - - - - - {networks.find((item) => item.id === currentNetworkId)?.icon} - - - - - - {networks.map(({ icon, id, label, onClick }) => ( - - - {icon} - - {label} - - ))} - - - - )} - - ) -} - -export default Search diff --git a/src/components/sharedComponents/TokenSelect/TopTokens/Item.tsx b/src/components/sharedComponents/TokenSelect/TopTokens/Item.tsx deleted file mode 100644 index cba34232..00000000 --- a/src/components/sharedComponents/TokenSelect/TopTokens/Item.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Box, chakra, Flex } from '@chakra-ui/react' -import type { ComponentPropsWithoutRef, FC } from 'react' -import TokenLogo from '@/src/components/sharedComponents/TokenLogo' -import type { Token } from '@/src/types/token' - -const ICON_SIZE = 24 - -interface ItemProps extends ComponentPropsWithoutRef<'button'> { - token: Token -} - -/** - * A single token item in the top tokens list - * - * @param {Token} token - The token to display - */ -const Item: FC = ({ token, ...restProps }) => { - const { symbol } = token - - return ( - - - - - - {symbol} - - - ) -} - -export default Item diff --git a/src/components/sharedComponents/TokenSelect/TopTokens/index.tsx b/src/components/sharedComponents/TokenSelect/TopTokens/index.tsx deleted file mode 100644 index e8ab61a1..00000000 --- a/src/components/sharedComponents/TokenSelect/TopTokens/index.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Flex, type FlexProps } from '@chakra-ui/react' -import type { FC } from 'react' -import Item from '@/src/components/sharedComponents/TokenSelect/TopTokens/Item' -import type { Token, Tokens } from '@/src/types/token' -import { isNativeToken } from '@/src/utils/address' - -interface TopTokensProps extends FlexProps { - onTokenSelect: (token: Token | undefined) => void - tokens: Tokens -} - -/** - * TopTokens component for TokenSelect. Displays a list of top / preferred tokens. - * - * @param {function} onTokenSelect - Callback function to be called when a token is selected. - * @param {Tokens} tokens - The list of tokens to display. - */ -const TopTokens: FC = ({ onTokenSelect, tokens, ...restProps }) => { - const topTokenSymbols = ['op', 'usdc', 'usdt', 'dai', 'weth', 'wbtc', 'aave'] - - return ( - - {[ - // append native token at the beginning - tokens.find((token) => isNativeToken(token.address)), - ...tokens - .filter((token) => topTokenSymbols.includes(token.symbol.toLowerCase())) - .sort( - (a, b) => - topTokenSymbols.indexOf(a.symbol.toLowerCase()) - - topTokenSymbols.indexOf(b.symbol.toLowerCase()), - ), - ] - // if token is not found, filter it out - .filter(Boolean) - // render the token - .map((token) => ( - onTokenSelect(token)} - // biome-ignore lint/style/noNonNullAssertion: token is defined when rendered via filter above - token={token!} - /> - ))} - - ) -} - -export default TopTokens diff --git a/src/components/sharedComponents/TokenSelect/index.tsx b/src/components/sharedComponents/TokenSelect/index.tsx deleted file mode 100644 index 0ca21ff8..00000000 --- a/src/components/sharedComponents/TokenSelect/index.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { Flex, type FlexProps } from '@chakra-ui/react' -import { useEffect, useRef, useState } from 'react' -import type { Chain } from 'viem/chains' -import List from '@/src/components/sharedComponents/TokenSelect/List' -import Search from '@/src/components/sharedComponents/TokenSelect/Search' -import TopTokens from '@/src/components/sharedComponents/TokenSelect/TopTokens' -import type { Networks } from '@/src/components/sharedComponents/TokenSelect/types' -import { getValidChainId } from '@/src/components/sharedComponents/TokenSelect/utils' -import { useTokenSearch } from '@/src/hooks/useTokenSearch' -import { useTokens } from '@/src/hooks/useTokens' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' -import { chains } from '@/src/lib/networks.config' -import type { Token } from '@/src/types/token' -import { withSuspenseAndRetry } from '@/src/utils/suspenseWrapper' -import styles from './styles' - -export interface TokenSelectProps { - containerHeight?: number - currentNetworkId?: number - iconSize?: number - itemHeight?: number - networks?: Networks | undefined - onTokenSelect: (token: Token | undefined) => void - placeholder?: string - showAddTokenButton?: boolean - showTopTokens?: boolean - showBalance?: boolean -} - -/** @ignore */ -type Props = FlexProps & TokenSelectProps - -/** - * TokenSelect component, used to search and select a token from a list. - * - * @param {object} props - TokenSelect props. - * @param {number} [props.currentNetworkId=mainnet.id] - The current network id. Default is mainnet's id. - * @param {function} props.onTokenSelect - Callback function to be called when a token is selected. - * @param {Networks} [props.networks] - Optional list of networks to display in the dropdown. The dropdown won't show up if undefined. Default is undefined. - * @param {string} [props.placeholder='Search by name or address'] - Optional placeholder text for the search input. Default is 'Search by name or address'. - * @param {number} [props.containerHeight=320] - Optional height of the virtualized tokens list. Default is 320. - * @param {number} [props.iconSize=32] - Optional size of the token icon in the list. Default is 32. - * @param {number} [props.itemHeight=64] - Optional height of each item in the list. Default is 64. - * @param {boolean} [props.showAddTokenButton=false] - Optional flag to allow adding a token. Default is false. - * @param {boolean} [props.showBalance=false] - Optional flag to show the token balance in the list. Default is false. - * @param {boolean} [props.showTopTokens=false] - Optional flag to show the top tokens in the list. Default is false. - */ -const TokenSelect = withSuspenseAndRetry( - ({ - children, - containerHeight = 320, - currentNetworkId, - css, - iconSize = 32, - itemHeight = 52, - networks = undefined, - onTokenSelect, - placeholder = 'Search by name or address', - showAddTokenButton = false, - showBalance = false, - showTopTokens = false, - ...restProps - }) => { - const { appChainId, walletChainId } = useWeb3Status() - - const [chainId, setChainId] = useState(() => - getValidChainId({ - appChainId, - currentNetworkId, - dappChains: chains, - networks, - walletChainId, - }), - ) - - const previousDepsRef = useRef([appChainId, currentNetworkId, walletChainId]) - - /** - * This is a sort-of observer, that listens to changes in the `appChainId` and `currentNetworkId` - * identifies which one changed and updates the chainId accordingly. - * - * This way, we can have a mixed behavior between app-based and wallet-based chain change. - */ - useEffect(() => { - const previousDeps = previousDepsRef.current - const currentDeps = [appChainId, currentNetworkId, walletChainId] - - previousDeps.forEach((previousDep, index) => { - const currentDep = currentDeps[index] - - if (previousDep !== currentDep) { - const currentChainId = currentDeps[1] - - if (index === 1 && !!currentChainId) { - // currentNetworkId changed, we stick with it - setChainId(currentChainId) - } else { - if (!currentDep) { - // if the chainId is undefined, we don't do anything - return - } - - // appChainId or walletChainId changed, - // we need to check that it's valid in the current context - if (networks) { - // if `networks` is defined, - // we need to check if the chainId is valid in the list and set it - if (networks.some((network) => network.id === currentDep)) { - setChainId(currentDep) - } - } else { - // if `networks` is not defined, - // we need to check if the chainId is valid in the dApp chains list and set it - if (chains.some((chain) => chain.id === currentDep)) { - setChainId(currentDep) - } - } - } - } - }) - - previousDepsRef.current = [appChainId, currentNetworkId, walletChainId] - }, [appChainId, currentNetworkId, networks, walletChainId]) - - const { isLoadingBalances, tokensByChainId } = useTokens({ - chainId, - withBalance: showBalance, - }) - - const { searchResult, searchTerm, setSearchTerm } = useTokenSearch( - { tokens: tokensByChainId[chainId] }, - [currentNetworkId, tokensByChainId[chainId]], - ) - - return ( - - - {showTopTokens && ( - - )} - - {children} - - ) - }, -) - -export default TokenSelect diff --git a/src/components/sharedComponents/TokenSelect/styles.ts b/src/components/sharedComponents/TokenSelect/styles.ts deleted file mode 100644 index 51f889e5..00000000 --- a/src/components/sharedComponents/TokenSelect/styles.ts +++ /dev/null @@ -1,70 +0,0 @@ -export const styles = { - 'html.light &': { - '--background-color': '#fff', - '--border-color': '#fff', - '--title-color': '#2e3048', - '--box-shadow': '0 9.6px 13px 0 rgb(0 0 0 / 8%)', - '--network-button-color': '#2e3048', - '--network-button-background-color': '#f7f7f7', - '--list-border-top-color': '#e2e0e7', - '--row-background-color': 'transparent', - '--row-background-color-hover': 'rgb(0 0 0 / 5%)', - '--row-token-name-color': '#2e3048', - '--row-token-balance-color': '#2e3048', - '--row-token-value-color': '#2e3048', - '--top-token-item-background-color': '#fff', - '--top-token-item-border-color': '#e2e0e7', - '--top-token-item-color': '#2e3048', - '--top-token-item-background-color-hover': 'rgb(0 0 0 / 5%)', - '--search-field-color': '#2e3048', - '--search-field-color-active': '#2e3048', - '--search-field-background-color': '#f7f7f7', - '--search-field-background-color-active': '#f7f7f7', - '--search-field-placeholder-color': '#161d1a', - '--search-field-box-shadow': 'none', - '--search-field-box-shadow-active': 'rgb(0 0 0 / 10%)', - '--search-field-border-color': '#e2e0e7', - '--search-field-border-color-active': '#e2e0e7', - '--add-erc20-token-button-background-color': '#2e3048', - '--add-erc20-token-button-background-color-hover': '#3d405f', - '--add-erc20-token-button-border-color': '#2e3048', - '--add-erc20-token-button-border-color-hover': '#3d405f', - '--add-erc20-token-button-color': '#fff', - '--add-erc20-token-button-color-hover': '#fff', - }, - 'html.dark &': { - '--background-color': '#2e3048', - '--border-color': '#2e3048', - '--title-color': '#fff', - '--box-shadow': '0 0 20px 0 rgb(255 255 255 / 8%)', - '--network-button-color': '#fff', - '--network-button-background-color': '#292b43', - '--list-border-top-color': '#4b4d60', - '--row-background-color': 'transparent', - '--row-background-color-hover': 'rgb(255 255 255 / 5%)', - '--row-token-name-color': '#fff', - '--row-token-balance-color': '#fff', - '--row-token-value-color': '#fff', - '--top-token-item-background-color': '#2e3048', - '--top-token-item-border-color': '#4b4d60', - '--top-token-item-color': '#fff', - '--top-token-item-background-color-hover': 'rgb(255 255 255 / 5%)', - '--search-field-color': '#fff', - '--search-field-color-active': '#fff', - '--search-field-background-color': '#292b43', - '--search-field-background-color-active': '#292b43', - '--search-field-placeholder-color': '#ddd', - '--search-field-box-shadow': 'none', - '--search-field-box-shadow-active': 'none', - '--search-field-border-color': '#5f6178', - '--search-field-border-color-active': '#5f6178', - '--add-erc20-token-button-background-color': '#5f6178', - '--add-erc20-token-button-background-color-hover': '#4a4c5f', - '--add-erc20-token-button-border-color': '#5f6178', - '--add-erc20-token-button-border-color-hover': '#4a4c5f', - '--add-erc20-token-button-color': '#fff', - '--add-erc20-token-button-color-hover': '#fff', - }, -} - -export default styles diff --git a/src/components/sharedComponents/TokenSelect/types.ts b/src/components/sharedComponents/TokenSelect/types.ts deleted file mode 100644 index 1b778dbc..00000000 --- a/src/components/sharedComponents/TokenSelect/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ReactElement } from 'react' - -export type Networks = Array<{ - icon: ReactElement - id: number - label: string - onClick: () => void -}> diff --git a/src/components/sharedComponents/TokenSelect/utils.tsx b/src/components/sharedComponents/TokenSelect/utils.tsx deleted file mode 100644 index 9666c46e..00000000 --- a/src/components/sharedComponents/TokenSelect/utils.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { Chain } from 'viem/chains' - -import type { Networks } from '@/src/components/sharedComponents/TokenSelect/types' -import type { ChainsIds, chains } from '@/src/lib/networks.config' - -interface GetValidChainIdParams { - currentNetworkId?: Chain['id'] - networks?: Networks - dappChains: typeof chains - walletChainId?: Chain['id'] - appChainId?: ChainsIds -} - -export const getValidChainId = ({ - appChainId, - currentNetworkId, - dappChains, - networks, - walletChainId, -}: GetValidChainIdParams): Chain['id'] => { - // If the current network is defined, use it - if (currentNetworkId) { - return currentNetworkId - } - - // if `networks` has been passed, then we need to stick with - if (networks !== undefined) { - // we prioritze the wallet chainId over the app chainId because it may be valid in this case, - // but not supported by the app to interact with but as a read-only chain. - if (typeof walletChainId === 'number' && networks.some(({ id }) => id === walletChainId)) { - return walletChainId - } - - if (typeof appChainId === 'number' && networks.some(({ id }) => id === appChainId)) { - return appChainId - } - - // if nothing matches, we default to the first network in the list - return networks[0].id - } - - // if `networks` is not defined, we need to verify the dApp configuration - // Same as before, we prioritize the wallet chainId over the app chainId - if (typeof walletChainId === 'number' && dappChains.some(({ id }) => id === walletChainId)) { - return walletChainId - } - - if (typeof appChainId === 'number' && dappChains.some(({ id }) => id === appChainId)) { - return appChainId - } - - // if nothing matches, we default to the first chain in the list - return dappChains[0].id -} diff --git a/src/components/sharedComponents/TransactionButton.test.tsx b/src/components/sharedComponents/TransactionButton.test.tsx deleted file mode 100644 index bebe37f3..00000000 --- a/src/components/sharedComponents/TransactionButton.test.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' -import { render, screen } from '@testing-library/react' -import { createElement, type ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import TransactionButton from './TransactionButton' - -const mockSwitchChain = vi.fn() -const mockExecute = vi.fn(async () => ({ - status: 'success' as const, - ref: { chainType: 'evm', id: '0xabc', chainId: 1 }, - receipt: {}, -})) - -vi.mock('@/src/sdk/react/hooks', () => ({ - useWallet: vi.fn(() => ({ - needsConnect: true, - needsChainSwitch: false, - isReady: false, - status: { connected: false, connecting: false, activeAccount: null, connectedChainIds: [] }, - switchChain: mockSwitchChain, - connect: vi.fn(), - disconnect: vi.fn(), - signMessage: vi.fn(), - getSigner: vi.fn(), - adapterKey: 'evm', - openConnectModal: vi.fn(), - openAccountModal: vi.fn(), - adapter: {} as never, - })), - useTransaction: vi.fn(() => ({ - phase: 'idle', - execute: mockExecute, - reset: vi.fn(), - prepareResult: null, - ref: null, - result: null, - preStepResults: [], - explorerUrl: null, - error: null, - })), - useChainRegistry: vi.fn(() => ({ - getChain: vi.fn(() => null), - getChainByCaip2: vi.fn(() => null), - getChainType: vi.fn(() => null), - getChainsByType: vi.fn(() => []), - getAllChains: vi.fn(() => []), - })), -})) - -vi.mock('@/src/wallet/providers', () => ({ - ConnectWalletButton: () => - createElement( - 'button', - { type: 'button', 'data-testid': 'connect-wallet-button' }, - 'Connect Wallet', - ), -})) - -vi.mock('@/src/components/sharedComponents/ui/SwitchChainButton', () => ({ - default: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => - createElement( - 'button', - { type: 'button', 'data-testid': 'switch-chain-button', onClick }, - children, - ), -})) - -const { useWallet, useTransaction, useChainRegistry } = await import('@/src/sdk/react/hooks') -const mockedUseWallet = vi.mocked(useWallet) -const mockedUseTransaction = vi.mocked(useTransaction) -const mockedUseChainRegistry = vi.mocked(useChainRegistry) - -const system = createSystem(defaultConfig) -const renderWithChakra = (ui: ReactNode) => - render({ui}) - -const testParams = { chainId: 1, payload: { to: '0x1234', value: '0' } } - -const makeWalletReady = () => - mockedUseWallet.mockReturnValue({ - needsConnect: false, - needsChainSwitch: false, - isReady: true, - status: { connected: true, connecting: false, activeAccount: '0xabc', connectedChainIds: [1] }, - switchChain: mockSwitchChain, - connect: vi.fn(), - disconnect: vi.fn(), - signMessage: vi.fn(), - getSigner: vi.fn(), - adapterKey: 'evm', - openConnectModal: vi.fn(), - openAccountModal: vi.fn(), - adapter: {} as never, - }) - -describe('TransactionButton', () => { - beforeEach(() => { - vi.clearAllMocks() - mockExecute.mockResolvedValue({ - status: 'success' as const, - ref: { chainType: 'evm', id: '0xabc', chainId: 1 }, - receipt: {}, - }) - }) - - it('renders connect button when wallet needsConnect', () => { - mockedUseWallet.mockReturnValue({ - needsConnect: true, - needsChainSwitch: false, - isReady: false, - status: { connected: false, connecting: false, activeAccount: null, connectedChainIds: [] }, - switchChain: mockSwitchChain, - connect: vi.fn(), - disconnect: vi.fn(), - signMessage: vi.fn(), - getSigner: vi.fn(), - adapterKey: 'evm', - openConnectModal: vi.fn(), - openAccountModal: vi.fn(), - adapter: {} as never, - }) - - renderWithChakra(Send) - - expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument() - expect(screen.queryByText('Send')).toBeNull() - }) - - it('renders custom fallback when provided and wallet needsConnect', () => { - mockedUseWallet.mockReturnValue({ - needsConnect: true, - needsChainSwitch: false, - isReady: false, - status: { connected: false, connecting: false, activeAccount: null, connectedChainIds: [] }, - switchChain: mockSwitchChain, - connect: vi.fn(), - disconnect: vi.fn(), - signMessage: vi.fn(), - getSigner: vi.fn(), - adapterKey: 'evm', - openConnectModal: vi.fn(), - openAccountModal: vi.fn(), - adapter: {} as never, - }) - - renderWithChakra( - - Send - , - ) - - expect(screen.getByTestId('custom-fallback')).toBeInTheDocument() - expect(screen.queryByText('Send')).toBeNull() - }) - - it('renders switch chain button when wallet needsChainSwitch', () => { - mockedUseWallet.mockReturnValue({ - needsConnect: false, - needsChainSwitch: true, - isReady: false, - status: { - connected: true, - connecting: false, - activeAccount: '0xabc', - connectedChainIds: [1], - }, - switchChain: mockSwitchChain, - connect: vi.fn(), - disconnect: vi.fn(), - signMessage: vi.fn(), - getSigner: vi.fn(), - adapterKey: 'evm', - openConnectModal: vi.fn(), - openAccountModal: vi.fn(), - adapter: {} as never, - }) - mockedUseChainRegistry.mockReturnValue({ - getChain: vi.fn(() => ({ - name: 'OP Mainnet', - chainId: 10, - chainType: 'evm', - caip2Id: 'eip155:10', - nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, - addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' }, - })), - getChainByCaip2: vi.fn(() => null), - getChainType: vi.fn(() => null), - getChainsByType: vi.fn(() => []), - getAllChains: vi.fn(() => []), - }) - - renderWithChakra(Send) - - expect(screen.getByTestId('switch-chain-button')).toBeInTheDocument() - expect(screen.getByText(/Switch to/)).toBeInTheDocument() - expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument() - expect(screen.queryByText('Send')).toBeNull() - }) - - it('renders switch chain label with chain name', () => { - mockedUseWallet.mockReturnValue({ - needsConnect: false, - needsChainSwitch: true, - isReady: false, - status: { - connected: true, - connecting: false, - activeAccount: '0xabc', - connectedChainIds: [1], - }, - switchChain: mockSwitchChain, - connect: vi.fn(), - disconnect: vi.fn(), - signMessage: vi.fn(), - getSigner: vi.fn(), - adapterKey: 'evm', - openConnectModal: vi.fn(), - openAccountModal: vi.fn(), - adapter: {} as never, - }) - mockedUseChainRegistry.mockReturnValue({ - getChain: vi.fn(() => ({ - name: 'OP Mainnet', - chainId: 10, - chainType: 'evm', - caip2Id: 'eip155:10', - nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, - addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' }, - })), - getChainByCaip2: vi.fn(() => null), - getChainType: vi.fn(() => null), - getChainsByType: vi.fn(() => []), - getAllChains: vi.fn(() => []), - }) - - renderWithChakra( - - Send - , - ) - - expect(screen.getByText(/Change to/)).toBeInTheDocument() - expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument() - }) - - it('renders transaction button when wallet is ready', () => { - makeWalletReady() - - renderWithChakra(Send ETH) - - expect(screen.getByText('Send ETH')).toBeInTheDocument() - expect(screen.queryByTestId('connect-wallet-button')).toBeNull() - }) - - it('shows labelSending when phase is not idle', () => { - makeWalletReady() - mockedUseTransaction.mockReturnValue({ - phase: 'submit', - execute: mockExecute, - reset: vi.fn(), - prepareResult: null, - ref: null, - result: null, - preStepResults: [], - explorerUrl: null, - error: null, - }) - - renderWithChakra(Send ETH) - - expect(screen.getByText('Sending...')).toBeInTheDocument() - expect(screen.queryByText('Send ETH')).toBeNull() - }) - - it('is disabled when disabled prop passed', () => { - makeWalletReady() - mockedUseTransaction.mockReturnValue({ - phase: 'idle', - execute: mockExecute, - reset: vi.fn(), - prepareResult: null, - ref: null, - result: null, - preStepResults: [], - explorerUrl: null, - error: null, - }) - - renderWithChakra( - - Send ETH - , - ) - - const button = screen.getByText('Send ETH').closest('button') - expect(button).toBeDefined() - expect(button?.disabled).toBe(true) - }) -}) diff --git a/src/components/sharedComponents/TransactionButton.tsx b/src/components/sharedComponents/TransactionButton.tsx deleted file mode 100644 index 15d05361..00000000 --- a/src/components/sharedComponents/TransactionButton.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { ButtonProps } from '@chakra-ui/react' -import type { ReactElement } from 'react' -import PrimaryButton from '@/src/components/sharedComponents/ui/PrimaryButton' -import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton' -import type { TransactionLifecycle, TransactionParams } from '@/src/sdk/core' -import { useChainRegistry, useTransaction, useWallet } from '@/src/sdk/react/hooks' -import { ConnectWalletButton } from '@/src/wallet/providers' - -interface TransactionButtonProps extends ButtonProps { - /** Transaction parameters. The chainId field drives wallet resolution. */ - params: TransactionParams - /** Per-operation lifecycle hooks merged with global lifecycle. */ - lifecycle?: TransactionLifecycle - /** Custom fallback when wallet needs connection. Defaults to ConnectWalletButton. */ - fallback?: ReactElement - /** Button label during pending transaction. Defaults to 'Sending...'. */ - labelSending?: string - /** Label for the switch chain button. Defaults to 'Switch to'. */ - switchChainLabel?: string -} - -/** - * Self-contained transaction button with wallet verification and submission. - * - * Shows a connect button if not connected, a switch chain button if on the wrong chain, - * or the transaction button when ready. - * - * @example - * ```tsx - * console.log('confirmed', result) }} - * > - * Send ETH - * - * ``` - */ -function TransactionButton({ - params, - lifecycle, - children = 'Send Transaction', - disabled, - fallback = , - labelSending = 'Sending...', - switchChainLabel = 'Switch to', - ...restProps -}: TransactionButtonProps) { - const wallet = useWallet({ chainId: params.chainId }) - const { execute, phase } = useTransaction({ lifecycle }) - const registry = useChainRegistry() - const isPending = phase !== 'idle' - - if (wallet.needsConnect) { - return fallback - } - - if (wallet.needsChainSwitch) { - const targetChain = registry.getChain(params.chainId) - return ( - wallet.switchChain(params.chainId)}> - {switchChainLabel} {targetChain?.name ?? String(params.chainId)} - - ) - } - - const handleClick = async () => { - try { - await execute(params) - } catch { - // useTransaction sets error state internally - } - } - - return ( - - {isPending ? labelSending : children} - - ) -} - -export default TransactionButton diff --git a/src/components/sharedComponents/WalletStatusVerifier.test.tsx b/src/components/sharedComponents/WalletStatusVerifier.test.tsx deleted file mode 100644 index 389e2525..00000000 --- a/src/components/sharedComponents/WalletStatusVerifier.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { createElement, type ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useWeb3StatusConnected, WalletStatusVerifier } from './WalletStatusVerifier' - -const mockSwitchChain = vi.fn() - -vi.mock('@/src/hooks/useWalletStatus', () => ({ - useWalletStatus: vi.fn(() => ({ - isReady: false, - needsConnect: true, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' }, - targetChainId: 1, - switchChain: mockSwitchChain, - })), -})) - -vi.mock('@/src/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(() => ({ - readOnlyClient: {}, - appChainId: 1, - address: '0xdeadbeef', - balance: undefined, - connectingWallet: false, - switchingChain: false, - isWalletConnected: true, - walletClient: undefined, - isWalletSynced: true, - walletChainId: 1, - switchChain: vi.fn(), - disconnect: vi.fn(), - })), -})) - -vi.mock('@/src/wallet/providers', () => ({ - ConnectWalletButton: () => - createElement( - 'button', - { type: 'button', 'data-testid': 'connect-wallet-button' }, - 'Connect Wallet', - ), -})) - -const { useWalletStatus } = await import('@/src/hooks/useWalletStatus') -const mockedUseWalletStatus = vi.mocked(useWalletStatus) - -const system = createSystem(defaultConfig) - -const renderWithChakra = (ui: ReactNode) => - render({ui}) - -describe('WalletStatusVerifier', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('renders default fallback (ConnectWalletButton) when wallet needs connect', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: false, - needsConnect: true, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, - switchChain: mockSwitchChain, - }) - - renderWithChakra( - createElement( - WalletStatusVerifier, - null, - createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), - ), - ) - - expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument() - expect(screen.queryByTestId('protected-content')).toBeNull() - }) - - it('renders custom fallback when provided and wallet needs connect', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: false, - needsConnect: true, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, - switchChain: mockSwitchChain, - }) - - renderWithChakra( - createElement( - WalletStatusVerifier, - { fallback: createElement('div', { 'data-testid': 'custom-fallback' }, 'Custom') }, - createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), - ), - ) - - expect(screen.getByTestId('custom-fallback')).toBeInTheDocument() - expect(screen.queryByTestId('protected-content')).toBeNull() - }) - - it('renders switch chain button when wallet needs chain switch', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: false, - needsConnect: false, - needsChainSwitch: true, - targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType< - typeof useWalletStatus - >['targetChain'], - targetChainId: 10, - switchChain: mockSwitchChain, - }) - - renderWithChakra( - createElement( - WalletStatusVerifier, - null, - createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), - ), - ) - - expect(screen.getByText(/Switch to/)).toBeInTheDocument() - expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument() - expect(screen.queryByTestId('protected-content')).toBeNull() - }) - - it('renders children when wallet is ready', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: true, - needsConnect: false, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, - switchChain: mockSwitchChain, - }) - - renderWithChakra( - createElement( - WalletStatusVerifier, - null, - createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), - ), - ) - - expect(screen.getByTestId('protected-content')).toBeInTheDocument() - }) - - it('calls switchChain when switch button is clicked', async () => { - const user = userEvent.setup() - - mockedUseWalletStatus.mockReturnValue({ - isReady: false, - needsConnect: false, - needsChainSwitch: true, - targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType< - typeof useWalletStatus - >['targetChain'], - targetChainId: 10, - switchChain: mockSwitchChain, - }) - - renderWithChakra( - createElement(WalletStatusVerifier, null, createElement('div', null, 'Protected')), - ) - - const switchButton = screen.getByText(/Switch to/) - await user.click(switchButton) - - expect(mockSwitchChain).toHaveBeenCalledWith(10) - }) - - it('provides web3 status context to children when wallet is ready', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: true, - needsConnect: false, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, - switchChain: mockSwitchChain, - }) - - const ChildComponent = () => { - const { address } = useWeb3StatusConnected() - return createElement('div', { 'data-testid': 'address' }, address) - } - - renderWithChakra(createElement(WalletStatusVerifier, null, createElement(ChildComponent))) - - expect(screen.getByTestId('address')).toHaveTextContent('0xdeadbeef') - }) -}) diff --git a/src/components/sharedComponents/WalletStatusVerifier.tsx b/src/components/sharedComponents/WalletStatusVerifier.tsx deleted file mode 100644 index ab88e5b2..00000000 --- a/src/components/sharedComponents/WalletStatusVerifier.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { createContext, type FC, type ReactElement, useContext } from 'react' -import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton' -import { useWalletStatus } from '@/src/hooks/useWalletStatus' -import { useWeb3Status, type Web3Status } from '@/src/hooks/useWeb3Status' -import type { ChainsIds } from '@/src/lib/networks.config' -import type { RequiredNonNull } from '@/src/types/utils' -import { DeveloperError } from '@/src/utils/DeveloperError' -import { ConnectWalletButton } from '@/src/wallet/providers' - -const WalletStatusVerifierContext = createContext | null>(null) - -/** - * Returns the connected wallet's Web3 status. - * - * Must be called inside a `` component tree. - * Throws if called outside one. - * - * @deprecated Use the new SDK hooks from `@/src/sdk/react/hooks` instead. - */ -export const useWeb3StatusConnected = () => { - const context = useContext(WalletStatusVerifierContext) - if (context === null) { - throw new DeveloperError( - 'useWeb3StatusConnected must be used inside a component.', - ) - } - return context -} - -interface WalletStatusVerifierProps { - chainId?: ChainsIds - children?: ReactElement - fallback?: ReactElement - switchChainLabel?: string -} - -/** - * Wrapper component that gates content on wallet connection and chain status. - * - * This is the primary API for protecting UI that requires a connected wallet. - * Components that call `useWeb3StatusConnected` must be rendered inside this component. - * - * @deprecated Use {@link WalletGuard} from `@/src/sdk/react` instead. - * - * @example - * ```tsx - * - * - * - * ``` - */ -const WalletStatusVerifier: FC = ({ - chainId, - children, - fallback = , - switchChainLabel = 'Switch to', -}: WalletStatusVerifierProps) => { - const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } = - useWalletStatus({ chainId }) - const web3Status = useWeb3Status() - - if (needsConnect) { - return fallback - } - - if (needsChainSwitch) { - return ( - switchChain(targetChainId)}> - {switchChainLabel} {targetChain.name} - - ) - } - - return ( - }> - {children} - - ) -} - -export { WalletStatusVerifier } diff --git a/src/contracts/definitions.ts b/src/contracts/definitions.ts index df13ac0f..b68baafd 100644 --- a/src/contracts/definitions.ts +++ b/src/contracts/definitions.ts @@ -6,7 +6,7 @@ import { type ContractFunctionArgs as WagmiContractFunctionArgs, type ContractFunctionName as WagmiContractFunctionName, } from 'viem' -import { mainnet, optimismSepolia, polygon, sepolia } from 'viem/chains' +import { baseSepolia, mainnet, optimismSepolia, polygon, sepolia } from 'viem/chains' import type { ChainsIds } from '@/src/core/types' import { AAVEWethABI } from './abis/AAVEWeth' @@ -51,8 +51,9 @@ const contracts = [ { abi: AaveFaucetABI, address: { - 11155111: '0xc959483dba39aa9e78757139af0e9a2edeb3f42d', - 1: '0x0000000000000000000000000000000000000000', + [sepolia.id]: '0xc959483dba39aa9e78757139af0e9a2edeb3f42d', + [baseSepolia.id]: '0xD9145b5F45Ad4519c7ACcD6E0A4A82e83bB8A6Dc', + [mainnet.id]: '0x0000000000000000000000000000000000000000', }, name: 'AaveFaucet', }, diff --git a/src/contracts/hooks/useOPL1CrossDomainMessengerProxy.ts b/src/contracts/hooks/useOPL1CrossDomainMessengerProxy.ts index afdc0206..50616420 100644 --- a/src/contracts/hooks/useOPL1CrossDomainMessengerProxy.ts +++ b/src/contracts/hooks/useOPL1CrossDomainMessengerProxy.ts @@ -1,11 +1,23 @@ -import { useCallback } from 'react' +/** + * Builds TransactionParams for a cross-domain message from L1 to Optimism L2. + * + * This is a Level 5 escape hatch — pure async function using viem for OP-specific + * gas estimation and calldata encoding. The result feeds into useTransaction().execute() + * or at Level 1-2. + * + * @precondition fromChain is sepolia or mainnet + * @precondition walletAddress is a valid connected account + * @postcondition returns TransactionParams targeting L1CrossDomainMessenger.sendMessage + * @postcondition gas field includes 20% safety buffer over combined L1+L2 estimates + */ import { type Address, createPublicClient, encodeFunctionData, type Hash } from 'viem' import type { mainnet } from 'viem/chains' import { optimism, optimismSepolia, sepolia } from 'viem/chains' -import { useWriteContract } from 'wagmi' import { transports } from '@/src/core/types' +import type { TransactionParams } from '@/src/sdk/core' +import type { EvmContractCall } from '@/src/sdk/core/evm/types' import { type ContractFunctionArgs, type ContractFunctionName, @@ -13,7 +25,17 @@ import { getContract, } from '../definitions' -async function l2ContractCallInfo({ +export interface BuildCrossDomainMessageConfig { + fromChain: typeof sepolia | typeof mainnet + l2ContractAddress: Address + contractName: ContractNames + functionName: ContractFunctionName + args: ContractFunctionArgs> + value: bigint + walletAddress: Address +} + +async function estimateL2Gas({ contractName, functionName, args, @@ -21,10 +43,10 @@ async function l2ContractCallInfo({ walletAddress, chain, }: { - args: ContractFunctionArgs + args: ContractFunctionArgs> chain: typeof optimismSepolia | typeof optimism contractName: ContractNames - functionName: ContractFunctionName + functionName: ContractFunctionName value?: bigint walletAddress: Address }) { @@ -39,10 +61,10 @@ async function l2ContractCallInfo({ address: contract.address, abi: contract.abi, functionName, - // biome-ignore lint/suspicious/noExplicitAny: TS does not infer correctly the type of valueuseop + // biome-ignore lint/suspicious/noExplicitAny: viem generic inference limitation args: args as any, account: walletAddress, - // biome-ignore lint/suspicious/noExplicitAny: TS does not infer correctly the type of value + // biome-ignore lint/suspicious/noExplicitAny: viem generic inference limitation value: value as any, }) @@ -55,7 +77,7 @@ async function l2ContractCallInfo({ return { message, gas } } -function estimateGasL1CrossDomainMessenger({ +async function estimateL1Gas({ chain, l2Gas, message, @@ -78,108 +100,58 @@ function estimateGasL1CrossDomainMessenger({ abi: contract.abi, functionName: 'sendMessage', args: [contract.address, message, Number(l2Gas)], - value: value, + value, }) } /** - * Custom hook to send a cross-domain message from L1 (Ethereum Mainnet or Sepolia) to Optimism. + * Builds TransactionParams for an L1→L2 cross-domain message via OP stack. * - * Handles the complex process of sending a message from L1 to L2 through Optimism's - * CrossDomainMessenger contract, including: - * - Estimating gas on both L1 and L2 - * - Encoding function data for the message - * - Adding safety buffer to gas estimates (20%) - * - Executing the cross-chain transaction + * Call this on user action (e.g., button click), then pass the result + * to useTransaction().execute(params) or . * - * @param {Object} params - The parameters object - * @param {Chain} params.fromChain - Source chain (sepolia or mainnet) - * @param {Address} params.l2ContractAddress - Target contract address on L2 - * @param {ContractNames} params.contractName - Name of the contract from contracts registry - * @param {ContractFunctionName} params.functionName - Name of function to call on the L2 contract - * @param {ContractFunctionArgs} params.args - Arguments to pass to the L2 function - * @param {bigint} params.value - Value in wei to send with the transaction - * - * @returns {Function} Async function that executes the cross-domain message when called - * - * @example - * ```tsx - * const sendToOptimism = useL1CrossDomainMessengerProxy({ - * fromChain: sepolia, - * l2ContractAddress: '0x...', - * contractName: 'MyContract', - * functionName: 'myFunction', - * args: [arg1, arg2], - * value: parseEther('0.1') - * }); - * - * // Later in your code - * const handleClick = async () => { - * try { - * const txHash = await sendToOptimism(); - * console.log('Transaction sent:', txHash); - * } catch (error) { - * console.error('Failed to send cross-domain message:', error); - * } - * }; - * ``` + * @precondition wallet must be connected to fromChain + * @postcondition returns TransactionParams with EvmContractCall targeting sendMessage */ -export function useL1CrossDomainMessengerProxy({ - fromChain, - l2ContractAddress, - contractName, - functionName, - args, - value, - walletAddress, -}: { - fromChain: typeof sepolia | typeof mainnet - l2ContractAddress: Address - contractName: ContractNames - functionName: ContractFunctionName - args: ContractFunctionArgs - value: bigint - walletAddress: Address -}) { - const contract = getContract('OPL1CrossDomainMessengerProxy', fromChain.id) - const { writeContractAsync } = useWriteContract() - - return useCallback(async () => { - const { gas: l2Gas, message } = await l2ContractCallInfo({ - contractName, - functionName, - args, - value, - walletAddress, - chain: fromChain === sepolia ? optimismSepolia : optimism, - }) - - const l1Gas = await estimateGasL1CrossDomainMessenger({ - chain: fromChain, - message, - value, - l2Gas, - }) - - return writeContractAsync({ - chainId: fromChain.id, - abi: contract.abi, - address: contract.address, - functionName: 'sendMessage', - args: [l2ContractAddress, message, Number(l2Gas)], - value, - gas: ((l1Gas + l2Gas) * 120n) / 100n, - }) - }, [ +export async function buildCrossDomainMessageParams( + config: BuildCrossDomainMessageConfig, +): Promise { + const { fromChain, l2ContractAddress, contractName, functionName, args, value, walletAddress } = + config + + const l2Chain = fromChain === sepolia ? optimismSepolia : optimism + + const { gas: l2Gas, message } = await estimateL2Gas({ contractName, functionName, args, value, walletAddress, - fromChain, - writeContractAsync, - contract.abi, - contract.address, - l2ContractAddress, - ]) + chain: l2Chain, + }) + + const l1Gas = await estimateL1Gas({ + chain: fromChain, + message, + value, + l2Gas, + }) + + const contract = getContract('OPL1CrossDomainMessengerProxy', fromChain.id) + + const payload: EvmContractCall = { + contract: { + address: contract.address, + abi: [...contract.abi], + functionName: 'sendMessage', + args: [l2ContractAddress, message, Number(l2Gas)], + }, + value, + gas: ((l1Gas + l2Gas) * 120n) / 100n, + } + + return { + chainId: fromChain.id, + payload, + } } diff --git a/src/core/config/networks.config.ts b/src/core/config/networks.config.ts index 87fea035..f1ca8874 100644 --- a/src/core/config/networks.config.ts +++ b/src/core/config/networks.config.ts @@ -5,12 +5,20 @@ * @packageDocumentation */ import { http, type Transport } from 'viem' -import { arbitrum, mainnet, optimism, optimismSepolia, polygon, sepolia } from 'viem/chains' +import { + arbitrum, + baseSepolia, + mainnet, + optimism, + optimismSepolia, + polygon, + sepolia, +} from 'viem/chains' import { env } from '@/src/env' import { includeTestnets } from './common' -const devChains = [optimismSepolia, sepolia] as const +const devChains = [baseSepolia, optimismSepolia, sepolia] as const const prodChains = [mainnet, polygon, arbitrum, optimism] as const const allChains = [...devChains, ...prodChains] as const export const chains = includeTestnets ? allChains : prodChains @@ -24,4 +32,5 @@ export const transports: RestrictedTransports = { [optimismSepolia.id]: http(env.PUBLIC_RPC_OPTIMISM_SEPOLIA), [polygon.id]: http(env.PUBLIC_RPC_POLYGON), [sepolia.id]: http(env.PUBLIC_RPC_SEPOLIA), + [baseSepolia.id]: http(env.PUBLIC_RPC_BASE_SEPOLIA), } diff --git a/src/core/ui/ExplorerLink.tsx b/src/core/ui/ExplorerLink.tsx index 120ab0be..3b9e15fc 100644 --- a/src/core/ui/ExplorerLink.tsx +++ b/src/core/ui/ExplorerLink.tsx @@ -34,7 +34,7 @@ export const ExplorerLink: FC = ({ }: ExplorerLinkProps) => { return ( diff --git a/src/core/ui/NotificationToast.tsx b/src/core/ui/NotificationToast.tsx index 17fab839..39e036c0 100644 --- a/src/core/ui/NotificationToast.tsx +++ b/src/core/ui/NotificationToast.tsx @@ -1,8 +1,6 @@ 'use client' import { Toaster as ChakraToaster, createToaster, Portal, Stack, Toast } from '@chakra-ui/react' -// TODO(task-3): move to app shell — core/ should not import from wallet/ -import { useWeb3Status } from '@/src/wallet/hooks' import Spinner from './Spinner' export const notificationToaster = createToaster({ @@ -13,9 +11,7 @@ export const notificationToaster = createToaster({ }) export const NotificationToast = () => { - const { readOnlyClient } = useWeb3Status() - const chain = readOnlyClient?.chain - return !chain ? null : ( + return ( { +export const getExplorerLink = ({ + chain, + explorerUrl, + hashOrAddress, +}: GetExplorerUrlParams): string | null => { if (isAddress(hashOrAddress)) { return explorerUrl ? `${explorerUrl}/address/${hashOrAddress}` @@ -54,5 +58,5 @@ export const getExplorerLink = ({ chain, explorerUrl, hashOrAddress }: GetExplor : `${chain.blockExplorers?.default.url}/tx/${hashOrAddress}` } - throw new Error('Invalid hash or address') + return null } diff --git a/src/hooks/useTokens.ts b/src/hooks/useTokens.ts deleted file mode 100644 index d829a12d..00000000 --- a/src/hooks/useTokens.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { - createConfig, - EVM, - getChains, - getTokenBalances, - getTokens, - type TokenAmount, - type TokensResponse, -} from '@lifi/sdk' -import { useQuery } from '@tanstack/react-query' -import { useMemo } from 'react' -import { type Address, type Chain, formatUnits } from 'viem' - -import { env } from '@/src/env' -import { useTokenLists } from '@/src/hooks/useTokenLists' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' -import type { Token, Tokens } from '@/src/types/token' -import { logger } from '@/src/utils/logger' -import type { TokensMap } from '@/src/utils/tokenListsCache' - -const BALANCE_EXPIRATION_TIME = 32_000 - -/** @ignore */ -export const lifiConfig = createConfig({ - integrator: env.PUBLIC_APP_NAME, - providers: [EVM()], -}) - -/** - * Custom hook for fetching and managing tokens data with price and balances. - * - * Combines token list data with real-time price and balance information from LI.FI SDK. - * Features include: - * - Token data fetching from token lists - * - Balance fetching for specified accounts across multiple chains - * - Price information retrieval - * - Automatic sorting by token value (balance × price) - * - Periodic refetching for up-to-date balances and prices - * - * @param {Object} params - Parameters for tokens fetching - * @param {Address} [params.account] - Account address for balance fetching (defaults to connected wallet) - * @param {Chain['id']} [params.chainId] - Specific chain ID to filter tokens (defaults to all supported chains) - * @param {boolean} [params.withBalance=true] - Whether to fetch token balances - * - * @returns {Object} Token data and loading state - * @returns {Token[]} returns.tokens - Array of tokens with price and balance information - * @returns {Record} returns.tokensByChainId - Tokens organized by chain ID - * @returns {boolean} returns.isLoadingBalances - Loading state for token balances and prices - * - * @example - * ```tsx - * // Fetch all tokens with balances for connected wallet - * const { tokens, tokensByChainId, isLoadingBalances } = useTokens(); - * - * // Fetch tokens for specific chain without balances - * const { tokens } = useTokens({ - * chainId: 1, - * withBalance: false - * }); - * - * // Fetch balances for specific account - * const { tokens } = useTokens({ - * account: '0x123...' - * }); - * ``` - */ -export const useTokens = ( - { - account, - chainId, - withBalance, - }: { - account?: Address - chainId?: Chain['id'] - withBalance?: boolean - } = { - withBalance: true, - }, -) => { - const { address } = useWeb3Status() - const tokensData = useTokenLists() - account ??= address - - const canFetchBalance = !!account && withBalance - - const { data: chains, isLoading: isLoadingChains } = useQuery({ - queryKey: ['lifi', 'chains'], - queryFn: () => getChains(), - staleTime: Number.POSITIVE_INFINITY, - refetchInterval: Number.POSITIVE_INFINITY, - gcTime: Number.POSITIVE_INFINITY, - enabled: canFetchBalance, - }) - - const dAppChainsId = chainId - ? [chainId] - : Object.keys(tokensData.tokensByChainId).map((id) => Number.parseInt(id, 10)) - const lifiChainsId = chains?.map((chain) => chain.id) ?? [] - const chainsToFetch = dAppChainsId.filter((id) => lifiChainsId.includes(id)) - - const { data: tokensPricesByChain, isLoading: isLoadingPrices } = useQuery({ - queryKey: ['lifi', 'tokens', 'prices', chainsToFetch], - queryFn: () => getTokens({ chains: chainsToFetch }), - staleTime: BALANCE_EXPIRATION_TIME, - refetchInterval: BALANCE_EXPIRATION_TIME, - gcTime: Number.POSITIVE_INFINITY, - enabled: canFetchBalance && !!chains, - }) - - const { data: tokensBalances, isLoading: isLoadingBalances } = useQuery({ - queryKey: ['lifi', 'tokens', 'balances', account, chainsToFetch], - queryFn: () => - getTokenBalances( - // biome-ignore lint/style/noNonNullAssertion: guarded by enabled: canFetchBalance && !!tokensPricesByChain - account!, - // biome-ignore lint/style/noNonNullAssertion: guarded by enabled: canFetchBalance && !!tokensPricesByChain - Object.entries(tokensPricesByChain!.tokens) - .filter(([chainId]) => chainsToFetch.includes(Number.parseInt(chainId, 10))) - .flatMap(([, tokens]) => tokens), - ), - staleTime: BALANCE_EXPIRATION_TIME, - refetchInterval: BALANCE_EXPIRATION_TIME, - gcTime: Number.POSITIVE_INFINITY, - enabled: canFetchBalance && !!tokensPricesByChain, - }) - - const cache = useMemo(() => { - if ( - withBalance && - account && - !isLoadingPrices && - !isLoadingBalances && - tokensBalances && - tokensPricesByChain - ) { - return udpateTokensBalances(tokensData.tokens, [tokensBalances, tokensPricesByChain]) - } - return tokensData - }, [ - account, - isLoadingBalances, - isLoadingPrices, - tokensBalances, - tokensData, - tokensPricesByChain, - withBalance, - ]) - - return { - ...cache, - isLoadingBalances: Boolean(isLoadingChains || isLoadingBalances || isLoadingPrices), - } -} - -/** - * Updates the tokens balances by extending the tokens with balance information and sorting them by balance. - * - * @param tokens - The array of tokens. - * @param results - The results containing the balance tokens and prices. - * @returns An object containing the updated tokens and tokens grouped by chain ID. - */ -function udpateTokensBalances(tokens: Tokens, results: [Array, TokensResponse]) { - const [balanceTokens, prices] = results - - logger.time('extending tokens with balance info') - const priceByChainAddress = Object.entries(prices.tokens).reduce( - (acc, [chainId, tokens]) => { - acc[chainId] = {} - - tokens.forEach((token) => { - acc[chainId][token.address] = token.priceUSD ?? '0' - }) - - return acc - }, - {} as { [chainId: string]: { [address: string]: string } }, - ) - - const balanceTokensByChain = balanceTokens.reduce( - (acc, balanceToken) => { - if (!acc[balanceToken.chainId]) { - acc[balanceToken.chainId] = {} - } - - acc[balanceToken.chainId][balanceToken.address] = balanceToken.amount ?? 0n - - return acc - }, - {} as { [chainId: number]: { [address: string]: bigint } }, - ) - - const tokensWithBalances = tokens.map((token): Token => { - const tokenPrice = priceByChainAddress[token.chainId]?.[token.address] ?? '0' - const tokenBalance = balanceTokensByChain[token.chainId]?.[token.address] ?? 0n - - return { - ...token, - extensions: { - priceUSD: tokenPrice, - balance: tokenBalance, - }, - } - }) - logger.timeEnd('extending tokens with balance info') - - logger.time('sorting tokens by balance') - tokensWithBalances.sort(sortFn) - logger.timeEnd('sorting tokens by balance') - - logger.time('updating tokens cache') - const tokensByChain = tokensWithBalances.reduce( - (acc, token) => { - if (!acc[token.chainId]) { - acc[token.chainId] = [token] - } else { - acc[token.chainId].push(token) - } - return acc - }, - {} as TokensMap['tokensByChainId'], - ) - - return { tokens: tokensWithBalances, tokensByChainId: tokensByChain } -} - -/** - * A sorting function used to sort tokens by balance. - * @param a The first token. - * @param b The second token. - * @returns A negative number if a should be sorted before b, a positive number - * if b should be sorted before a, or 0 if they have the same order. - */ -function sortFn(a: Token, b: Token) { - return ( - Number.parseFloat(formatUnits((b.extensions?.balance as bigint) ?? 0n, b.decimals)) * - Number.parseFloat((b.extensions?.priceUSD as string) ?? '0') - - Number.parseFloat(formatUnits((a.extensions?.balance as bigint) ?? 0n, a.decimals)) * - Number.parseFloat((a.extensions?.priceUSD as string) ?? '0') - ) -} diff --git a/src/hooks/useWalletStatus.test.ts b/src/hooks/useWalletStatus.test.ts deleted file mode 100644 index ce5bd0fd..00000000 --- a/src/hooks/useWalletStatus.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useWalletStatus } from './useWalletStatus' - -// Mock useWeb3Status -const mockSwitchChain = vi.fn() -const mockDisconnect = vi.fn() - -vi.mock('@/src/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(() => ({ - appChainId: 1, - isWalletConnected: false, - isWalletSynced: false, - switchChain: mockSwitchChain, - walletChainId: undefined, - })), -})) - -vi.mock('@/src/lib/networks.config', () => ({ - chains: [ - { id: 1, name: 'Ethereum' }, - { id: 10, name: 'OP Mainnet' }, - { id: 137, name: 'Polygon' }, - ], -})) - -vi.mock('viem', async () => { - const actual = await vi.importActual('viem') - return { - ...actual, - extractChain: vi.fn(({ chains, id }) => { - const chain = chains.find((c: { id: number }) => c.id === id) - if (!chain) { - throw new Error(`Chain with id ${id} not found`) - } - return chain - }), - } -}) - -// Import after mocks are set up -const { useWeb3Status } = await import('@/src/hooks/useWeb3Status') -const mockedUseWeb3Status = vi.mocked(useWeb3Status) - -const baseWeb3Status: ReturnType = { - readOnlyClient: undefined, - appChainId: 1, - address: undefined, - balance: undefined, - connectingWallet: false, - switchingChain: false, - isWalletConnected: false, - walletClient: undefined, - isWalletSynced: false, - walletChainId: undefined, - switchChain: mockSwitchChain, - disconnect: mockDisconnect, -} - -describe('useWalletStatus', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns needsConnect when wallet is not connected', () => { - mockedUseWeb3Status.mockReturnValue({ - ...baseWeb3Status, - appChainId: 1, - isWalletConnected: false, - isWalletSynced: false, - walletChainId: undefined, - }) - - const { result } = renderHook(() => useWalletStatus()) - - expect(result.current.needsConnect).toBe(true) - expect(result.current.needsChainSwitch).toBe(false) - expect(result.current.isReady).toBe(false) - }) - - it('returns needsChainSwitch when connected but on wrong chain', () => { - mockedUseWeb3Status.mockReturnValue({ - ...baseWeb3Status, - appChainId: 1, - isWalletConnected: true, - isWalletSynced: false, - walletChainId: 137, - }) - - const { result } = renderHook(() => useWalletStatus()) - - expect(result.current.needsConnect).toBe(false) - expect(result.current.needsChainSwitch).toBe(true) - expect(result.current.isReady).toBe(false) - expect(result.current.targetChain).toEqual({ id: 1, name: 'Ethereum' }) - expect(result.current.targetChainId).toBe(1) - }) - - it('returns isReady when connected and on correct chain', () => { - mockedUseWeb3Status.mockReturnValue({ - ...baseWeb3Status, - appChainId: 1, - isWalletConnected: true, - isWalletSynced: true, - walletChainId: 1, - }) - - const { result } = renderHook(() => useWalletStatus()) - - expect(result.current.needsConnect).toBe(false) - expect(result.current.needsChainSwitch).toBe(false) - expect(result.current.isReady).toBe(true) - }) - - it('uses provided chainId over appChainId', () => { - mockedUseWeb3Status.mockReturnValue({ - ...baseWeb3Status, - appChainId: 1, - isWalletConnected: true, - isWalletSynced: true, - walletChainId: 1, - }) - - const { result } = renderHook(() => useWalletStatus({ chainId: 10 })) - - expect(result.current.needsChainSwitch).toBe(true) - expect(result.current.isReady).toBe(false) - expect(result.current.targetChain).toEqual({ id: 10, name: 'OP Mainnet' }) - expect(result.current.targetChainId).toBe(10) - }) - - it('falls back to chains[0].id when no chainId or appChainId', () => { - mockedUseWeb3Status.mockReturnValue({ - ...baseWeb3Status, - appChainId: undefined as unknown as ReturnType['appChainId'], - isWalletConnected: true, - isWalletSynced: false, - walletChainId: 137, - }) - - const { result } = renderHook(() => useWalletStatus()) - - expect(result.current.targetChain).toEqual({ id: 1, name: 'Ethereum' }) - }) - - it('switchChain calls through to useWeb3Status switchChain', () => { - mockedUseWeb3Status.mockReturnValue({ - ...baseWeb3Status, - appChainId: 1, - isWalletConnected: true, - isWalletSynced: false, - walletChainId: 137, - }) - - const { result } = renderHook(() => useWalletStatus()) - - result.current.switchChain(10) - expect(mockSwitchChain).toHaveBeenCalledWith(10) - }) -}) diff --git a/src/hooks/useWalletStatus.ts b/src/hooks/useWalletStatus.ts deleted file mode 100644 index 973be211..00000000 --- a/src/hooks/useWalletStatus.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Chain } from 'viem' -import { extractChain } from 'viem' - -import { useWeb3Status } from '@/src/hooks/useWeb3Status' -import { type ChainsIds, chains } from '@/src/lib/networks.config' - -interface UseWalletStatusOptions { - chainId?: ChainsIds -} - -interface WalletStatus { - isReady: boolean - needsConnect: boolean - needsChainSwitch: boolean - targetChain: Chain - targetChainId: ChainsIds - switchChain: (chainId: ChainsIds) => void -} - -/** @deprecated Use {@link useWallet} from `@/src/sdk/react/hooks` instead. */ -export const useWalletStatus = (options?: UseWalletStatusOptions): WalletStatus => { - const { appChainId, isWalletConnected, isWalletSynced, switchChain, walletChainId } = - useWeb3Status() - - const targetChainId = options?.chainId || appChainId || chains[0].id - const targetChain = extractChain({ chains, id: targetChainId }) - - const needsConnect = !isWalletConnected - const needsChainSwitch = isWalletConnected && (!isWalletSynced || walletChainId !== targetChainId) - const isReady = isWalletConnected && !needsChainSwitch - - return { - isReady, - needsConnect, - needsChainSwitch, - targetChain, - targetChainId, - switchChain, - } -} diff --git a/src/hooks/useWeb3Status.test.ts b/src/hooks/useWeb3Status.test.ts deleted file mode 100644 index 3fe34ca3..00000000 --- a/src/hooks/useWeb3Status.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { renderHook } from '@testing-library/react' -import { createElement } from 'react' -import type { Address } from 'viem' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useWeb3StatusConnected } from '@/src/components/sharedComponents/WalletStatusVerifier' -import { useWeb3Status } from './useWeb3Status' - -const mockDisconnect = vi.fn() -const mockSwitchChain = vi.fn() - -vi.mock('wagmi', () => ({ - useAccount: vi.fn(() => ({ - address: undefined, - chainId: undefined, - isConnected: false, - isConnecting: false, - })), - useChainId: vi.fn(() => 1), - useSwitchChain: vi.fn(() => ({ isPending: false, switchChain: mockSwitchChain })), - usePublicClient: vi.fn(() => undefined), - useWalletClient: vi.fn(() => ({ data: undefined })), - useBalance: vi.fn(() => ({ data: undefined })), - useDisconnect: vi.fn(() => ({ disconnect: mockDisconnect })), -})) - -vi.mock('@/src/hooks/useWalletStatus', () => ({ - useWalletStatus: vi.fn(() => ({ - isReady: false, - needsConnect: true, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' }, - targetChainId: 1, - switchChain: vi.fn(), - })), -})) - -vi.mock('@/src/providers/Web3Provider', () => ({ - ConnectWalletButton: () => - createElement('button', { type: 'button', 'data-testid': 'connect-wallet-button' }, 'Connect'), -})) - -import * as wagmi from 'wagmi' -import { WalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier' - -const { useWalletStatus } = await import('@/src/hooks/useWalletStatus') -const mockedUseWalletStatus = vi.mocked(useWalletStatus) - -type MockAccount = ReturnType -type MockSwitchChain = ReturnType - -describe('useWeb3Status', () => { - beforeEach(() => { - mockDisconnect.mockClear() - mockSwitchChain.mockClear() - }) - - it('returns disconnected state when no wallet connected', () => { - const { result } = renderHook(() => useWeb3Status()) - expect(result.current.isWalletConnected).toBe(false) - expect(result.current.address).toBeUndefined() - expect(result.current.walletChainId).toBeUndefined() - }) - - it('returns connected state with wallet address', () => { - const mock = { - address: '0xabc123' as Address, - chainId: 1, - isConnected: true, - isConnecting: false, - } as unknown as MockAccount - vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock) - const { result } = renderHook(() => useWeb3Status()) - expect(result.current.isWalletConnected).toBe(true) - expect(result.current.address).toBe('0xabc123') - }) - - it('sets isWalletSynced true when wallet chainId matches app chainId', () => { - const mock = { - address: '0xabc123' as Address, - chainId: 1, - isConnected: true, - isConnecting: false, - } as unknown as MockAccount - vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock) - vi.mocked(wagmi.useChainId).mockReturnValueOnce(1) - const { result } = renderHook(() => useWeb3Status()) - expect(result.current.isWalletSynced).toBe(true) - }) - - it('sets isWalletSynced false when wallet chainId differs from app chainId', () => { - const mock = { - address: '0xabc123' as Address, - chainId: 137, - isConnected: true, - isConnecting: false, - } as unknown as MockAccount - vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock) - vi.mocked(wagmi.useChainId).mockReturnValueOnce(1) - const { result } = renderHook(() => useWeb3Status()) - expect(result.current.isWalletSynced).toBe(false) - }) - - it('sets switchingChain when useSwitchChain is pending', () => { - const mock = { isPending: true, switchChain: mockSwitchChain } as unknown as MockSwitchChain - vi.mocked(wagmi.useSwitchChain).mockReturnValueOnce(mock) - const { result } = renderHook(() => useWeb3Status()) - expect(result.current.switchingChain).toBe(true) - }) - - it('exposes disconnect function', () => { - const { result } = renderHook(() => useWeb3Status()) - result.current.disconnect() - expect(mockDisconnect).toHaveBeenCalled() - }) - - it('calls switchChain with chainId when switchChain action is invoked', () => { - const { result } = renderHook(() => useWeb3Status()) - result.current.switchChain(137) - expect(mockSwitchChain).toHaveBeenCalledWith({ chainId: 137 }) - }) - - it('exposes appChainId from useChainId', () => { - vi.mocked(wagmi.useChainId).mockReturnValueOnce(42161) - const { result } = renderHook(() => useWeb3Status()) - expect(result.current.appChainId).toBe(42161) - }) -}) - -describe('useWeb3StatusConnected', () => { - it('throws when wallet is not connected', () => { - expect(() => renderHook(() => useWeb3StatusConnected())).toThrow( - 'useWeb3StatusConnected must be used inside a component.', - ) - }) - - it('returns status when wallet is connected', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: true, - needsConnect: false, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, - switchChain: vi.fn(), - }) - - vi.mocked(wagmi.useAccount).mockReturnValueOnce({ - address: '0xdeadbeef' as Address, - chainId: 1, - isConnected: true, - isConnecting: false, - } as unknown as ReturnType) - - const wrapper = ({ children }: { children: React.ReactNode }) => - createElement(WalletStatusVerifier, null, children) - - const { result } = renderHook(() => useWeb3StatusConnected(), { wrapper }) - expect(result.current.address).toBe('0xdeadbeef') - expect(result.current.isWalletConnected).toBe(true) - }) -}) diff --git a/src/hooks/useWeb3Status.tsx b/src/hooks/useWeb3Status.tsx deleted file mode 100644 index 50808d0b..00000000 --- a/src/hooks/useWeb3Status.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { Address, Chain } from 'viem' -import { - type UseBalanceReturnType, - type UsePublicClientReturnType, - type UseWalletClientReturnType, - useAccount, - useBalance, - useChainId, - useDisconnect, - usePublicClient, - useSwitchChain, - useWalletClient, -} from 'wagmi' - -import { type ChainsIds, chains } from '@/src/lib/networks.config' - -export type AppWeb3Status = { - readOnlyClient: UsePublicClientReturnType - appChainId: ChainsIds -} - -export type WalletWeb3Status = { - address: Address | undefined - balance?: UseBalanceReturnType['data'] | undefined - connectingWallet: boolean - switchingChain: boolean - isWalletConnected: boolean - walletClient: UseWalletClientReturnType['data'] - isWalletSynced: boolean - walletChainId: Chain['id'] | undefined -} - -export type Web3Actions = { - switchChain: (chainId?: ChainsIds) => void - disconnect: () => void -} - -export type Web3Status = AppWeb3Status & WalletWeb3Status & Web3Actions - -/** - * Custom hook that provides comprehensive Web3 connection state and actions. - * - * Aggregates various Wagmi hooks to provide a unified interface for Web3 state management, - * including wallet connection status, chain information, and common actions. - * - * The hook provides three categories of data: - * - App Web3 Status: Information about the app's current blockchain context - * - Wallet Web3 Status: Information about the connected wallet - * - Web3 Actions: Functions to modify connection state - * - * @returns {Web3Status} Combined object containing: - * @returns {UsePublicClientReturnType} returns.readOnlyClient - Public client for read operations - * @returns {ChainsIds} returns.appChainId - Current chain ID of the application - * @returns {Address|undefined} returns.address - Connected wallet address (if any) - * @returns {UseBalanceReturnType['data']|undefined} returns.balance - Wallet balance information - * @returns {boolean} returns.connectingWallet - Indicates if wallet connection is in progress - * @returns {boolean} returns.switchingChain - Indicates if chain switching is in progress - * @returns {boolean} returns.isWalletConnected - Whether a wallet is currently connected - * @returns {UseWalletClientReturnType['data']} returns.walletClient - Wallet client for write operations - * @returns {boolean} returns.isWalletSynced - Whether wallet chain matches app chain - * @returns {Chain['id']|undefined} returns.walletChainId - Current chain ID of connected wallet - * @returns {Function} returns.switchChain - Function to switch to a different chain - * @returns {Function} returns.disconnect - Function to disconnect wallet - * - * @deprecated Use {@link useWallet} or `useChainRegistry` from `@/src/sdk/react/hooks` instead. - * - * @example - * ```tsx - * const { - * address, - * balance, - * isWalletConnected, - * appChainId, - * switchChain, - * disconnect - * } = useWeb3Status(); - * - * return ( - *
- * {isWalletConnected ? ( - * <> - *

Connected to: {address}

- *

Balance: {balance?.formatted} {balance?.symbol}

- * - * - * - * ) : ( - *

Wallet not connected

- * )} - *
- * ); - * ``` - */ -export const useWeb3Status = () => { - const { - address, - chainId: walletChainId, - isConnected: isWalletConnected, - isConnecting: connectingWallet, - } = useAccount() - const appChainId = useChainId() as ChainsIds - const { isPending: switchingChain, switchChain } = useSwitchChain() - const readOnlyClient = usePublicClient() - const { data: walletClient } = useWalletClient() - const { data: balance } = useBalance() - const { disconnect } = useDisconnect() - - const isWalletSynced = isWalletConnected && walletChainId === appChainId - - const appWeb3Status: AppWeb3Status = { - readOnlyClient, - appChainId, - } - - const walletWeb3Status: WalletWeb3Status = { - address, - balance, - isWalletConnected, - connectingWallet, - switchingChain, - walletClient, - isWalletSynced, - walletChainId, - } - - const web3Actions: Web3Actions = { - switchChain: (chainId: number = chains[0].id) => switchChain({ chainId }), // default to the first chain in the config - disconnect: disconnect, - } - - const web3Connection: Web3Status = { - ...appWeb3Status, - ...walletWeb3Status, - ...web3Actions, - } - - return web3Connection -} diff --git a/src/main.tsx b/src/main.tsx index a246ed66..c15a44ca 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,13 @@ import { createRouter, RouterProvider } from '@tanstack/react-router' import { StrictMode } from 'react' import ReactDOM from 'react-dom/client' +// BigInt is not serializable by JSON.stringify. React 19 dev mode tries to serialize +// component props for dev tools logging, which crashes when props contain BigInt values +// (e.g., TransactionParams with value: parseEther('0.1')). This polyfill prevents the crash. +;(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () { + return this.toString() +} + import NotFound404 from '@/src/components/pageComponents/NotFound404' import { routeTree } from '@/src/routeTree.gen' import { printAppInfo } from '@/src/utils/printAppInfo' diff --git a/src/providers/TransactionNotificationProvider.tsx b/src/providers/TransactionNotificationProvider.tsx deleted file mode 100644 index debaa4b5..00000000 --- a/src/providers/TransactionNotificationProvider.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext } from 'react' -import type { - Hash, - ReplacementReturnType, - SignMessageErrorType, - TransactionExecutionError, -} from 'viem' -import { ExplorerLink } from '@/src/components/sharedComponents/ExplorerLink' -import { - NotificationToast, - notificationToaster, -} from '@/src/components/sharedComponents/NotificationToast' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' - -type WatchSignatureArgs = { - successMessage?: string - message: ReactNode | string - signaturePromise: Promise - onToastId?: (toastId: string) => void - showSuccessToast?: boolean -} - -type WatchHashArgs = { - message?: string - successMessage?: string - errorMessage?: string - hash: Hash - toastId?: string -} - -type WatchTxArgs = { txPromise: Promise; methodId?: string } - -type TransactionContextValue = { - watchSignature: (args: WatchSignatureArgs) => void - watchHash: (args: WatchHashArgs) => void - watchTx: (args: WatchTxArgs) => void -} - -const TransactionContext = createContext(undefined) - -/** - * Provider component for transaction notifications - * - * Manages transaction-related notifications including signature requests, - * transaction submissions, and transaction confirmations. - * - * Provides context with methods for: - * - watchSignature: Tracks a signature request and displays appropriate notifications - * - watchHash: Monitors a transaction by hash and shows its progress/outcome - * - watchTx: Combines signature and transaction monitoring in one method - * - * @example - * ```tsx - * - * - * - * ``` - */ -export const TransactionNotificationProvider: FC = ({ children }) => { - const { readOnlyClient } = useWeb3Status() - const chain = readOnlyClient?.chain - - async function watchSignature({ - message, - onToastId, - showSuccessToast = true, - signaturePromise, - successMessage = 'Signature received!', - }: WatchSignatureArgs) { - const toastId = notificationToaster.create({ - description: message, - type: 'loading', - }) - onToastId?.(toastId) - - try { - await signaturePromise - if (showSuccessToast) { - notificationToaster.create({ - description: successMessage, - type: 'success', - id: toastId, - }) - } - } catch (e) { - const error = e as TransactionExecutionError | SignMessageErrorType - const message = - 'shortMessage' in error ? error.shortMessage : error.message || 'An error occurred' - - notificationToaster.create({ - description: message, - type: 'success', - id: toastId, - }) - } - } - - async function watchHash({ - errorMessage = 'Transaction was reverted!', - hash, - message = 'Transaction sent', - successMessage = 'Transaction has been mined!', - toastId, - }: WatchHashArgs) { - if (!chain) { - console.error('Chain is not defined') - return - } - - if (!readOnlyClient) { - console.error('ReadOnlyClient is not defined') - return - } - - notificationToaster.create({ - description: message, - type: 'loading', - id: toastId, - }) - - try { - let replacedTx = null as ReplacementReturnType | null - const receipt = await readOnlyClient.waitForTransactionReceipt({ - hash, - onReplaced: (replacedTxData) => { - replacedTx = replacedTxData - }, - }) - - if (replacedTx !== null) { - if (['replaced', 'cancelled'].includes(replacedTx.reason)) { - notificationToaster.create({ - description: ( -
-
Transaction has been {replacedTx.reason}!
- -
- ), - type: 'error', - id: toastId, - }) - } else { - notificationToaster.create({ - description: ( -
-
{successMessage}
- -
- ), - type: 'success', - id: toastId, - }) - } - return - } - - if (receipt.status === 'success') { - notificationToaster.create({ - description: ( -
-
{successMessage}
- -
- ), - type: 'success', - id: toastId, - }) - } else { - notificationToaster.create({ - description: ( -
-
{errorMessage}
- -
- ), - type: 'error', - id: toastId, - }) - } - } catch (error) { - console.error('Error watching hash', error) - } - } - - async function watchTx({ methodId, txPromise }: WatchTxArgs) { - const transactionMessage = methodId ? `Transaction for calling ${methodId}` : 'Transaction' - - let toastId = '' - await watchSignature({ - message: `Signature requested: ${transactionMessage}`, - signaturePromise: txPromise, - showSuccessToast: false, - onToastId: (id) => { - toastId = id - }, - }) - - const hash = await txPromise - await watchHash({ - hash, - toastId, - message: `${transactionMessage} is pending to be mined ...`, - successMessage: `${transactionMessage} has been mined!`, - errorMessage: `${transactionMessage} has reverted!`, - }) - } - - return ( - - {children} - - - ) -} - -export function useTransactionNotification() { - const context = useContext(TransactionContext) - - if (context === undefined) { - throw new Error( - 'useTransactionNotification must be used within a TransactionNotificationProvider', - ) - } - return context -} diff --git a/src/providers/Web3Provider.tsx b/src/providers/Web3Provider.tsx deleted file mode 100644 index 98401513..00000000 --- a/src/providers/Web3Provider.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import type { FC, PropsWithChildren } from 'react' -import { WagmiProvider } from 'wagmi' - -import '@/src/lib/wallets/portoInit' -import { ConnectWalletButton, config, WalletProvider } from '@/src/lib/wallets/connectkit.config' - -const queryClient = new QueryClient() - -export { ConnectWalletButton } - -/** - * Provider component for web3 functionality - * - * Sets up the necessary providers for blockchain interactions: - * - WagmiProvider for blockchain connectivity - * - QueryClientProvider for data fetching - * - WalletProvider for wallet connection - * - * @example - * ```tsx - * - * - * - * ``` - */ -export const Web3Provider: FC = ({ children }) => { - return ( - - - {children} - - - ) -} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index bce42a4e..3feec0cb 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,15 +1,21 @@ import { Footer, Header, + NotificationToast, + notificationToaster, Provider, TanStackReactQueryDevtools, TanStackRouterDevtools, Toaster, } from '@/src/core/components' import { chains, transports } from '@/src/core/types' -import { createEvmTransactionAdapter, createEvmWalletAdapter } from '@/src/sdk/core/evm' -import { DAppBoosterProvider } from '@/src/sdk/react' -import { TransactionNotificationProvider } from '@/src/transactions/providers' +import { createEvmTransactionAdapter } from '@/src/sdk/core/evm' +import { + createEvmWalletBundle, + createNotificationLifecycle, + createSigningNotificationLifecycle, + DAppBoosterProvider, +} from '@/src/sdk/react' import { connector, config as wagmiConfig } from '@/src/wallet/connectors/wagmi.config' import '@/src/wallet/connectors/portoInit' import { Flex } from '@chakra-ui/react' @@ -19,7 +25,7 @@ import type { Chain } from 'viem' const evmChains: Chain[] = [...chains] -const evmWalletBundle = createEvmWalletAdapter({ +const evmWalletBundle = createEvmWalletBundle({ connector, chains: evmChains, transports, @@ -31,9 +37,19 @@ const evmTransactionAdapter = createEvmTransactionAdapter({ transports, }) +const notificationLifecycle = createNotificationLifecycle({ + toaster: notificationToaster, +}) + +const signingLifecycle = createSigningNotificationLifecycle({ + toaster: notificationToaster, +}) + const dappboosterConfig = { wallets: { evm: evmWalletBundle }, transactions: { evm: evmTransactionAdapter }, + lifecycle: notificationLifecycle, + walletLifecycle: signingLifecycle, } export const Route = createRootRoute({ @@ -44,26 +60,25 @@ function Root() { return ( - + +
-
- - - -