From 91600afdcd189fa38ef9dc248b26013ec8787abb Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:07:50 +0200 Subject: [PATCH 01/16] refactor: reorganize src into domain folders with barrel imports --- .gitignore | 16 +- .install-files/home/Examples/index.tsx | 2 +- index.html | 34 +- package.json | 8 +- pnpm-lock.yaml | 4 +- setupTests.ts | 17 +- .../pageComponents/NotFound404.test.tsx | 3 +- src/components/pageComponents/NotFound404.tsx | 3 +- .../home/Examples/Item/index.tsx | 2 +- .../demos/ConnectWallet/index.test.tsx | 2 +- .../Examples/demos/ConnectWallet/index.tsx | 2 +- .../Examples/demos/EnsName/index.test.tsx | 3 +- .../home/Examples/demos/EnsName/index.tsx | 2 +- .../home/Examples/demos/HashHandling/Hash.tsx | 5 +- .../demos/HashHandling/index.test.tsx | 4 +- .../Examples/demos/HashHandling/index.tsx | 7 +- .../OptimismCrossDomainMessenger/index.tsx | 16 +- .../Examples/demos/SignMessage/index.test.tsx | 4 +- .../home/Examples/demos/SignMessage/index.tsx | 6 +- .../demos/SwitchNetwork/index.test.tsx | 4 +- .../Examples/demos/SwitchNetwork/index.tsx | 7 +- .../demos/TokenDropdown/index.test.tsx | 4 +- .../Examples/demos/TokenDropdown/index.tsx | 4 +- .../home/Examples/demos/TokenInput/index.tsx | 12 +- .../ERC20ApproveAndTransferButton.tsx | 12 +- .../MintUSDC.tsx | 8 +- .../ERC20ApproveAndTransferButton/index.tsx | 9 +- .../demos/TransactionButton/NativeToken.tsx | 7 +- .../demos/TransactionButton/index.test.tsx | 36 +- .../demos/TransactionButton/index.tsx | 2 +- .../demos/subgraphs/Subgraph/index.tsx | 10 +- .../demos/subgraphs/SubgraphStatus/index.tsx | 4 +- .../pageComponents/home/Examples/index.tsx | 2 +- .../pageComponents/home/Welcome/index.tsx | 2 +- .../ui/Header/MobileMenu/MobileMenu.tsx | 2 +- .../sharedComponents/ui/Header/index.tsx | 2 +- src/contracts/abis/AAVEWeth.ts | 132 +++++ src/contracts/abis/AaveFaucet.ts | 99 ++++ src/contracts/abis/ENSRegistry.ts | 202 +++++++ .../abis/OPL1CrossDomainMessengerProxy.ts | 13 + src/contracts/definitions.ts | 142 +++++ .../hooks/useOPL1CrossDomainMessengerProxy.ts | 185 ++++++ src/contracts/wagmi/config.ts | 14 + .../wagmi/plugins/reactSuspenseRead.ts | 116 ++++ src/core/components.ts | 31 ++ src/core/config/common.ts | 11 + src/core/config/networks.config.ts | 27 + src/core/hooks.ts | 1 + src/core/hooks/useNetworkBlockNumber.ts | 51 ++ src/core/types.ts | 3 + src/core/types/utils.ts | 1 + src/core/ui/Avatar.test.tsx | 94 ++++ src/core/ui/Avatar.tsx | 79 +++ src/core/ui/BigNumberInput.test.tsx | 24 + src/core/ui/BigNumberInput.tsx | 148 +++++ src/core/ui/Button.tsx | 44 ++ src/core/ui/CopyButton/index.tsx | 106 ++++ src/core/ui/CopyButton/styles.ts | 12 + src/core/ui/DropdownButton.tsx | 46 ++ src/core/ui/ExplorerLink.test.tsx | 91 +++ src/core/ui/ExplorerLink.tsx | 44 ++ src/core/ui/ExternalLink/index.tsx | 84 +++ src/core/ui/ExternalLink/styles.ts | 12 + src/core/ui/Footer/LogoMini.tsx | 52 ++ src/core/ui/Footer/Socials/assets/Github.tsx | 26 + .../ui/Footer/Socials/assets/LinkedIn.tsx | 28 + .../ui/Footer/Socials/assets/Telegram.tsx | 26 + src/core/ui/Footer/Socials/assets/Twitter.tsx | 28 + src/core/ui/Footer/Socials/index.tsx | 49 ++ src/core/ui/Footer/index.tsx | 59 ++ src/core/ui/Footer/styles.ts | 14 + src/core/ui/GeneralMessage/index.tsx | 136 +++++ src/core/ui/GeneralMessage/styles.ts | 20 + src/core/ui/Hash.test.tsx | 112 ++++ src/core/ui/Hash.tsx | 69 +++ src/core/ui/HashInput.test.tsx | 28 + src/core/ui/HashInput.tsx | 111 ++++ src/core/ui/Header/Logo.tsx | 33 ++ src/core/ui/Header/MainMenu.tsx | 98 ++++ src/core/ui/Header/MobileMenu/MobileMenu.tsx | 140 +++++ src/core/ui/Header/MobileMenu/styles.ts | 12 + src/core/ui/Header/index.tsx | 69 +++ src/core/ui/Header/styles.ts | 10 + src/core/ui/Inner.tsx | 17 + src/core/ui/Menu/index.tsx | 50 ++ src/core/ui/Menu/styles.ts | 32 ++ src/core/ui/Modal/index.tsx | 107 ++++ src/core/ui/Modal/styles.ts | 18 + src/core/ui/NotificationToast.tsx | 51 ++ src/core/ui/PrimaryButton/index.tsx | 27 + src/core/ui/PrimaryButton/styles.ts | 26 + src/core/ui/SecondaryButton/index.tsx | 27 + src/core/ui/SecondaryButton/styles.ts | 26 + src/core/ui/Spinner/index.tsx | 16 + src/core/ui/Spinner/styles.ts | 10 + src/core/ui/SwitchThemeButton/assets/Dark.tsx | 35 ++ .../ui/SwitchThemeButton/assets/Light.tsx | 94 ++++ src/core/ui/SwitchThemeButton/index.tsx | 123 ++++ src/core/ui/SwitchThemeButton/styles.ts | 12 + src/core/ui/chakra/color-mode.tsx | 16 + src/core/ui/chakra/provider.tsx | 134 +++++ src/core/ui/chakra/toaster.tsx | 36 ++ src/core/ui/chakra/tooltip.tsx | 50 ++ .../ui/dev/TanStackReactQueryDevtools.tsx | 15 + src/core/ui/dev/TanStackRouterDevtools.tsx | 15 + src/core/utils.ts | 13 + src/core/utils/DeveloperError.ts | 7 + src/core/utils/address.ts | 21 + src/core/utils/getExplorerLink.ts | 58 ++ src/core/utils/getTransactionOutputs.test.ts | 175 ++++++ src/core/utils/getTransactionOutputs.ts | 134 +++++ src/core/utils/hash.test.ts | 87 +++ src/core/utils/hash.ts | 251 +++++++++ src/core/utils/logger.ts | 9 + src/core/utils/numberFormat.test.ts | 160 ++++++ src/core/utils/numberFormat.ts | 525 ++++++++++++++++++ src/core/utils/printAppInfo.ts | 20 + src/core/utils/strings.ts | 81 +++ src/core/utils/suspenseWrapper.tsx | 208 +++++++ src/data/adapters/subgraph/codegen.ts | 48 ++ .../subgraph/queries/aave/reserves.ts | 16 + src/data/adapters/subgraph/queries/readme.txt | 1 + .../subgraph/queries/uniswap/pools.ts | 13 + src/data/types.ts | 1 + src/hooks/useWalletStatus.ts | 1 + src/hooks/useWeb3Status.tsx | 2 + src/lib/wallets/web3modal.config.tsx | 4 + src/tokens/components.ts | 4 + src/tokens/components/TokenDropdown.tsx | 116 ++++ .../components/TokenInput/Components.tsx | 290 ++++++++++ src/tokens/components/TokenInput/index.tsx | 250 +++++++++ src/tokens/components/TokenInput/styles.ts | 70 +++ .../components/TokenInput/useTokenInput.tsx | 81 +++ src/tokens/components/TokenLogo.tsx | 116 ++++ .../TokenSelect/List/AddERC20TokenButton.tsx | 78 +++ .../components/TokenSelect/List/Row.tsx | 124 +++++ .../TokenSelect/List/TokenBalance.tsx | 71 +++ .../TokenSelect/List/VirtualizedList.tsx | 59 ++ .../components/TokenSelect/List/index.tsx | 86 +++ .../components/TokenSelect/Search/Input.tsx | 94 ++++ .../TokenSelect/Search/NetworkButton.tsx | 63 +++ .../components/TokenSelect/Search/index.tsx | 89 +++ .../components/TokenSelect/TopTokens/Item.tsx | 72 +++ .../TokenSelect/TopTokens/index.tsx | 55 ++ src/tokens/components/TokenSelect/index.tsx | 185 ++++++ src/tokens/components/TokenSelect/styles.ts | 70 +++ src/tokens/components/TokenSelect/types.ts | 8 + src/tokens/components/TokenSelect/utils.tsx | 54 ++ src/tokens/config/tokenLists.ts | 9 + src/tokens/hooks.ts | 6 + src/tokens/hooks/useErc20Balance.ts | 61 ++ src/tokens/hooks/useTokenLists.ts | 189 +++++++ src/tokens/hooks/useTokenSearch.ts | 85 +++ src/tokens/hooks/useTokens.ts | 241 ++++++++ src/tokens/types.ts | 18 + src/tokens/types/index.ts | 86 +++ src/tokens/utils/tokenListsCache.ts | 55 ++ src/transactions/components.ts | 3 + src/transactions/providers.ts | 4 + src/wallet/components.ts | 4 + src/wallet/components/ConnectButton/index.tsx | 82 +++ src/wallet/components/ConnectButton/styles.ts | 26 + src/wallet/components/SwitchChainButton.tsx | 14 + src/wallet/components/SwitchNetwork.tsx | 129 +++++ src/wallet/connectors/connectkit.config.tsx | 100 ++++ src/wallet/connectors/portoInit.ts | 10 + src/wallet/connectors/rainbowkit.config.tsx | 43 ++ src/wallet/connectors/reown.config.tsx | 59 ++ src/wallet/hooks.ts | 2 + src/wallet/hooks/useWalletStatus.test.ts | 160 ++++++ src/wallet/hooks/useWalletStatus.ts | 40 ++ src/wallet/hooks/useWeb3Status.tsx | 138 +++++ src/wallet/providers.ts | 1 + src/wallet/providers/Web3Provider.tsx | 35 ++ src/wallet/types.ts | 7 + typedoc.json | 18 +- 176 files changed, 9480 insertions(+), 151 deletions(-) create mode 100644 src/contracts/abis/AAVEWeth.ts create mode 100644 src/contracts/abis/AaveFaucet.ts create mode 100644 src/contracts/abis/ENSRegistry.ts create mode 100644 src/contracts/abis/OPL1CrossDomainMessengerProxy.ts create mode 100644 src/contracts/definitions.ts create mode 100644 src/contracts/hooks/useOPL1CrossDomainMessengerProxy.ts create mode 100644 src/contracts/wagmi/config.ts create mode 100644 src/contracts/wagmi/plugins/reactSuspenseRead.ts create mode 100644 src/core/components.ts create mode 100644 src/core/config/common.ts create mode 100644 src/core/config/networks.config.ts create mode 100644 src/core/hooks.ts create mode 100644 src/core/hooks/useNetworkBlockNumber.ts create mode 100644 src/core/types.ts create mode 100644 src/core/types/utils.ts create mode 100644 src/core/ui/Avatar.test.tsx create mode 100644 src/core/ui/Avatar.tsx create mode 100644 src/core/ui/BigNumberInput.test.tsx create mode 100644 src/core/ui/BigNumberInput.tsx create mode 100644 src/core/ui/Button.tsx create mode 100644 src/core/ui/CopyButton/index.tsx create mode 100644 src/core/ui/CopyButton/styles.ts create mode 100644 src/core/ui/DropdownButton.tsx create mode 100644 src/core/ui/ExplorerLink.test.tsx create mode 100644 src/core/ui/ExplorerLink.tsx create mode 100644 src/core/ui/ExternalLink/index.tsx create mode 100644 src/core/ui/ExternalLink/styles.ts create mode 100644 src/core/ui/Footer/LogoMini.tsx create mode 100644 src/core/ui/Footer/Socials/assets/Github.tsx create mode 100644 src/core/ui/Footer/Socials/assets/LinkedIn.tsx create mode 100644 src/core/ui/Footer/Socials/assets/Telegram.tsx create mode 100644 src/core/ui/Footer/Socials/assets/Twitter.tsx create mode 100644 src/core/ui/Footer/Socials/index.tsx create mode 100644 src/core/ui/Footer/index.tsx create mode 100644 src/core/ui/Footer/styles.ts create mode 100644 src/core/ui/GeneralMessage/index.tsx create mode 100644 src/core/ui/GeneralMessage/styles.ts create mode 100644 src/core/ui/Hash.test.tsx create mode 100644 src/core/ui/Hash.tsx create mode 100644 src/core/ui/HashInput.test.tsx create mode 100644 src/core/ui/HashInput.tsx create mode 100644 src/core/ui/Header/Logo.tsx create mode 100644 src/core/ui/Header/MainMenu.tsx create mode 100644 src/core/ui/Header/MobileMenu/MobileMenu.tsx create mode 100644 src/core/ui/Header/MobileMenu/styles.ts create mode 100644 src/core/ui/Header/index.tsx create mode 100644 src/core/ui/Header/styles.ts create mode 100644 src/core/ui/Inner.tsx create mode 100644 src/core/ui/Menu/index.tsx create mode 100644 src/core/ui/Menu/styles.ts create mode 100644 src/core/ui/Modal/index.tsx create mode 100644 src/core/ui/Modal/styles.ts create mode 100644 src/core/ui/NotificationToast.tsx create mode 100644 src/core/ui/PrimaryButton/index.tsx create mode 100644 src/core/ui/PrimaryButton/styles.ts create mode 100644 src/core/ui/SecondaryButton/index.tsx create mode 100644 src/core/ui/SecondaryButton/styles.ts create mode 100644 src/core/ui/Spinner/index.tsx create mode 100644 src/core/ui/Spinner/styles.ts create mode 100644 src/core/ui/SwitchThemeButton/assets/Dark.tsx create mode 100644 src/core/ui/SwitchThemeButton/assets/Light.tsx create mode 100644 src/core/ui/SwitchThemeButton/index.tsx create mode 100644 src/core/ui/SwitchThemeButton/styles.ts create mode 100644 src/core/ui/chakra/color-mode.tsx create mode 100644 src/core/ui/chakra/provider.tsx create mode 100644 src/core/ui/chakra/toaster.tsx create mode 100644 src/core/ui/chakra/tooltip.tsx create mode 100644 src/core/ui/dev/TanStackReactQueryDevtools.tsx create mode 100644 src/core/ui/dev/TanStackRouterDevtools.tsx create mode 100644 src/core/utils.ts create mode 100644 src/core/utils/DeveloperError.ts create mode 100644 src/core/utils/address.ts create mode 100644 src/core/utils/getExplorerLink.ts create mode 100644 src/core/utils/getTransactionOutputs.test.ts create mode 100644 src/core/utils/getTransactionOutputs.ts create mode 100644 src/core/utils/hash.test.ts create mode 100644 src/core/utils/hash.ts create mode 100644 src/core/utils/logger.ts create mode 100644 src/core/utils/numberFormat.test.ts create mode 100644 src/core/utils/numberFormat.ts create mode 100644 src/core/utils/printAppInfo.ts create mode 100644 src/core/utils/strings.ts create mode 100644 src/core/utils/suspenseWrapper.tsx create mode 100644 src/data/adapters/subgraph/codegen.ts create mode 100644 src/data/adapters/subgraph/queries/aave/reserves.ts create mode 100644 src/data/adapters/subgraph/queries/readme.txt create mode 100644 src/data/adapters/subgraph/queries/uniswap/pools.ts create mode 100644 src/data/types.ts create mode 100644 src/tokens/components.ts create mode 100644 src/tokens/components/TokenDropdown.tsx create mode 100644 src/tokens/components/TokenInput/Components.tsx create mode 100644 src/tokens/components/TokenInput/index.tsx create mode 100644 src/tokens/components/TokenInput/styles.ts create mode 100644 src/tokens/components/TokenInput/useTokenInput.tsx create mode 100644 src/tokens/components/TokenLogo.tsx create mode 100644 src/tokens/components/TokenSelect/List/AddERC20TokenButton.tsx create mode 100644 src/tokens/components/TokenSelect/List/Row.tsx create mode 100644 src/tokens/components/TokenSelect/List/TokenBalance.tsx create mode 100644 src/tokens/components/TokenSelect/List/VirtualizedList.tsx create mode 100644 src/tokens/components/TokenSelect/List/index.tsx create mode 100644 src/tokens/components/TokenSelect/Search/Input.tsx create mode 100644 src/tokens/components/TokenSelect/Search/NetworkButton.tsx create mode 100644 src/tokens/components/TokenSelect/Search/index.tsx create mode 100644 src/tokens/components/TokenSelect/TopTokens/Item.tsx create mode 100644 src/tokens/components/TokenSelect/TopTokens/index.tsx create mode 100644 src/tokens/components/TokenSelect/index.tsx create mode 100644 src/tokens/components/TokenSelect/styles.ts create mode 100644 src/tokens/components/TokenSelect/types.ts create mode 100644 src/tokens/components/TokenSelect/utils.tsx create mode 100644 src/tokens/config/tokenLists.ts create mode 100644 src/tokens/hooks.ts create mode 100644 src/tokens/hooks/useErc20Balance.ts create mode 100644 src/tokens/hooks/useTokenLists.ts create mode 100644 src/tokens/hooks/useTokenSearch.ts create mode 100644 src/tokens/hooks/useTokens.ts create mode 100644 src/tokens/types.ts create mode 100644 src/tokens/types/index.ts create mode 100644 src/tokens/utils/tokenListsCache.ts create mode 100644 src/transactions/components.ts create mode 100644 src/transactions/providers.ts create mode 100644 src/wallet/components.ts create mode 100644 src/wallet/components/ConnectButton/index.tsx create mode 100644 src/wallet/components/ConnectButton/styles.ts create mode 100644 src/wallet/components/SwitchChainButton.tsx create mode 100644 src/wallet/components/SwitchNetwork.tsx create mode 100644 src/wallet/connectors/connectkit.config.tsx create mode 100644 src/wallet/connectors/portoInit.ts create mode 100644 src/wallet/connectors/rainbowkit.config.tsx create mode 100644 src/wallet/connectors/reown.config.tsx create mode 100644 src/wallet/hooks.ts create mode 100644 src/wallet/hooks/useWalletStatus.test.ts create mode 100644 src/wallet/hooks/useWalletStatus.ts create mode 100644 src/wallet/hooks/useWeb3Status.tsx create mode 100644 src/wallet/providers.ts create mode 100644 src/wallet/providers/Web3Provider.tsx create mode 100644 src/wallet/types.ts diff --git a/.gitignore b/.gitignore index 3b156c1f..863119b3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,16 +16,12 @@ tsconfig.tsbuildinfo # Project folders typedoc coverage -src/subgraphs/gql +src/data/adapters/subgraph/gql # Project files -src/hooks/generated.ts +src/contracts/generated.ts vite.config.ts.timestamp* -# Claude Code -.claude/settings.local.json -CLAUDE.local.md - # Project .env files .env.local .env @@ -45,4 +41,10 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* -.worktrees +.vercel +.env*.local + +# Local/unversioned +.claude/ +.env.local.bkp +docs/superpowers/ diff --git a/.install-files/home/Examples/index.tsx b/.install-files/home/Examples/index.tsx index 152697dd..49c497ba 100644 --- a/.install-files/home/Examples/index.tsx +++ b/.install-files/home/Examples/index.tsx @@ -9,7 +9,7 @@ import switchNetwork from '@/src/components/pageComponents/home/Examples/demos/S import tokenDropdown from '@/src/components/pageComponents/home/Examples/demos/TokenDropdown' import tokenInput from '@/src/components/pageComponents/home/Examples/demos/TokenInput' import transactionButton from '@/src/components/pageComponents/home/Examples/demos/TransactionButton' -import { Inner } from '@/src/components/sharedComponents/ui/Inner' +import { Inner } from '@/src/core/components' import { Box, type BoxProps, Flex, Heading, Text, chakra } from '@chakra-ui/react' import type { FC } from 'react' import styles from './styles' diff --git a/index.html b/index.html index 227549e1..ebcc0889 100644 --- a/index.html +++ b/index.html @@ -31,49 +31,17 @@ - - - - - + rel="stylesheet" />
- \ No newline at end of file diff --git a/package.json b/package.json index f104bc53..05b1a926 100644 --- a/package.json +++ b/package.json @@ -18,17 +18,17 @@ "preview": "vite preview", "routes:generate": "tsr generate", "routes:watch": "tsr watch", - "subgraph-codegen": "graphql-codegen --config ./src/subgraphs/codegen.ts", + "subgraph-codegen": "graphql-codegen --config ./src/data/adapters/subgraph/codegen.ts", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", - "wagmi-generate": "wagmi generate --config src/lib/wagmi/config.ts" + "wagmi-generate": "wagmi generate --config src/contracts/wagmi/config.ts" }, "dependencies": { "@bootnodedev/db-subgraph": "^0.1.2", - "@chakra-ui/react": "^3.34.0", + "@chakra-ui/react": "^3.17.0", "@emotion/react": "^11.14.0", - "@lifi/sdk": "^3.16.3", + "@lifi/sdk": "^3.6.13", "@rainbow-me/rainbowkit": "^2.2.9", "@reown/appkit": "^1.8.19", "@reown/appkit-adapter-wagmi": "^1.8.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d35cd3f5..afce3093 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,13 +15,13 @@ importers: specifier: ^0.1.2 version: 0.1.2(@parcel/watcher@2.5.6)(@tanstack/react-query@5.96.1(react@19.2.4))(@types/node@25.3.0)(bufferutil@4.1.0)(crossws@0.3.5)(graphql-tag@2.12.6(graphql@16.13.2))(react@19.2.4)(typescript@6.0.2)(utf-8-validate@5.0.10)(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)) '@chakra-ui/react': - specifier: ^3.34.0 + specifier: ^3.17.0 version: 3.34.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@emotion/react': specifier: ^11.14.0 version: 11.14.0(@types/react@19.2.14)(react@19.2.4) '@lifi/sdk': - specifier: ^3.16.3 + specifier: ^3.6.13 version: 3.16.3(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10))(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(utf-8-validate@5.0.10)(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) '@rainbow-me/rainbowkit': specifier: ^2.2.9 diff --git a/setupTests.ts b/setupTests.ts index a9d13f77..44b373c0 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -4,18 +4,11 @@ import { afterEach, expect } from 'vitest' expect.extend(matchers) -// ResizeObserver is not implemented in jsdom but required by @floating-ui (Chakra menus/popovers). -// Use a real class rather than vi.fn() so vi.restoreAllMocks() in test files cannot clear it. -if (typeof globalThis.ResizeObserver === 'undefined') { - class ResizeObserver { - // biome-ignore lint/suspicious/noExplicitAny: stub for jsdom test environment - observe(_target: any) {} - // biome-ignore lint/suspicious/noExplicitAny: stub for jsdom test environment - unobserve(_target: any) {} - disconnect() {} - } - // @ts-expect-error ResizeObserver is not in the Node/jsdom type definitions - globalThis.ResizeObserver = ResizeObserver +// jsdom does not implement ResizeObserver; stub it so floating-ui / Chakra popper tests don't throw. +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} } afterEach(() => { diff --git a/src/components/pageComponents/NotFound404.test.tsx b/src/components/pageComponents/NotFound404.test.tsx index 6ce87039..fa6b3658 100644 --- a/src/components/pageComponents/NotFound404.test.tsx +++ b/src/components/pageComponents/NotFound404.test.tsx @@ -5,7 +5,8 @@ import NotFound404 from './NotFound404' const system = createSystem(defaultConfig) -vi.mock('@tanstack/react-router', () => ({ +vi.mock('@tanstack/react-router', async (importOriginal) => ({ + ...(await importOriginal()), useNavigate: vi.fn(() => vi.fn()), })) diff --git a/src/components/pageComponents/NotFound404.tsx b/src/components/pageComponents/NotFound404.tsx index 7bfbb871..227b4c3d 100644 --- a/src/components/pageComponents/NotFound404.tsx +++ b/src/components/pageComponents/NotFound404.tsx @@ -1,6 +1,5 @@ import { useNavigate } from '@tanstack/react-router' -import { GeneralMessage } from '@/src/components/sharedComponents/ui/GeneralMessage' -import PrimaryButton from '@/src/components/sharedComponents/ui/PrimaryButton' +import { GeneralMessage, PrimaryButton } from '@/src/core/components' const Icon = () => ( ({ +vi.mock('@/src/wallet/providers', () => ({ ConnectWalletButton: () => , })) diff --git a/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.tsx b/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.tsx index 7d2911f2..43167477 100644 --- a/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.tsx @@ -1,5 +1,5 @@ import Icon from '@/src/components/pageComponents/home/Examples/demos/ConnectWallet/Icon' -import { ConnectWalletButton } from '@/src/providers/Web3Provider' +import { ConnectWalletButton } from '@/src/wallet/providers' const connectWallet = { demo: , diff --git a/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx b/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx index 2463d875..0349520f 100644 --- a/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx @@ -5,7 +5,8 @@ import ensName from './index' const system = createSystem(defaultConfig) -vi.mock('wagmi', () => ({ +vi.mock('wagmi', async (importOriginal) => ({ + ...(await importOriginal()), useEnsName: vi.fn(() => ({ data: undefined, error: undefined, status: 'pending' })), })) diff --git a/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx b/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx index 69cf3741..90201aae 100644 --- a/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx @@ -6,7 +6,7 @@ import { useEnsName } from 'wagmi' import { mainnet } from 'wagmi/chains' import Icon from '@/src/components/pageComponents/home/Examples/demos/EnsName/Icon' import { OptionsDropdown } from '@/src/components/pageComponents/home/Examples/demos/OptionsDropdown' -import Spinner from '@/src/components/sharedComponents/ui/Spinner' +import { Spinner } from '@/src/core/components' const EnsNameSearch = ({ address }: { address?: Address }) => { const { data, error, status } = useEnsName({ diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx index 377300a9..cfe50ea5 100644 --- a/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx +++ b/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx @@ -1,9 +1,8 @@ import type { FlexProps } from '@chakra-ui/react' import type { FC } from 'react' import type { Address, Chain } from 'viem' -import BaseHash from '@/src/components/sharedComponents/Hash' -import { toaster } from '@/src/components/ui/toaster' -import { getExplorerLink } from '@/src/utils/getExplorerLink' +import { Hash as BaseHash, toaster } from '@/src/core/components' +import { getExplorerLink } from '@/src/core/utils' interface Props extends FlexProps { chain: Chain 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 90fc9c0f..5dcfd3e0 100644 --- a/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx @@ -5,14 +5,14 @@ import hashHandling from './index' const system = createSystem(defaultConfig) -vi.mock('@/src/hooks/useWeb3Status', () => ({ +vi.mock('@/src/wallet/hooks/useWeb3Status', () => ({ useWeb3Status: vi.fn(() => ({ isWalletConnected: false, walletChainId: undefined, })), })) -vi.mock('@/src/utils/hash', () => { +vi.mock('@/src/core/utils/hash', () => { const mockFn = vi.fn(() => Promise.resolve(null)) return { default: mockFn, diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx index 844da2d9..4854ba23 100644 --- a/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx @@ -5,10 +5,9 @@ import * as chains from 'viem/chains' import Hash from '@/src/components/pageComponents/home/Examples/demos/HashHandling/Hash' import Icon from '@/src/components/pageComponents/home/Examples/demos/HashHandling/Icon' import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper' -import HashInput from '@/src/components/sharedComponents/HashInput' -import Spinner from '@/src/components/sharedComponents/ui/Spinner' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' -import type { DetectedHash } from '@/src/utils/hash' +import { HashInput, Spinner } from '@/src/core/components' +import type { DetectedHash } from '@/src/core/utils' +import { useWeb3Status } from '@/src/wallet/hooks' const AlertIcon = () => ( { // https://sepolia-optimism.etherscan.io/address/0xb50201558b00496a145fe76f7424749556e326d8 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 d673002c..a39e295d 100644 --- a/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx @@ -5,7 +5,7 @@ import signMessage from './index' const system = createSystem(defaultConfig) -vi.mock('@/src/hooks/useWeb3Status', () => ({ +vi.mock('@/src/wallet/hooks/useWeb3Status', () => ({ useWeb3Status: vi.fn(() => ({ isWalletConnected: false, isWalletSynced: false, @@ -15,7 +15,7 @@ vi.mock('@/src/hooks/useWeb3Status', () => ({ })), })) -vi.mock('@/src/providers/Web3Provider', () => ({ +vi.mock('@/src/wallet/providers', () => ({ ConnectWalletButton: () => , })) diff --git a/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx b/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx index b3bbff9b..a2c0ff95 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 Icon from '@/src/components/pageComponents/home/Examples/demos/SignMessage/Icon' import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper' -import SignButton from '@/src/components/sharedComponents/SignButton' -import PrimaryButton from '@/src/components/sharedComponents/ui/PrimaryButton' -import { WalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier' +import { PrimaryButton } from '@/src/core/components' +import { SignButton } from '@/src/transactions/components' +import { WalletStatusVerifier } from '@/src/wallet/components' const message = ` 👻🚀 Welcome to dAppBooster! 🚀👻 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 7b93bb7b..9f930c9f 100644 --- a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx @@ -5,13 +5,13 @@ import switchNetwork from './index' const system = createSystem(defaultConfig) -vi.mock('@/src/hooks/useWeb3Status', () => ({ +vi.mock('@/src/wallet/hooks', () => ({ useWeb3Status: vi.fn(() => ({ isWalletConnected: false, })), })) -vi.mock('@/src/providers/Web3Provider', () => ({ +vi.mock('@/src/wallet/providers', () => ({ ConnectWalletButton: () => , })) diff --git a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx index 8764854f..243f1685 100644 --- a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx @@ -6,9 +6,10 @@ import { } from '@web3icons/react' import { arbitrum, mainnet, optimism, polygon } from 'viem/chains' import Icon from '@/src/components/pageComponents/home/Examples/demos/SwitchNetwork/Icon' -import BaseSwitchNetwork, { type Networks } from '@/src/components/sharedComponents/SwitchNetwork' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' -import { ConnectWalletButton } from '@/src/providers/Web3Provider' +import { SwitchNetwork as BaseSwitchNetwork } from '@/src/wallet/components' +import { useWeb3Status } from '@/src/wallet/hooks' +import { ConnectWalletButton } from '@/src/wallet/providers' +import type { Networks } from '@/src/wallet/types' const SwitchNetwork = () => { const { isWalletConnected } = useWeb3Status() diff --git a/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx index ade29b7c..d5384652 100644 --- a/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx @@ -7,8 +7,8 @@ const system = createSystem(defaultConfig) // Mock the shared component to avoid its deep dependency chain // (TokenSelect uses withSuspenseAndRetry, useTokenLists, useTokens, etc.) -vi.mock('@/src/components/sharedComponents/TokenDropdown', () => ({ - default: () =>
Token Dropdown
, +vi.mock('@/src/tokens/components', () => ({ + TokenDropdown: () =>
Token Dropdown
, })) describe('TokenDropdown demo', () => { diff --git a/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.tsx b/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.tsx index 98da81c7..7defb5bf 100644 --- a/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.tsx @@ -1,8 +1,8 @@ import { type FC, useState } from 'react' import Icon from '@/src/components/pageComponents/home/Examples/demos/TokenDropdown/Icon' import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper' -import BaseTokenDropdown from '@/src/components/sharedComponents/TokenDropdown' -import type { Token } from '@/src/types/token' +import { TokenDropdown as BaseTokenDropdown } from '@/src/tokens/components' +import type { Token } from '@/src/tokens/types' const TokenDropdown: FC = ({ ...restProps }) => { const [currentToken, setCurrentToken] = useState() diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx index 38a3eba9..32f27b44 100644 --- a/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx @@ -9,13 +9,11 @@ import { useState } from 'react' 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 BaseTokenInput from '@/src/components/sharedComponents/TokenInput' -import { useTokenInput } from '@/src/components/sharedComponents/TokenInput/useTokenInput' -import type { Networks } from '@/src/components/sharedComponents/TokenSelect/types' -import { useTokenLists } from '@/src/hooks/useTokenLists' -import { useTokenSearch } from '@/src/hooks/useTokenSearch' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' -import { withSuspenseAndRetry } from '@/src/utils/suspenseWrapper' +import { withSuspenseAndRetry } from '@/src/core/utils' +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' 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 973ce924..b4503c1b 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx @@ -3,12 +3,12 @@ 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 TransactionButton from '@/src/components/sharedComponents/TransactionButton' -import { useWeb3StatusConnected } from '@/src/components/sharedComponents/WalletStatusVerifier' -import { useSuspenseReadErc20Allowance } from '@/src/hooks/generated' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' -import type { Token } from '@/src/types/token' -import { getExplorerLink } from '@/src/utils/getExplorerLink' +import { useSuspenseReadErc20Allowance } from '@/src/contracts/generated' +import { getExplorerLink } from '@/src/core/utils' +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' interface Props { amount: bigint 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 10c1e59f..368e6c6f 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx @@ -1,9 +1,9 @@ import { sepolia } from 'viem/chains' import { useWriteContract } from 'wagmi' -import TransactionButton from '@/src/components/sharedComponents/TransactionButton' -import { useWeb3StatusConnected } from '@/src/components/sharedComponents/WalletStatusVerifier' -import { AaveFaucetABI } from '@/src/constants/contracts/abis/AaveFaucet' -import { getContract } from '@/src/constants/contracts/contracts' +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' export default function MintUSDC({ onSuccess }: { onSuccess: () => void }) { const { address } = useWeb3StatusConnected() 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 5aabb626..786d0415 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx @@ -4,11 +4,10 @@ import { useWriteContract } from 'wagmi' 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 { useWeb3StatusConnected } from '@/src/components/sharedComponents/WalletStatusVerifier' -import { useSuspenseReadErc20BalanceOf } from '@/src/hooks/generated' -import type { Token } from '@/src/types/token' -import { formatNumberOrString, NumberType } from '@/src/utils/numberFormat' -import { withSuspense } from '@/src/utils/suspenseWrapper' +import { useSuspenseReadErc20BalanceOf } from '@/src/contracts/generated' +import { formatNumberOrString, NumberType, withSuspense } from '@/src/core/utils' +import type { Token } from '@/src/tokens/types' +import { useWeb3StatusConnected } from '@/src/wallet/components' // USDC token on Sepolia chain const tokenUSDC_sepolia: Token = { diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx index 890f4225..afabae8a 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx @@ -3,10 +3,9 @@ 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 TransactionButton from '@/src/components/sharedComponents/TransactionButton' -import { GeneralMessage } from '@/src/components/sharedComponents/ui/GeneralMessage' -import PrimaryButton from '@/src/components/sharedComponents/ui/PrimaryButton' -import { useWeb3StatusConnected } from '@/src/components/sharedComponents/WalletStatusVerifier' +import { GeneralMessage, PrimaryButton } from '@/src/core/components' +import { LegacyTransactionButton as TransactionButton } from '@/src/transactions/components' +import { useWeb3StatusConnected } from '@/src/wallet/components' /** * This demo shows how to send a native token transaction. 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 fd29ee05..346fcf8b 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx @@ -3,14 +3,46 @@ import { describe, expect, it, vi } from 'vitest' import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils' import transactionButton from './index' -vi.mock('@/src/hooks/useWeb3Status', () => ({ +vi.mock('@/src/wallet/hooks/useWeb3Status', () => ({ useWeb3Status: vi.fn(() => createMockWeb3Status({ appChainId: 11155420 })), })) -vi.mock('@/src/providers/Web3Provider', () => ({ +vi.mock('@/src/wallet/providers', () => ({ ConnectWalletButton: () => , })) +vi.mock('@/src/sdk/react/hooks', () => ({ + useWallet: vi.fn(() => ({ + needsConnect: true, + needsChainSwitch: false, + isReady: false, + status: { connected: false, connecting: false, activeAccount: null, connectedChainIds: [] }, + switchChain: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + signMessage: vi.fn(), + getSigner: vi.fn(), + })), + useTransaction: vi.fn(() => ({ + phase: 'idle', + execute: vi.fn(), + 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(() => []), + })), +})) + describe('TransactionButton demo', () => { it('renders connect wallet fallback when wallet not connected', () => { renderWithProviders(transactionButton.demo) diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx index c8233417..d5a1eb23 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx @@ -5,7 +5,7 @@ import { OptionsDropdown } from '@/src/components/pageComponents/home/Examples/d 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/components/sharedComponents/WalletStatusVerifier' +import { WalletStatusVerifier } from '@/src/wallet/components' type Options = 'erc20' | 'native' diff --git a/src/components/pageComponents/home/Examples/demos/subgraphs/Subgraph/index.tsx b/src/components/pageComponents/home/Examples/demos/subgraphs/Subgraph/index.tsx index ada82963..b93ab7d0 100644 --- a/src/components/pageComponents/home/Examples/demos/subgraphs/Subgraph/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/subgraphs/Subgraph/index.tsx @@ -15,13 +15,11 @@ import { Wrapper, } from '@/src/components/pageComponents/home/Examples/demos/subgraphs/Subgraph/Components' import Icon from '@/src/components/pageComponents/home/Examples/demos/subgraphs/Subgraph/Icon' -import CopyButton from '@/src/components/sharedComponents/ui/CopyButton' -import ExternalLink from '@/src/components/sharedComponents/ui/ExternalLink' -import { toaster } from '@/src/components/ui/toaster' +import { CopyButton, ExternalLinkButton as ExternalLink, toaster } from '@/src/core/components' +import { withSuspenseAndRetry } from '@/src/core/utils' +import { allAaveReservesQueryDocument } from '@/src/data/adapters/subgraph/queries/aave/reserves' +import { allUniswapPoolsQueryDocument } from '@/src/data/adapters/subgraph/queries/uniswap/pools' import { env } from '@/src/env' -import { allAaveReservesQueryDocument } from '@/src/subgraphs/queries/aave/reserves' -import { allUniswapPoolsQueryDocument } from '@/src/subgraphs/queries/uniswap/pools' -import { withSuspenseAndRetry } from '@/src/utils/suspenseWrapper' const chainNameMapping: { [key: number]: string } = { [arbitrum.id]: 'arbitrum', diff --git a/src/components/pageComponents/home/Examples/demos/subgraphs/SubgraphStatus/index.tsx b/src/components/pageComponents/home/Examples/demos/subgraphs/SubgraphStatus/index.tsx index e61b3766..d68fd57b 100644 --- a/src/components/pageComponents/home/Examples/demos/subgraphs/SubgraphStatus/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/subgraphs/SubgraphStatus/index.tsx @@ -12,9 +12,9 @@ import { Wrapper, } from '@/src/components/pageComponents/home/Examples/demos/subgraphs/SubgraphStatus/Components' import Icon from '@/src/components/pageComponents/home/Examples/demos/subgraphs/SubgraphStatus/Icon' -import Spinner from '@/src/components/sharedComponents/ui/Spinner' +import { Spinner } from '@/src/core/components' +import { withSuspenseAndRetry } from '@/src/core/utils' import { env } from '@/src/env' -import { withSuspenseAndRetry } from '@/src/utils/suspenseWrapper' export const SkeletonLoadingItem = () => ( = ({ css, ...restProps }) => { diff --git a/src/components/pageComponents/home/Welcome/index.tsx b/src/components/pageComponents/home/Welcome/index.tsx index dcb1c64c..0a6b6773 100644 --- a/src/components/pageComponents/home/Welcome/index.tsx +++ b/src/components/pageComponents/home/Welcome/index.tsx @@ -1,6 +1,6 @@ import { chakra, type FlexProps, Heading, Link, Span, Text } from '@chakra-ui/react' import type { FC } from 'react' -import { Inner } from '@/src/components/sharedComponents/ui/Inner' +import { Inner } from '@/src/core/components' import styles from './styles' const Arrow = () => ( diff --git a/src/components/sharedComponents/ui/Header/MobileMenu/MobileMenu.tsx b/src/components/sharedComponents/ui/Header/MobileMenu/MobileMenu.tsx index 48cf1109..368f8122 100644 --- a/src/components/sharedComponents/ui/Header/MobileMenu/MobileMenu.tsx +++ b/src/components/sharedComponents/ui/Header/MobileMenu/MobileMenu.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import Logo from '@/src/components/sharedComponents/ui/Header/Logo' import MainMenu from '@/src/components/sharedComponents/ui/Header/MainMenu' import { SwitchThemeButton } from '@/src/components/sharedComponents/ui/SwitchThemeButton' -import { ConnectWalletButton } from '@/src/providers/Web3Provider' +import { ConnectWalletButton } from '@/src/wallet/providers' import styles from './styles' const MenuIcon = () => ( diff --git a/src/components/sharedComponents/ui/Header/index.tsx b/src/components/sharedComponents/ui/Header/index.tsx index ccd79602..41397aad 100644 --- a/src/components/sharedComponents/ui/Header/index.tsx +++ b/src/components/sharedComponents/ui/Header/index.tsx @@ -7,7 +7,7 @@ import MainMenu from '@/src/components/sharedComponents/ui/Header/MainMenu' import MobileMenu from '@/src/components/sharedComponents/ui/Header/MobileMenu/MobileMenu' import { Inner } from '@/src/components/sharedComponents/ui/Inner' import { SwitchThemeButton } from '@/src/components/sharedComponents/ui/SwitchThemeButton' -import { ConnectWalletButton } from '@/src/providers/Web3Provider' +import { ConnectWalletButton } from '@/src/wallet/providers' import styles from './styles' const HomeLink = chakra(Link) diff --git a/src/contracts/abis/AAVEWeth.ts b/src/contracts/abis/AAVEWeth.ts new file mode 100644 index 00000000..fd2bd327 --- /dev/null +++ b/src/contracts/abis/AAVEWeth.ts @@ -0,0 +1,132 @@ +export const AAVEWethABI = [ + { + inputs: [ + { internalType: 'address', name: 'weth', type: 'address' }, + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'contract IPool', name: 'pool', type: 'address' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'previousOwner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'OwnershipTransferred', + type: 'event', + }, + { stateMutability: 'payable', type: 'fallback' }, + { + inputs: [ + { internalType: 'address', name: '', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'uint256', name: 'interestRateMode', type: 'uint256' }, + { internalType: 'uint16', name: 'referralCode', type: 'uint16' }, + ], + name: 'borrowETH', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '', type: 'address' }, + { internalType: 'address', name: 'onBehalfOf', type: 'address' }, + { internalType: 'uint16', name: 'referralCode', type: 'uint16' }, + ], + name: 'depositETH', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + ], + name: 'emergencyEtherTransfer', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'token', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + ], + name: 'emergencyTokenTransfer', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'getWETHAddress', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'uint256', name: 'rateMode', type: 'uint256' }, + { internalType: 'address', name: 'onBehalfOf', type: 'address' }, + ], + name: 'repayETH', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'address', name: 'to', type: 'address' }, + ], + name: 'withdrawETH', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'deadline', type: 'uint256' }, + { internalType: 'uint8', name: 'permitV', type: 'uint8' }, + { internalType: 'bytes32', name: 'permitR', type: 'bytes32' }, + { internalType: 'bytes32', name: 'permitS', type: 'bytes32' }, + ], + name: 'withdrawETHWithPermit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { stateMutability: 'payable', type: 'receive' }, +] as const diff --git a/src/contracts/abis/AaveFaucet.ts b/src/contracts/abis/AaveFaucet.ts new file mode 100644 index 00000000..d39d9b11 --- /dev/null +++ b/src/contracts/abis/AaveFaucet.ts @@ -0,0 +1,99 @@ +export const AaveFaucetABI = [ + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'bool', name: 'permissioned', type: 'bool' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'previousOwner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'OwnershipTransferred', + type: 'event', + }, + { + inputs: [], + name: 'MAX_MINT_AMOUNT', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'asset', type: 'address' }], + name: 'isMintable', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'isPermissioned', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'token', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + ], + name: 'mint', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'asset', type: 'address' }, + { internalType: 'bool', name: 'active', type: 'bool' }, + ], + name: 'setMintable', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bool', name: 'permissioned', type: 'bool' }], + name: 'setPermissioned', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address[]', name: 'childContracts', type: 'address[]' }, + { internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'transferOwnershipOfChild', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const diff --git a/src/contracts/abis/ENSRegistry.ts b/src/contracts/abis/ENSRegistry.ts new file mode 100644 index 00000000..8ed619b7 --- /dev/null +++ b/src/contracts/abis/ENSRegistry.ts @@ -0,0 +1,202 @@ +export const ENSRegistryABI = [ + { + inputs: [{ internalType: 'contract ENS', name: '_old', type: 'address' }], + payable: false, + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'owner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'operator', type: 'address' }, + { indexed: false, internalType: 'bool', name: 'approved', type: 'bool' }, + ], + name: 'ApprovalForAll', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: true, internalType: 'bytes32', name: 'label', type: 'bytes32' }, + { indexed: false, internalType: 'address', name: 'owner', type: 'address' }, + ], + name: 'NewOwner', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'address', name: 'resolver', type: 'address' }, + ], + name: 'NewResolver', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'uint64', name: 'ttl', type: 'uint64' }, + ], + name: 'NewTTL', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'address', name: 'owner', type: 'address' }, + ], + name: 'Transfer', + type: 'event', + }, + { + constant: true, + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'address', name: 'operator', type: 'address' }, + ], + name: 'isApprovedForAll', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'old', + outputs: [{ internalType: 'contract ENS', name: '', type: 'address' }], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [{ internalType: 'bytes32', name: 'node', type: 'bytes32' }], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [{ internalType: 'bytes32', name: 'node', type: 'bytes32' }], + name: 'recordExists', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [{ internalType: 'bytes32', name: 'node', type: 'bytes32' }], + name: 'resolver', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { internalType: 'address', name: 'operator', type: 'address' }, + { internalType: 'bool', name: 'approved', type: 'bool' }, + ], + name: 'setApprovalForAll', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: false, + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'address', name: 'owner', type: 'address' }, + ], + name: 'setOwner', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: false, + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'address', name: 'resolver', type: 'address' }, + { internalType: 'uint64', name: 'ttl', type: 'uint64' }, + ], + name: 'setRecord', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: false, + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'address', name: 'resolver', type: 'address' }, + ], + name: 'setResolver', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: false, + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'bytes32', name: 'label', type: 'bytes32' }, + { internalType: 'address', name: 'owner', type: 'address' }, + ], + name: 'setSubnodeOwner', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: false, + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'bytes32', name: 'label', type: 'bytes32' }, + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'address', name: 'resolver', type: 'address' }, + { internalType: 'uint64', name: 'ttl', type: 'uint64' }, + ], + name: 'setSubnodeRecord', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: false, + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'uint64', name: 'ttl', type: 'uint64' }, + ], + name: 'setTTL', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [{ internalType: 'bytes32', name: 'node', type: 'bytes32' }], + name: 'ttl', + outputs: [{ internalType: 'uint64', name: '', type: 'uint64' }], + payable: false, + stateMutability: 'view', + type: 'function', + }, +] as const diff --git a/src/contracts/abis/OPL1CrossDomainMessengerProxy.ts b/src/contracts/abis/OPL1CrossDomainMessengerProxy.ts new file mode 100644 index 00000000..6cec637e --- /dev/null +++ b/src/contracts/abis/OPL1CrossDomainMessengerProxy.ts @@ -0,0 +1,13 @@ +export const OPL1CrossDomainMessengerProxyABI = [ + { + inputs: [ + { internalType: 'address', name: '_target', type: 'address' }, + { internalType: 'bytes', name: '_message', type: 'bytes' }, + { internalType: 'uint32', name: '_minGasLimit', type: 'uint32' }, + ], + name: 'sendMessage', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, +] as const diff --git a/src/contracts/definitions.ts b/src/contracts/definitions.ts new file mode 100644 index 00000000..df13ac0f --- /dev/null +++ b/src/contracts/definitions.ts @@ -0,0 +1,142 @@ +import { + type Abi, + type Address, + erc20Abi, + isAddress, + type ContractFunctionArgs as WagmiContractFunctionArgs, + type ContractFunctionName as WagmiContractFunctionName, +} from 'viem' +import { mainnet, optimismSepolia, polygon, sepolia } from 'viem/chains' + +import type { ChainsIds } from '@/src/core/types' +import { AAVEWethABI } from './abis/AAVEWeth' +import { AaveFaucetABI } from './abis/AaveFaucet' +import { ENSRegistryABI } from './abis/ENSRegistry' +import { OPL1CrossDomainMessengerProxyABI } from './abis/OPL1CrossDomainMessengerProxy' + +type OptionalAddresses = Partial> +type ContractConfig = { + abi: TAbi + name: string + address?: OptionalAddresses +} + +/** + * A collection of contracts to be used in the dapp with their ABI and addresses per chain. + * + * @dev The data required to configure this variable is: + * - `RequiredChainId` is mandatory in the address object. + * - IDs defined `ChainIds` can be added as well if necessary. + */ +const contracts = [ + { + abi: erc20Abi, + name: 'ERC20', + }, + { + abi: erc20Abi, + name: 'SpecialERC20WithAddress', + address: { + [polygon.id]: '0x314159265dd8dbb310642f98f50c066173ceeeee', + }, + }, + { + abi: ENSRegistryABI, + address: { + [mainnet.id]: '0x314159265dd8dbb310642f98f50c066173c1259b', + [sepolia.id]: '0x0667161579ce7e84EF2b7333f9F93375a627799B', + }, + name: 'EnsRegistry', + }, + { + abi: AaveFaucetABI, + address: { + 11155111: '0xc959483dba39aa9e78757139af0e9a2edeb3f42d', + 1: '0x0000000000000000000000000000000000000000', + }, + name: 'AaveFaucet', + }, + { + abi: AAVEWethABI, + address: { + [optimismSepolia.id]: '0x589750BA8aF186cE5B55391B0b7148cAD43a1619', + }, + name: 'AAVEWeth', + }, + { + abi: OPL1CrossDomainMessengerProxyABI, + address: { + [mainnet.id]: '0x25ace71c97B33Cc4729CF772ae268934F7ab5fA1', + [sepolia.id]: '0x58Cc85b8D04EA49cC6DBd3CbFFd00B4B8D6cb3ef', + }, + name: 'OPL1CrossDomainMessengerProxy', + }, +] as const satisfies ContractConfig[] + +/** + * Retrieves all contracts. + * + * @returns {Array} An array containing the contracts' ABI and addresses. + */ +export const getContracts = () => contracts + +export type ContractNames = (typeof contracts)[number]['name'] + +type ContractOfName = Extract<(typeof contracts)[number], { name: CN }> +type AbiOfName = ContractOfName['abi'] + +type AddressRecord = + ContractOfName extends { address: infer K } ? K : never +type ChainIdOf = keyof AddressRecord + +export type ContractFunctionName = WagmiContractFunctionName< + AbiOfName, + 'nonpayable' | 'payable' +> + +export type ContractFunctionArgs< + CN extends ContractNames, + MN extends ContractFunctionName, +> = WagmiContractFunctionArgs, 'nonpayable' | 'payable', MN> + +/** + * Retrieves the contract information based on the contract name and chain ID. + * + * @param {string} name - The name of the contract. + * @param {ChainsIds} chainId - The chain ID configured in the dApp. See networks.config.ts. + * @returns {Contract} An object containing the contract's ABI and address. + * + * @throws If contract is not found. + */ +export const getContract = < + ContractName extends ContractNames, + ChainId extends ChainIdOf, +>( + name: ContractName, + chainId: ChainId, +) => { + const contract = contracts.find((contract) => contract.name === name) + + if (!contract) { + throw new Error(`Contract ${name} not found`) + } + + // address key not present + if (!('address' in contract)) { + throw new Error(`Contract ${name} address not found}`) + } + + const address = (contract.address as AddressRecord)[chainId] + + // address undefined + if (!address) { + throw new Error(`Contract ${name} address not found for chain ${chainId.toString()}`) + } + + // not a valid address + if (!isAddress(address as string)) { + throw new Error(`Contract ${name} address is not a valid address`) + } + + return { abi: contract.abi as AbiOfName, address } +} diff --git a/src/contracts/hooks/useOPL1CrossDomainMessengerProxy.ts b/src/contracts/hooks/useOPL1CrossDomainMessengerProxy.ts new file mode 100644 index 00000000..afdc0206 --- /dev/null +++ b/src/contracts/hooks/useOPL1CrossDomainMessengerProxy.ts @@ -0,0 +1,185 @@ +import { useCallback } from 'react' + +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 ContractFunctionArgs, + type ContractFunctionName, + type ContractNames, + getContract, +} from '../definitions' + +async function l2ContractCallInfo({ + contractName, + functionName, + args, + value, + walletAddress, + chain, +}: { + args: ContractFunctionArgs + chain: typeof optimismSepolia | typeof optimism + contractName: ContractNames + functionName: ContractFunctionName + value?: bigint + walletAddress: Address +}) { + const contract = getContract(contractName, chain.id) + + const readOnlyClient = createPublicClient({ + transport: transports[chain.id], + chain, + }) + + const gas = await readOnlyClient.estimateContractGas({ + address: contract.address, + abi: contract.abi, + functionName, + // biome-ignore lint/suspicious/noExplicitAny: TS does not infer correctly the type of valueuseop + args: args as any, + account: walletAddress, + // biome-ignore lint/suspicious/noExplicitAny: TS does not infer correctly the type of value + value: value as any, + }) + + const message = encodeFunctionData({ + abi: contract.abi, + functionName, + args, + }) + + return { message, gas } +} + +function estimateGasL1CrossDomainMessenger({ + chain, + l2Gas, + message, + value, +}: { + message: Hash + value?: bigint + chain: typeof sepolia | typeof mainnet + l2Gas: bigint +}) { + const contract = getContract('OPL1CrossDomainMessengerProxy', chain.id) + + const readOnlyClient = createPublicClient({ + transport: transports[chain.id], + chain, + }) + + return readOnlyClient.estimateContractGas({ + address: contract.address, + abi: contract.abi, + functionName: 'sendMessage', + args: [contract.address, message, Number(l2Gas)], + value: value, + }) +} + +/** + * Custom hook to send a cross-domain message from L1 (Ethereum Mainnet or Sepolia) to Optimism. + * + * 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 + * + * @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); + * } + * }; + * ``` + */ +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, + }) + }, [ + contractName, + functionName, + args, + value, + walletAddress, + fromChain, + writeContractAsync, + contract.abi, + contract.address, + l2ContractAddress, + ]) +} diff --git a/src/contracts/wagmi/config.ts b/src/contracts/wagmi/config.ts new file mode 100644 index 00000000..8ed7ae8f --- /dev/null +++ b/src/contracts/wagmi/config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@wagmi/cli' +import { react } from '@wagmi/cli/plugins' + +import { getContracts } from '../definitions' +import { reactSuspenseRead } from './plugins/reactSuspenseRead' + +// You can extend the config object with additional properties +// https://wagmi.sh/cli/config/options + +export default defineConfig({ + out: 'src/contracts/generated.ts', + plugins: [reactSuspenseRead(), react()], + contracts: getContracts(), +}) diff --git a/src/contracts/wagmi/plugins/reactSuspenseRead.ts b/src/contracts/wagmi/plugins/reactSuspenseRead.ts new file mode 100644 index 00000000..d2d43e9d --- /dev/null +++ b/src/contracts/wagmi/plugins/reactSuspenseRead.ts @@ -0,0 +1,116 @@ +import type { ActionsConfig } from '@wagmi/cli/plugins' +import { pascalCase } from 'change-case' + +/** + * This plugin generates a set of React hooks for reading contract state using suspenseQuery. + * + */ + +// Shared wagmi Config — used by both the SDK adapter and generated hooks +const walletConfigImport = `import { config } from '@/src/wallet/connectors/wagmi.config'` + +type ActionsResult = { + name: string + + // biome-ignore lint/suspicious/noExplicitAny: + run: ({ contracts }: { contracts: any[] }) => Promise<{ + imports: string + content: string + }> +} + +export function reactSuspenseRead(config: ActionsConfig = {}): ActionsResult { + return { + name: 'SuspenseRead', + async run({ contracts }) { + const imports = new Set([]) + const content: string[] = [] + const pure = '/*#__PURE__*/' + + const actionNames = new Set() + + // biome-ignore lint/suspicious/noExplicitAny: + const isReadFunction = (item: any) => + item.type === 'function' && + (item.stateMutability === 'view' || item.stateMutability === 'pure') + + for (const contract of contracts) { + const readItems = contract.abi.filter(isReadFunction) + const hasReadFunction = readItems.length > 0 + + let innerContent = `abi: ${contract.meta.abiName}` + if (contract.meta.addressName) { + innerContent += `, address: ${contract.meta.addressName}` + } + + if (hasReadFunction) { + const actionName = getActionName(config, actionNames, 'read', contract.name) + const functionName = 'createReadContract' + imports.add(functionName) + content.push(`export const ${actionName} = ${pure} ${functionName}({ ${innerContent} })`) + + const names = new Set() + for (const item of readItems) { + if (names.has(item.name)) continue + names.add(item.name) + + const hookName = getActionName(config, actionNames, 'read', contract.name, item.name) + + content.push( + ` + export const ${hookName} = ${pure} ${functionName}({ ${innerContent}, functionName: '${ + item.name + }' }) + export const useSuspense${pascalCase( + hookName, + )} = (params: Parameters[1], options?: UseSuspenseQueryOptions>>) => { + return useSuspenseQuery>>({ queryKey: ['${hookName}', params, config.state.chainId], queryFn: () => ${hookName}(config, params), ...options }) + } + `, + ) + } + } + } + + const importValues = [...imports.values()] + + return { + imports: importValues.length + ? `import { ${importValues.join(', ')} } from 'wagmi/codegen' + import { useSuspenseQuery, UseSuspenseQueryOptions } from '@tanstack/react-query' + ${walletConfigImport}` + : '', + content: content.join('\n\n'), + } + }, + } +} + +function getActionName( + config: ActionsConfig, + actionNames: Set, + type: 'read' | 'simulate' | 'watch' | 'write', + contractName: string, + itemName?: string | undefined, +) { + const ContractName = pascalCase(contractName) + const ItemName = itemName ? pascalCase(itemName) : undefined + + let actionName: string + if (typeof config.getActionName === 'function') + actionName = config.getActionName({ type, contractName: ContractName, itemName: ItemName }) + else if (typeof config.getActionName === 'string' && type === 'simulate') { + actionName = `prepareWrite${ContractName}${ItemName ?? ''}` + } else { + actionName = `${type}${ContractName}${ItemName ?? ''}` + if (type === 'watch') actionName = `${actionName}Event` + } + + if (actionNames.has(actionName)) + throw new Error( + `Action name "${actionName}" must be unique for contract "${contractName}". Try using \`getActionName\` to create a unique name.`, + ) + + actionNames.add(actionName) + return actionName +} diff --git a/src/core/components.ts b/src/core/components.ts new file mode 100644 index 00000000..afa5fdd1 --- /dev/null +++ b/src/core/components.ts @@ -0,0 +1,31 @@ +// UI components + +export { default as Avatar } from './ui/Avatar' +export { + BigNumberInput, + type BigNumberInputProps, + type RenderInputProps, +} from './ui/BigNumberInput' +export { Button } from './ui/Button' +export { CopyButton } from './ui/CopyButton' +// Chakra UI providers and utilities +export { Provider } from './ui/chakra/provider' +export { Toaster, toaster } from './ui/chakra/toaster' +export { default as DropdownButton } from './ui/DropdownButton' +export { TanStackReactQueryDevtools } from './ui/dev/TanStackReactQueryDevtools' +export { TanStackRouterDevtools } from './ui/dev/TanStackRouterDevtools' +export { ExplorerLink } from './ui/ExplorerLink' +export { ExternalLinkButton } from './ui/ExternalLink' +export { Footer } from './ui/Footer' +export { GeneralMessage } from './ui/GeneralMessage' +export { default as Hash } from './ui/Hash' +export { default as HashInput } from './ui/HashInput' +export { Header } from './ui/Header' +export { Inner } from './ui/Inner' +export { MenuContent, MenuItem } from './ui/Menu' +export { CloseButton, Modal } from './ui/Modal' +export { NotificationToast, notificationToaster } from './ui/NotificationToast' +export { PrimaryButton } from './ui/PrimaryButton' +export { SecondaryButton } from './ui/SecondaryButton' +export { Spinner } from './ui/Spinner' +export { SwitchThemeButton } from './ui/SwitchThemeButton' diff --git a/src/core/config/common.ts b/src/core/config/common.ts new file mode 100644 index 00000000..a12b5a0e --- /dev/null +++ b/src/core/config/common.ts @@ -0,0 +1,11 @@ +import { env } from '@/src/env' + +/** + * @source + */ +export const isDev = import.meta.env.DEV + +/** + * @source + */ +export const includeTestnets = env.PUBLIC_INCLUDE_TESTNETS diff --git a/src/core/config/networks.config.ts b/src/core/config/networks.config.ts new file mode 100644 index 00000000..87fea035 --- /dev/null +++ b/src/core/config/networks.config.ts @@ -0,0 +1,27 @@ +// networks.config.ts +/** + * This file contains the configuration for the networks used in the application. + * + * @packageDocumentation + */ +import { http, type Transport } from 'viem' +import { arbitrum, mainnet, optimism, optimismSepolia, polygon, sepolia } from 'viem/chains' + +import { env } from '@/src/env' +import { includeTestnets } from './common' + +const devChains = [optimismSepolia, sepolia] as const +const prodChains = [mainnet, polygon, arbitrum, optimism] as const +const allChains = [...devChains, ...prodChains] as const +export const chains = includeTestnets ? allChains : prodChains +export type ChainsIds = (typeof chains)[number]['id'] + +type RestrictedTransports = Record +export const transports: RestrictedTransports = { + [mainnet.id]: http(env.PUBLIC_RPC_MAINNET), + [arbitrum.id]: http(env.PUBLIC_RPC_ARBITRUM), + [optimism.id]: http(env.PUBLIC_RPC_OPTIMISM), + [optimismSepolia.id]: http(env.PUBLIC_RPC_OPTIMISM_SEPOLIA), + [polygon.id]: http(env.PUBLIC_RPC_POLYGON), + [sepolia.id]: http(env.PUBLIC_RPC_SEPOLIA), +} diff --git a/src/core/hooks.ts b/src/core/hooks.ts new file mode 100644 index 00000000..221c9100 --- /dev/null +++ b/src/core/hooks.ts @@ -0,0 +1 @@ +export { useNetworkBlockNumber } from './hooks/useNetworkBlockNumber' diff --git a/src/core/hooks/useNetworkBlockNumber.ts b/src/core/hooks/useNetworkBlockNumber.ts new file mode 100644 index 00000000..33b673b6 --- /dev/null +++ b/src/core/hooks/useNetworkBlockNumber.ts @@ -0,0 +1,51 @@ +import { type UseSuspenseQueryOptions, useSuspenseQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { createPublicClient, http } from 'viem' +import type { Chain } from 'viem/chains' + +/** + * Custom hook to fetch the block number of a specific network. + * + * Creates a dedicated public client specifically for the provided chain, + * regardless of whether it's supported in the app configuration. + * Uses TanStack Query's suspense mode for data fetching. + * + * @param {Object} params - The parameters object + * @param {Chain} params.chain - The viem chain object for the target network + * @param {Omit} [params.options] - Optional TanStack Query options + * + * @returns {bigint|undefined} The current block number as a bigint + * + * @example + * ```tsx + * const blockNumber = useNetworkBlockNumber({ + * chain: optimism, + * options: { refetchInterval: 5000 } + * }); + * ``` + */ +export const useNetworkBlockNumber = ({ + chain, + options, +}: { + chain: Chain + options?: Omit +}) => { + const publicClient = useMemo( + () => + createPublicClient({ + chain, + transport: http(), + }), + [chain], + ) + + const { data } = useSuspenseQuery({ + queryKey: ['networkBlockNumber', chain.id], + queryFn: async () => publicClient.getBlockNumber(), + refetchInterval: 10_000, + ...options, + }) + + return data as bigint | undefined +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 00000000..386dcdde --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,3 @@ +export { includeTestnets, isDev } from './config/common' +export { type ChainsIds, chains, transports } from './config/networks.config' +export type { RequiredNonNull } from './types/utils' diff --git a/src/core/types/utils.ts b/src/core/types/utils.ts new file mode 100644 index 00000000..4200842e --- /dev/null +++ b/src/core/types/utils.ts @@ -0,0 +1 @@ +export type RequiredNonNull = { [P in keyof T]-?: NonNullable } diff --git a/src/core/ui/Avatar.test.tsx b/src/core/ui/Avatar.test.tsx new file mode 100644 index 00000000..5908a4f5 --- /dev/null +++ b/src/core/ui/Avatar.test.tsx @@ -0,0 +1,94 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Avatar from './Avatar' + +// Mock Jazzicon component +vi.mock('react-jazzicon', () => ({ + default: vi.fn(({ diameter, seed }) => ( +
+ Mocked Jazzicon +
+ )), + jsNumberForAddress: vi.fn().mockReturnValue(12345), +})) + +const system = createSystem(defaultConfig) + +describe('Avatar', () => { + const mockAddress = '0x1234567890abcdef1234567890abcdef12345678' + + it('renders Jazzicon when no ENS image is provided', () => { + render( + + + , + ) + + const jazzicon = screen.getByTestId('avatar-icon') + expect(jazzicon).toBeDefined() + expect(jazzicon.getAttribute('data-diameter')).toBe('100') + }) + + it('renders ENS image when provided', () => { + const ensImage = 'https://example.com/avatar.png' + const ensName = 'test.eth' + + render( + + + , + ) + + const image = screen.getByAltText(ensName) + expect(image).toBeDefined() + expect(image.getAttribute('src')).toBe(ensImage) + }) + + it('uses address as alt text when ENS name is not provided', () => { + const ensImage = 'https://example.com/avatar.png' + + render( + + + , + ) + + const image = screen.getByAltText(mockAddress) + expect(image).toBeDefined() + }) + + it('renders with default size when size is not provided', () => { + render( + + + , + ) + + const jazzicon = screen.getByTestId('avatar-icon') + expect(jazzicon.getAttribute('data-diameter')).toBe('100') + }) +}) diff --git a/src/core/ui/Avatar.tsx b/src/core/ui/Avatar.tsx new file mode 100644 index 00000000..5aba2264 --- /dev/null +++ b/src/core/ui/Avatar.tsx @@ -0,0 +1,79 @@ +import { Box } from '@chakra-ui/react' +import type { ComponentProps, FC } from 'react' +import * as JazziconModule from 'react-jazzicon' + +// react-jazzicon is CJS — Vite 8 may double-wrap the default export. +const resolved = JazziconModule.default ?? JazziconModule +const Jazzicon = ( + typeof resolved === 'function' ? resolved : (resolved as { default: unknown }).default +) as FC<{ diameter: number; seed: number }> +const jsNumberForAddress = (JazziconModule as unknown as Record) + .jsNumberForAddress as (address: string) => number + +interface AvatarProps extends ComponentProps<'div'> { + address: string + ensImage: string | null | undefined + ensName: string | null | undefined + size?: number +} + +/** + * Avatar component, displays an avatar with an ENS image or Jazzicon based on the provided props. + * + * If an ENS image is provided, it will be displayed, otherwise a Jazzicon will be displayed based on the address. + * This component is used as a custom avatar for the WalletProvider. + * + * @param {object} props - Avatar component props. + * @param {string} props.address - The address to infer the avatar from + * @param {string | null | undefined} props.ensImage - The ENS image URL for the avatar + * @param {string | null | undefined} props.ensName - The ENS name + * @param {number} [props.size=100] - The size of the avatar + * + * @example + * ```tsx + * + * ``` + */ +const Avatar: FC = ({ + address, + ensImage, + ensName, + size = 100, +}: { + address: string + ensImage: string | null | undefined + ensName: string | null | undefined + size?: number +}) => { + return ( + + {ensImage ? ( + {ensName + ) : ( + + )} + + ) +} + +export default Avatar diff --git a/src/core/ui/BigNumberInput.test.tsx b/src/core/ui/BigNumberInput.test.tsx new file mode 100644 index 00000000..7554dc5a --- /dev/null +++ b/src/core/ui/BigNumberInput.test.tsx @@ -0,0 +1,24 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { BigNumberInput } from './BigNumberInput' + +const system = createSystem(defaultConfig) + +describe('BigNumberInput', () => { + it('renders without crashing', () => { + render( + + {}} + /> + , + ) + + const input = screen.getByRole('textbox') + expect(input).not.toBeNull() + expect(input.tagName).toBe('INPUT') + }) +}) diff --git a/src/core/ui/BigNumberInput.tsx b/src/core/ui/BigNumberInput.tsx new file mode 100644 index 00000000..a7c6aa4f --- /dev/null +++ b/src/core/ui/BigNumberInput.tsx @@ -0,0 +1,148 @@ +import { chakra, type InputProps } from '@chakra-ui/react' +import { + type ChangeEvent, + type FC, + type ReactElement, + type RefObject, + useEffect, + useRef, +} from 'react' +import { formatUnits, maxUint256, parseUnits } from 'viem' +export type RenderInputProps = Omit & { + onChange: (event: ChangeEvent | string) => void + inputRef: RefObject +} + +export type BigNumberInputProps = { + autofocus?: boolean + decimals: number + disabled?: boolean + max?: bigint + min?: bigint + onChange: (value: bigint) => void + onError?: (error: { value: string; message: string } | null) => void + placeholder?: string + renderInput?: (props: RenderInputProps) => ReactElement + value: bigint +} + +/** + * BigNumberInput component for handling bigint values with decimal precision. + * + * This component provides a way to input and validate numeric values with specific decimal places. + * It handles conversion between string representation and bigint values. + * + * @param {BigNumberInputProps} props - The props for the BigNumberInput component. + * @param {boolean} [props.autofocus=false] - Whether to focus the input automatically. + * @param {number} props.decimals - The number of decimal places to use. + * @param {boolean} [props.disabled=false] - Whether the input is disabled. + * @param {bigint} [props.max=maxUint256] - Maximum allowed value. + * @param {bigint} [props.min=0] - Minimum allowed value. + * @param {(value: bigint) => void} props.onChange - Function called when the value changes. + * @param {(error: { value: string; message: string } | null) => void} [props.onError] - Function called when there's an error. + * @param {string} [props.placeholder='0.00'] - Placeholder text for the input. + * @param {(props: RenderInputProps) => ReactElement} [props.renderInput] - Custom input renderer. + * @param {bigint} props.value - The current value. + * + * @example + * ```tsx + * console.log(value)} + * value={BigInt(0)} + * /> + * ``` + */ +export const BigNumberInput: FC = ({ + autofocus, + decimals, + disabled, + max = maxUint256, + min = BigInt(0), + onChange, + onError, + placeholder = '0.00', + renderInput, + value, +}: BigNumberInputProps) => { + const inputRef = useRef(null) + + // update inputValue when value changes + useEffect(() => { + const current = inputRef.current + if (!current) { + return + } + const currentInputValue = parseUnits(current.value.replace(/,/g, '') || '0', decimals) + + if (currentInputValue !== value) { + current.value = formatUnits(value, decimals) + } + }, [decimals, value]) + + // autofocus + useEffect(() => { + if (!renderInput && autofocus && inputRef.current) { + inputRef.current.focus() + } + }, [renderInput, autofocus]) + + const updateValue = (event: ChangeEvent | string) => { + const { value } = typeof event === 'string' ? { value: event } : event.currentTarget + + if (value === '') { + onChange(BigInt(0)) + return + } + + let newValue: bigint + try { + newValue = parseUnits(value, decimals) + } catch (e) { + console.error(e) + // don't update the input on invalid values + return + } + + // this will fail when a value has no decimals, which is quite common + try { + const [, valueDecimals] = value.split('.') + + if (valueDecimals.length > decimals) { + return + } + } catch { + // fall-through + } + + const invalidValue = (min && newValue < min) || (max && newValue > max) + + if (invalidValue) { + const _min = formatUnits(min, decimals) + const _max = formatUnits(max, decimals) + const message = `Invalid value! Range: [${_min}, ${ + max === maxUint256 ? 'maxUint256' : _max + }] and value is: ${value}` + console.warn(message) + onError?.({ value, message }) + } + + onChange(newValue) + } + + const inputProps = { + disabled, + onChange: updateValue, + placeholder, + type: 'text', + } + + return renderInput ? ( + renderInput({ ...inputProps, inputRef }) + ) : ( + + ) +} diff --git a/src/core/ui/Button.tsx b/src/core/ui/Button.tsx new file mode 100644 index 00000000..edde4693 --- /dev/null +++ b/src/core/ui/Button.tsx @@ -0,0 +1,44 @@ +import { chakra } from '@chakra-ui/react' + +export const Button = chakra( + 'button', + { + base: { + alignItems: 'center', + borderRadius: 'sm', + borderStyle: 'solid', + borderWidth: '1px', + cursor: 'pointer', + display: 'flex', + fontFamily: '{fonts.body}', + fontSize: '15px', + fontWeight: '400', + gap: 2, + height: '48px', + justifyContent: 'center', + lineHeight: '1', + outline: 'none', + paddingY: 0, + paddingX: 4, + textDecoration: 'none', + transition: + 'background-color {durations.moderate}, border-color {durations.moderate}, color {durations.moderate', + userSelect: 'none', + whiteSpace: 'nowrap', + _disabled: { + cursor: 'not-allowed', + opacity: 0.6, + }, + _active: { + opacity: 0.8, + }, + }, + }, + { + defaultProps: { + type: 'button', + }, + }, +) + +export default Button diff --git a/src/core/ui/CopyButton/index.tsx b/src/core/ui/CopyButton/index.tsx new file mode 100644 index 00000000..93a2b523 --- /dev/null +++ b/src/core/ui/CopyButton/index.tsx @@ -0,0 +1,106 @@ +import { type ButtonProps, chakra } from '@chakra-ui/react' +import type { FC, HTMLAttributes, MouseEventHandler } from 'react' +import styles from './styles' + +const Copy: FC> = ({ ...restProps }) => ( + + Copy Icon + + + +) + +interface Props extends ButtonProps { + value: string +} + +/** + * CopyButton component that copies text to the clipboard when clicked. + * + * Renders a button with a copy icon by default. When clicked, copies the provided + * value to the clipboard using the Clipboard API. + * + * @param {Props} props - CopyButton component props. + * @param {string} props.value - The text to copy to the clipboard. + * @param {ReactNode} [props.children=] - Content to render inside the button. + * @param {CSSObject} [props.css] - Custom CSS styling. + * @param {MouseEventHandler} [props.onClick] - Additional onClick handler. + * @param {ButtonProps} props.restProps - Additional props from Chakra UI ButtonProps. + * + * @example + * ```tsx + * Copy + * ``` + */ +export const CopyButton: FC = ({ + children = , + css, + onClick, + value, + ...restProps +}: Props) => { + const onCopy: MouseEventHandler = (e) => { + navigator.clipboard.writeText(value) + onClick?.(e) + } + + return ( + + {children} + + ) +} + +export default CopyButton diff --git a/src/core/ui/CopyButton/styles.ts b/src/core/ui/CopyButton/styles.ts new file mode 100644 index 00000000..d3fca26e --- /dev/null +++ b/src/core/ui/CopyButton/styles.ts @@ -0,0 +1,12 @@ +export const styles = { + 'html.light &': { + '--color': '#2e3048', + '--color-hover': '#8b46a4', + }, + 'html.dark &': { + '--color': '#e2e0e7', + '--color-hover': '#c670e5', + }, +} + +export default styles diff --git a/src/core/ui/DropdownButton.tsx b/src/core/ui/DropdownButton.tsx new file mode 100644 index 00000000..bd8f260a --- /dev/null +++ b/src/core/ui/DropdownButton.tsx @@ -0,0 +1,46 @@ +import { type ButtonProps, chakra } from '@chakra-ui/react' +import type { FC } from 'react' +import PrimaryButton from './PrimaryButton' + +const ChevronDown: FC = () => ( + + Chevron down + + +) + +const Button: FC = ({ children, ...restProps }) => { + return ( + + {children} + + ) +} + +export default Button diff --git a/src/core/ui/ExplorerLink.test.tsx b/src/core/ui/ExplorerLink.test.tsx new file mode 100644 index 00000000..d8bac906 --- /dev/null +++ b/src/core/ui/ExplorerLink.test.tsx @@ -0,0 +1,91 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import type { Chain } from 'viem' +import { describe, expect, it, vi } from 'vitest' +import { ExplorerLink } from './ExplorerLink' + +// Mock the getExplorerLink utility +vi.mock('../utils/getExplorerLink', () => ({ + getExplorerLink: vi.fn(({ chain, hashOrAddress, explorerUrl }) => { + if (explorerUrl) { + return `${explorerUrl}/address/${hashOrAddress}` + } + return `https://example.com/${chain.id}/address/${hashOrAddress}` + }), +})) + +const system = createSystem(defaultConfig) + +describe('ExplorerLink', () => { + const mockChain = { id: 1, name: 'Ethereum' } + const mockAddress = '0x1234567890abcdef1234567890abcdef12345678' + + it('renders with default text', () => { + render( + + + , + ) + + const link = screen.getByText('View on explorer') + expect(link).toBeDefined() + expect(link.tagName).toBe('A') + expect(link.getAttribute('href')).toBe(`https://example.com/1/address/${mockAddress}`) + expect(link.getAttribute('target')).toBe('_blank') + expect(link.getAttribute('rel')).toBe('noopener noreferrer') + }) + + it('renders with custom text', () => { + const customText = 'View transaction' + + render( + + + , + ) + + const link = screen.getByText(customText) + expect(link).toBeDefined() + expect(link.getAttribute('href')).toBe(`https://example.com/1/address/${mockAddress}`) + }) + + it('passes additional props to the link', () => { + render( + + + , + ) + + const link = screen.getByText('View on explorer') + expect(link).toBeDefined() + }) + + it('works with explorerUrl parameter', () => { + const mockExplorerUrl = 'https://custom-explorer.com' + + render( + + + , + ) + + const link = screen.getByText('View on explorer') + expect(link.getAttribute('href')).toBe(`${mockExplorerUrl}/address/${mockAddress}`) + }) +}) diff --git a/src/core/ui/ExplorerLink.tsx b/src/core/ui/ExplorerLink.tsx new file mode 100644 index 00000000..120ab0be --- /dev/null +++ b/src/core/ui/ExplorerLink.tsx @@ -0,0 +1,44 @@ +import { chakra, type LinkProps } from '@chakra-ui/react' +import type { FC } from 'react' +import { type GetExplorerUrlParams, getExplorerLink } from '../utils/getExplorerLink' + +interface ExplorerLinkProps extends GetExplorerUrlParams, LinkProps { + text?: string +} + +/** + * Link to blockchain explorer for the specified network. + * + * This component renders a link to the appropriate blockchain explorer based on the provided chain + * and hash/address, allowing users to view transactions, addresses, or other on-chain data. + * + * @param {ExplorerLinkProps} props - The props for the ExplorerLink component. + * @param {Chain} props.chain - The blockchain network (from viem chains). + * @param {string} [props.explorerUrl] - Optional custom explorer URL to override the default. + * @param {Hash | Address} props.hashOrAddress - The transaction hash or address to view in the explorer. + * @param {string} [props.text='View on explorer'] - The text displayed in the link. + * @param {LinkProps} props.restProps - Additional props inherited from Chakra UI LinkProps. + * + * @example + * ```tsx + * + * ``` + */ +export const ExplorerLink: FC = ({ + text = 'View on explorer', + ...props +}: ExplorerLinkProps) => { + return ( + + {text} + + ) +} diff --git a/src/core/ui/ExternalLink/index.tsx b/src/core/ui/ExternalLink/index.tsx new file mode 100644 index 00000000..aecd0161 --- /dev/null +++ b/src/core/ui/ExternalLink/index.tsx @@ -0,0 +1,84 @@ +import { chakra, Link, type LinkProps } from '@chakra-ui/react' +import type { FC, HTMLAttributes } from 'react' +import styles from './styles' + +const LinkSVG: FC> = ({ ...restProps }) => ( + + External link Icon + + + + +) + +/** + * @name ExternalLink + * @description A button that opens a link in a new tab. + * @param {React.HTMLAttributeAnchorTarget} target - The target attribute specifies where to open the linked document. Default is '_blank'. + */ +export const ExternalLinkButton: FC = ({ + children = , + css, + target = '_blank', + ...restProps +}: LinkProps) => { + return ( + + {children} + + ) +} + +export default ExternalLinkButton diff --git a/src/core/ui/ExternalLink/styles.ts b/src/core/ui/ExternalLink/styles.ts new file mode 100644 index 00000000..d3fca26e --- /dev/null +++ b/src/core/ui/ExternalLink/styles.ts @@ -0,0 +1,12 @@ +export const styles = { + 'html.light &': { + '--color': '#2e3048', + '--color-hover': '#8b46a4', + }, + 'html.dark &': { + '--color': '#e2e0e7', + '--color-hover': '#c670e5', + }, +} + +export default styles diff --git a/src/core/ui/Footer/LogoMini.tsx b/src/core/ui/Footer/LogoMini.tsx new file mode 100644 index 00000000..06606b9d --- /dev/null +++ b/src/core/ui/Footer/LogoMini.tsx @@ -0,0 +1,52 @@ +import { chakra } from '@chakra-ui/react' +import type { FC } from 'react' + +/** + * @name LogoMini + * + * @description dAppBooster mini logo component + */ +export const LogoMini: FC = ({ ...restProps }) => ( + + BootNode - Web3 Blockchain Development + + + + + + + + + + +) + +export default LogoMini diff --git a/src/core/ui/Footer/Socials/assets/Github.tsx b/src/core/ui/Footer/Socials/assets/Github.tsx new file mode 100644 index 00000000..0240b945 --- /dev/null +++ b/src/core/ui/Footer/Socials/assets/Github.tsx @@ -0,0 +1,26 @@ +import { chakra } from '@chakra-ui/react' +import type { FC, HTMLAttributes } from 'react' + +/** + * Github logo component + */ +const Github: FC> = ({ ...restProps }) => ( + + BootNode Github + + +) + +export default Github diff --git a/src/core/ui/Footer/Socials/assets/LinkedIn.tsx b/src/core/ui/Footer/Socials/assets/LinkedIn.tsx new file mode 100644 index 00000000..2e1569cb --- /dev/null +++ b/src/core/ui/Footer/Socials/assets/LinkedIn.tsx @@ -0,0 +1,28 @@ +import { chakra } from '@chakra-ui/react' +import type { FC, HTMLAttributes } from 'react' + +/** + * LinkedIn logo component + */ +const LinkedIn: FC> = ({ ...restProps }) => ( + + BootNode LinkedIn + + +) + +export default LinkedIn diff --git a/src/core/ui/Footer/Socials/assets/Telegram.tsx b/src/core/ui/Footer/Socials/assets/Telegram.tsx new file mode 100644 index 00000000..3239a2a2 --- /dev/null +++ b/src/core/ui/Footer/Socials/assets/Telegram.tsx @@ -0,0 +1,26 @@ +import { chakra } from '@chakra-ui/react' +import type { FC, HTMLAttributes } from 'react' + +/** + * Telegram logo component + */ +const Telegram: FC> = ({ ...restProps }) => ( + + BootNode Telegram + + +) + +export default Telegram diff --git a/src/core/ui/Footer/Socials/assets/Twitter.tsx b/src/core/ui/Footer/Socials/assets/Twitter.tsx new file mode 100644 index 00000000..46bff27d --- /dev/null +++ b/src/core/ui/Footer/Socials/assets/Twitter.tsx @@ -0,0 +1,28 @@ +import { chakra } from '@chakra-ui/react' +import type { FC, HTMLAttributes } from 'react' + +/** + * Twitter logo component + */ +const Twitter: FC> = ({ ...restProps }) => ( + + BootNode Twitter / X + + +) + +export default Twitter diff --git a/src/core/ui/Footer/Socials/index.tsx b/src/core/ui/Footer/Socials/index.tsx new file mode 100644 index 00000000..2dcfbb53 --- /dev/null +++ b/src/core/ui/Footer/Socials/index.tsx @@ -0,0 +1,49 @@ +import { Flex, type FlexProps, Link } from '@chakra-ui/react' +import type { FC } from 'react' +import Github from './assets/Github' +import LinkedIn from './assets/LinkedIn' +import Telegram from './assets/Telegram' +import Twitter from './assets/Twitter' + +const Socials: FC = ({ ...restProps }) => { + const items = [ + { label: 'Telegram', icon: , href: 'https://t.me/dAppBooster' }, + { label: 'Github', icon: , href: 'https://github.com/BootNodeDev' }, + { label: 'Twitter', icon: , href: 'https://twitter.com/bootnodedev' }, + { + label: 'LinkedIn', + icon: , + href: 'https://www.linkedin.com/company/bootnode-dev/', + }, + ] + + return ( + + {items.map(({ href, icon, label }) => ( + + {icon} + + ))} + + ) +} + +export default Socials diff --git a/src/core/ui/Footer/index.tsx b/src/core/ui/Footer/index.tsx new file mode 100644 index 00000000..b40e08d4 --- /dev/null +++ b/src/core/ui/Footer/index.tsx @@ -0,0 +1,59 @@ +import { Box, Flex, type FlexProps } from '@chakra-ui/react' +import packageJSON from '@packageJSON' +import type { FC } from 'react' +import { Inner } from '../Inner' +import { LogoMini } from './LogoMini' +import Socials from './Socials' +import styles from './styles' + +export const Footer: FC = ({ css, ...restProps }) => { + return ( + + + + + + + + + + + Version: {packageJSON.version} + + + + ) +} diff --git a/src/core/ui/Footer/styles.ts b/src/core/ui/Footer/styles.ts new file mode 100644 index 00000000..4b0b888d --- /dev/null +++ b/src/core/ui/Footer/styles.ts @@ -0,0 +1,14 @@ +export const styles = { + 'html.light &': { + '--background-color': '#f7f7f7', + '--text-color': '#2e3048', + '--line-color': '#c5c2cb', + }, + 'html.dark &': { + '--background-color': '#23048', + '--text-color': '#c5c2cb', + '--line-color': '#5f6178', + }, +} + +export default styles diff --git a/src/core/ui/GeneralMessage/index.tsx b/src/core/ui/GeneralMessage/index.tsx new file mode 100644 index 00000000..91498fed --- /dev/null +++ b/src/core/ui/GeneralMessage/index.tsx @@ -0,0 +1,136 @@ +import { Card as BaseCard, type CardRootProps, Flex, Heading } from '@chakra-ui/react' +import type { ComponentProps, FC, ReactElement } from 'react' +import styles from './styles' + +const AlertIcon: FC> = ({ ...restProps }) => ( + + Alert Icon + + + + +) + +interface Props extends CardRootProps { + actionButton?: ReactElement + icon?: ReactElement + message?: string | ReactElement + title?: string +} + +/** + * @name GeneralMessage + * + * @description General error component. + * + * @param {ReactElement} [actionButton] - Optional action button. Can be used to reload the page, redirect the user somewhere, etc. + * @param {Array | ReactElement} [icon] - Optional icon to display. Default is an alert icon. + * @param {string | ReactElement} [message] - Optional message to display. Default is 'Something went wrong.' + * @param {string} [title] - Optional title to display. Default is 'Error'. + */ +export const GeneralMessage: FC = ({ + actionButton, + css, + icon = , + message = 'Something went wrong.', + title = 'Error', + ...restProps +}: Props) => { + return ( + + + {icon} + + + {title} + + + {message} + + {actionButton} + + ) +} + +export default GeneralMessage diff --git a/src/core/ui/GeneralMessage/styles.ts b/src/core/ui/GeneralMessage/styles.ts new file mode 100644 index 00000000..171ebe23 --- /dev/null +++ b/src/core/ui/GeneralMessage/styles.ts @@ -0,0 +1,20 @@ +export const styles = { + 'html.light &': { + '--background-color': '#fff', + '--border-color': '#fff', + '--box-shadow': '0 9.6px 13px 0 rgb(0 0 0 / 8%)', + '--color-title': '#2e3048', + '--color-message-background': '#f8f8f8', + '--color-text': '#4b4d60', + }, + 'html.dark &': { + '--background-color': '#fff', + '--border-color': '#fff', + '--box-shadow': '0 9.6px 13px 0 rgb(0 0 0 / 8%)', + '--color-title': '#2e3048', + '--color-message-background': '#f8f8f8', + '--color-text': '#4b4d60', + }, +} + +export default styles diff --git a/src/core/ui/Hash.test.tsx b/src/core/ui/Hash.test.tsx new file mode 100644 index 00000000..f66243d4 --- /dev/null +++ b/src/core/ui/Hash.test.tsx @@ -0,0 +1,112 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Hash from './Hash' + +// Mock the dependencies +vi.mock('./CopyButton', () => ({ + default: vi.fn(({ onClick, value }) => ( + + )), +})) + +vi.mock('./ExternalLink', () => ({ + default: vi.fn(({ href }) => ( + + External + + )), +})) + +vi.mock('../utils/strings', () => ({ + getTruncatedHash: vi.fn((hash, length) => { + if (length === 0) return hash + return `${hash.substring(0, length)}...${hash.substring(hash.length - length)}` + }), +})) + +const system = createSystem(defaultConfig) + +describe('Hash', () => { + const mockHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + + it('renders truncated hash with default settings', () => { + render( + + + , + ) + + // Default truncated length is 6 + expect(screen.getByText('0x1234...abcdef')).toBeDefined() + }) + + it('renders full hash when truncation is disabled', () => { + render( + + + , + ) + + expect(screen.getByText(mockHash)).toBeDefined() + }) + + it('renders with copy button when showCopyButton is true', () => { + render( + + + , + ) + + const copyButton = screen.getByTestId('copy-button') + expect(copyButton).toBeDefined() + expect(copyButton.getAttribute('data-value')).toBe(mockHash) + }) + + it('renders with external link when explorerURL is provided', () => { + const explorerURL = 'https://example.com/tx/123' + + render( + + + , + ) + + const externalLink = screen.getByTestId('external-link') + expect(externalLink).toBeDefined() + expect(externalLink.getAttribute('href')).toBe(explorerURL) + }) + + it('renders with custom truncated length', () => { + render( + + + , + ) + + // Custom truncated length of 4 + expect(screen.getByText('0x12...cdef')).toBeDefined() + }) +}) diff --git a/src/core/ui/Hash.tsx b/src/core/ui/Hash.tsx new file mode 100644 index 00000000..be58b88a --- /dev/null +++ b/src/core/ui/Hash.tsx @@ -0,0 +1,69 @@ +import { Flex, type FlexProps, Span } from '@chakra-ui/react' +import type { FC, MouseEventHandler } from 'react' +import { getTruncatedHash } from '../utils/strings' +import CopyButton from './CopyButton' +import ExternalLink from './ExternalLink' + +interface HashProps extends Omit { + explorerURL?: string + hash: string + onCopy?: MouseEventHandler + showCopyButton?: boolean + truncatedHashLength?: number | 'disabled' +} + +/** + * Hash component, displays a hash with an optional copy button and an optional external link. + * + * @param {HashProps} props - Hash component props. + * @param {string} props.hash - The hash to display. + * @param {string} [props.explorerURL=''] - The URL to the explorer for the hash. If provided, an external link icon will be displayed. Default is an empty string. + * @param {MouseEventHandler} [props.onCopy=undefined] - The function to call when the copy button is clicked. Default is undefined. + * @param {boolean} [props.showCopyButton=false] - Whether to show the copy button. Default is false. + * @param {number | 'disabled'} [props.truncatedHashLength=6] - The number of characters to show at the start and end of the hash. 'disabled' if you don't want to truncate the hash value. Default is 6. + * + * @example + * ```tsx + * + * ``` + */ +const Hash: FC = ({ + explorerURL = '', + hash, + onCopy, + showCopyButton = false, + truncatedHashLength = 6, + ...restProps +}: HashProps) => { + return ( + + + {truncatedHashLength === 'disabled' ? hash : getTruncatedHash(hash, truncatedHashLength)} + + {showCopyButton && ( + + )} + {explorerURL && } + + ) +} + +export default Hash diff --git a/src/core/ui/HashInput.test.tsx b/src/core/ui/HashInput.test.tsx new file mode 100644 index 00000000..72f11389 --- /dev/null +++ b/src/core/ui/HashInput.test.tsx @@ -0,0 +1,28 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { mainnet } from 'viem/chains' +import { describe, expect, it, vi } from 'vitest' +import HashInput from './HashInput' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/utils/hash', () => ({ + default: vi.fn().mockResolvedValue(null), +})) + +describe('HashInput', () => { + it('renders without crashing', () => { + render( + + {}} + /> + , + ) + + const input = screen.getByTestId('hash-input') + expect(input).not.toBeNull() + expect(input.tagName).toBe('INPUT') + }) +}) diff --git a/src/core/ui/HashInput.tsx b/src/core/ui/HashInput.tsx new file mode 100644 index 00000000..cfc9d198 --- /dev/null +++ b/src/core/ui/HashInput.tsx @@ -0,0 +1,111 @@ +import { chakra, type InputProps } from '@chakra-ui/react' +import { + type ChangeEvent, + type FC, + type ReactElement, + useCallback, + useEffect, + useState, +} from 'react' +import { useDebouncedCallback } from 'use-debounce' +import type { Chain } from 'viem' +import detectHash, { type DetectedHash } from '../utils/hash' + +interface HashInputProps extends InputProps { + chain: Chain + debounceTime?: number + onLoading?: (loading: boolean) => void + onSearch: (result: DetectedHash | null) => void + renderInput?: (props: InputProps) => ReactElement + value?: string +} + +/** + * HashInput component for entering and detecting blockchain addresses, transaction hashes, or ENS names. + * + * This component provides an input field that processes user input to detect its type + * (address, transaction hash, or ENS name) on a specified blockchain network. + * It uses debounced search to prevent excessive requests and can be customized with a custom input renderer. + * + * @param {HashInputProps} props - The props for the HashInput component. + * @param {Chain} props.chain - The blockchain network to use for detection (from viem chains). + * @param {number} [props.debounceTime=500] - Delay in milliseconds before triggering search after input changes. + * @param {(loading: boolean) => void} [props.onLoading] - Callback fired when loading state changes. + * @param {(result: DetectedHash | null) => void} props.onSearch - Callback fired with detection results. + * @param {(props: InputProps) => ReactElement} [props.renderInput] - Custom input renderer function. + * @param {string} [props.value] - Controlled input value. + * @param {InputProps} [props.restProps] - Additional props inherited from Chakra UI InputProps. + * + * @example + * ```tsx + * console.log(result)} + * debounceTime={300} + * placeholder="Enter address, ENS name or transaction hash" + * /> + * ``` + */ +const HashInput: FC = ({ + chain, + debounceTime = 500, + onLoading, + onSearch, + renderInput, + value, + ...restProps +}: HashInputProps) => { + const [input, setInput] = useState(value || '') + const [loading, setLoading] = useState(false) + + const handleSearch = useCallback( + async (value: string) => { + if (value) { + setLoading(true) + const detected = await detectHash({ chain, hashOrString: value }) + setLoading(false) + onSearch(detected) + } else { + onSearch(null) + } + }, + [chain, onSearch], + ) + + const debouncedHandleChange = useDebouncedCallback(handleSearch, debounceTime) + + const handleChange = (e: ChangeEvent) => { + const value = e.target.value + setInput(value) + debouncedHandleChange(value) + } + + useEffect(() => { + if (value !== undefined) { + setInput(value) + debouncedHandleChange(value) + } + }, [value, debouncedHandleChange]) + + useEffect(() => { + onLoading?.(loading) + }, [loading, onLoading]) + + return ( + <> + {renderInput ? ( + renderInput({ value: input, onChange: handleChange, ...restProps }) + ) : ( + + )} + + ) +} + +export default HashInput diff --git a/src/core/ui/Header/Logo.tsx b/src/core/ui/Header/Logo.tsx new file mode 100644 index 00000000..6ae697ad --- /dev/null +++ b/src/core/ui/Header/Logo.tsx @@ -0,0 +1,33 @@ +import { chakra, type ImageProps } from '@chakra-ui/react' +import type { FC } from 'react' + +const LogoDark = + 'PHN2ZyB3aWR0aD0iMTkzIiBoZWlnaHQ9Ijc3IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik0zNi40OCA0Mi41ODNjLS4xMy0uMzQzLS4yNi0uNjg3LS4zNy0xLjA0MS0xLjI5LTQuMjA0LTIuMDItMTMuMjUyLTIuNDgtMTguNDg4LTEuMDYtMTEuODQ3LTUuMTktMjEuNTYyLTExLjU3LTIxLjkxNnYtLjA3OWMtNi4zOC4zNDQtMTAuNTEgMTAuMDctMTEuNTcgMjEuOTA3LS40NyA1LjI0NS0xLjE5IDE0LjI4My0yLjQ4IDE4LjQ4OC0uMTIuMzUzLS4yNC43MDctLjM3IDEuMDQtMTkuODYgMTAuMTY4IDkuNiAxMS41MDQgMTQuNDIgMTEuNTczdi4wNzljNC44Mi0uMDcgMzQuMjktMS40MDUgMTQuNDItMTEuNTcydi4wMXoiIGZpbGw9IiMxMzE1MjEiLz48cGF0aCBkPSJNMzEuMTggNTguMjI1YzEuNzQtMy4xMiAzLTcuNTEgMy05Ljk3IDAtLjYyLS4wNi0xLjI2LS4xNi0xLjg3LS4zNS0yLjE0LTEuNTItMy41OS0yLjgyLTMuNThoLS4wM2MtMS4zMSAwLTIuNDcgMS40NC0yLjgyIDMuNTgtLjEuNjItLjE2IDEuMjUtLjE2IDEuODcgMCAyLjQ2IDEuMjYgNi44NCAzIDkuOTdoLS4wMXoiIGZpbGw9IiNDMTFDNzkiLz48cGF0aCBkPSJNMzEuMTggNTEuOTM1Yy45NC0xLjY5IDEuNjItNC4wNyAxLjYyLTUuNCAwLS4zNC0uMDMtLjY4LS4wOC0xLjAxLS4xOS0xLjE2LS44Mi0xLjk0LTEuNTMtMS45NGgtLjAyYy0uNzEgMC0xLjM0Ljc4LTEuNTMgMS45NC0uMDYuMzMtLjA4LjY4LS4wOCAxLjAxIDAgMS4zMy42OCAzLjcxIDEuNjIgNS40eiIgZmlsbD0iI0ZGNDM4QiIvPjxwYXRoIGQ9Ik0xMi44NyA1OC42OTZjMS43NC0zLjEyIDMtNy41MSAzLTkuOTcgMC0uNjItLjA2LTEuMjYtLjE2LTEuODctLjM1LTIuMTQtMS41Mi0zLjU5LTIuODItMy41OGgtLjAzYy0xLjMxIDAtMi40NyAxLjQ0LTIuODIgMy41OC0uMS42Mi0uMTYgMS4yNS0uMTYgMS44NyAwIDIuNDYgMS4yNiA2Ljg0IDMgOS45N2gtLjAxeiIgZmlsbD0iI0MxMUM3OSIvPjxwYXRoIGQ9Ik0xMi44NyA1MS45MzZjLjk0LTEuNjkgMS42Mi00LjA3IDEuNjItNS40IDAtLjM0LS4wMy0uNjgtLjA5LTEuMDEtLjE5LTEuMTYtLjgyLTEuOTQtMS41My0xLjk0aC0uMDJjLS43MSAwLTEuMzQuNzgtMS41MyAxLjk0LS4wNi4zMy0uMDkuNjgtLjA5IDEuMDEgMCAxLjMzLjY4IDMuNzEgMS42MiA1LjRoLjAyeiIgZmlsbD0iI0ZGNDM4QiIvPjxwYXRoIGQ9Ik0yMi4wMiA2Ny4yNDZjMi43OC00LjU5IDQuNzktMTEuMDIgNC43OS0xNC42MyAwLS45Mi0uMDktMS44NS0uMjUtMi43NS0uNTctMy4xNC0yLjQzLTUuMjctNC41Mi01LjI2aC0uMDVjLTIuMDktLjAxLTMuOTUgMi4xMS00LjUyIDUuMjYtLjE2LjktLjI1IDEuODMtLjI1IDIuNzUgMCAzLjYxIDIuMDEgMTAuMDUgNC43OSAxNC42M2guMDF6IiBmaWxsPSIjQzExQzc5Ii8+PHBhdGggZD0iTTIyLjAyIDU4LjExNWMxLjI2LTIuMDEgMi4xNy00Ljg0IDIuMTctNi40MyAwLS40LS4wNC0uODEtLjExLTEuMjEtLjI2LTEuMzgtMS4xLTIuMzItMi4wNC0yLjMxaC0uMDJjLS45NCAwLTEuNzkuOTMtMi4wNCAyLjMxLS4wNy40LS4xMS44MS0uMTEgMS4yMSAwIDEuNTkuOTEgNC40MSAyLjE3IDYuNDNoLS4wMnoiIGZpbGw9IiNGRjQzOEIiLz48cGF0aCBkPSJNMjIuMDggMi4xMzVjLTUuODkgMC05LjMxIDEyLjM4LTEwLjEgMjEuNDctLjM0IDMuOTUtLjgzIDExLjA2LTEuNDMgMTQuMzItLjM1IDEuOTQtMS41NSA1LjEyLTEuNjQgNS4zNy01LjM3IDMuMzItNi42MiA0Ljg1LTYuMzEgNS4yMy4xNC4xNiAyLjE5LS4yNSA0LjU4LS43NSAxLjg2LS4zOSAzLjk2LS43NyA1LjMyLTEuNDUgNS4zMS0yLjY0IDQuNTUtOC4xOSA1LjQ2LTguMjIgMS4wNC0uMDMuNDIgMS42NyAxLjE5IDMuNTMgMS4wMyAyLjUgMy43MyA1LjMgMTAuMTQgNC4zNC0xLjc4LTEuNTUtMi4xNi03LjgxLTEuMDQtNy44MS44NCAwIC4xNCAzLjI3IDMuNjIgNi42MSAzLjE5IDMuMDcgOS43NCAzLjk4IDkuNzkgMy43NC4yMi0xLjIxLTQuNDItMy42Ni02LjQ2LTUuMTktMS40MS0zLjMzLTIuNDQtMTcuNTctMi42OC0xOS43Ny0xLjEyLTEwLjI3LTQuMzQtMjEuNDMtMTAuNDMtMjEuNDNsLS4wMS4wMXoiIGZpbGw9IiNFOEU4RTgiLz48cGF0aCBkPSJNMjIuMDggMi4xMzZjNi4wOSAwIDkuMzIgMTEuMTYgMTAuNDMgMjEuNDMuMjQgMi4xOSAxLjI3IDE2LjQ0IDIuNjggMTkuNzcgMi4wNCAxLjUzIDYuNjcgMy45NyA2LjQ2IDUuMTkgMCAuMDItLjA3LjA0LS4yLjA0LTEuMTMgMC02LjcxLTEuMDItOS41OC0zLjc4LTMuNDgtMy4zNS0yLjc4LTYuNjEtMy42Mi02LjYxLTEuMTIgMC0uNzQgNi4yNiAxLjA0IDcuODEtLjg4LjEzLTEuNjkuMTktMi40NC4xOS00LjY4IDAtNi44MS0yLjM4LTcuNy00LjU0LS43Ni0xLjg1LS4xNy0zLjUzLTEuMTctMy41M2gtLjAzYy0uOTEuMDMtLjE1IDUuNTgtNS40NiA4LjIyLTEuMzYuNjgtMy40NSAxLjA1LTUuMzIgMS40NS0yLjAyLjQzLTMuODEuNzktNC4zOS43OS0uMSAwLS4xNy0uMDEtLjE5LS4wNC0uMzEtLjM3Ljk0LTEuOTEgNi4zMS01LjIzLjEtLjI2IDEuMjktMy40MyAxLjY0LTUuMzcuNTktMy4yNiAxLjA4LTEwLjM2IDEuNDMtMTQuMzIuODEtOS4xIDQuMjItMjEuNDcgMTAuMTEtMjEuNDd6bTAtMS42MWMtNy42NiAwLTEwLjk2IDE0LjQyLTExLjcgMjIuOTQtLjA3Ljg0LS4xNSAxLjgxLS4yNCAyLjg2LS4zMiAzLjkyLS43MiA4LjgxLTEuMTcgMTEuMy0uMjQgMS4zMi0uOTcgMy40Ni0xLjM4IDQuNTgtNi4zMSAzLjk2LTYuNTEgNS4xOS02LjYyIDUuODYtLjA5LjU0LjA1IDEuMDcuMzkgMS40OC4zNC40MS44Mi42MiAxLjQzLjYyczEuNzctLjIgNC43Mi0uODNsLjctLjE1YzEuNzgtLjM3IDMuNjMtLjc1IDUtMS40NCAyLjU4LTEuMjggMy45NS0zLjE3IDQuNzQtNC44OCAxLjA0IDIuMDcgMy40NyA0LjkgOC45IDQuOS44NCAwIDEuNzQtLjA3IDIuNjctLjIxbDIuNzYtLjQxYzMuMzggMi4yMyA4LjAyIDMuMDEgOS4xNyAzLjAxIDEuNSAwIDEuNzYtMS4yMyAxLjc5LTEuMzcuMzQtMS45Mi0xLjg4LTMuMzUtNC45Ni01LjM0LS42NC0uNDEtMS4yNS0uOC0xLjc0LTEuMTUtMS4wMS0zLjEtMS44OS0xMy4wNC0yLjIzLTE2Ljg5LS4wOC0uOTQtLjE1LTEuNjYtLjE5LTIuMDMtLjc1LTYuODYtMy40Mi0yMi44NS0xMi4wNC0yMi44NXoiIGZpbGw9IiMxMzE1MjEiLz48cGF0aCBkPSJNMTguMzIgMjEuOTE2YzEuMTMyIDAgMi4wNS0xLjU5OSAyLjA1LTMuNTcgMC0xLjk3Mi0uOTE4LTMuNTctMi4wNS0zLjU3LTEuMTMyIDAtMi4wNSAxLjU5OC0yLjA1IDMuNTcgMCAxLjk3MS45MTggMy41NyAyLjA1IDMuNTd6bTcuNTggMGMxLjEzMiAwIDIuMDUtMS41OTkgMi4wNS0zLjU3IDAtMS45NzItLjkxOC0zLjU3LTIuMDUtMy41Ny0xLjEzMiAwLTIuMDUgMS41OTgtMi4wNSAzLjU3IDAgMS45NzEuOTE4IDMuNTcgMi4wNSAzLjU3eiIgZmlsbD0iIzEzMTUyMSIvPjxwYXRoIG9wYWNpdHk9Ii4yIiBkPSJNMTkuMTYgMzcuNzY2Yy0uMzUgMS41OS0uNDcgMi44NS4zMyA0LjU2LjY5IDEuMjggMS44NyAyLjU0IDMuNzkgMy4yNS0yLjkxLTIuOS0zLjItNC41Mi00LjExLTcuODFoLS4wMXptLTYuMTkgOC4yOWMuNDgtMS42MyAxLTQuODUuOTMtNi41OC0uMDkuNjItMS44NSA0LjIzLTEuODUgNC4yM3MtMS45MSAyLjE3LTQuNDcgMy45N2MxLjc2LS4zNyAzLjY1LS43NCA0LjkxLTEuMzYuMTctLjA4LjMzLS4xNy40OC0uMjZ6bTE1LjgxLTguOTZjMCAxLjIyLjE0IDMuNTggMS4yNyA1LjQyLjQzLjcyIDEuMDEgMS40OSAxLjgxIDIuMjYgMi4zNyAyLjI4IDYuNiAzLjM3IDguNjMgMy42OC03LjI1LTMuNTktOS41NC02LjYxLTExLjcxLTExLjM2eiIgZmlsbD0iIzJFMzA0OCIvPjxwYXRoIGQ9Ik0yMi4yNCAyNS41NTVjLjQ3IDAgLjg1LS40ODMuODUtMS4wOCAwLS41OTYtLjM4LTEuMDgtLjg1LTEuMDhzLS44NS40ODQtLjg1IDEuMDhjMCAuNTk3LjM4IDEuMDguODUgMS4wOHoiIGZpbGw9IiMxMzE1MjEiLz48cGF0aCBkPSJNMjguMDY4IDYuNzI3Yy0xLjU4NC0yLjgxNS0zLjU3Mi00LjYwMi02LjAyNC00LjYwMi0yLjI4IDAtNi41MjYgMi40OTMtOS4wNDkgMTQuNjAxIDMuMDI3LTUuNTUgNS41NS0xMi4xMDggOS4yMS0xMS42NzQgMi4wOCAwIDQuMDM3LjU5NSA1Ljg1MyAxLjY2NWwuMDEuMDF6IiBmaWxsPSIjQzNDM0M4Ii8+PHBhdGggb3BhY2l0eT0iLjQiIGQ9Ik0yMi4wNiA3Ni40NjZjMTAuMjc4IDAgMTguNjEtMS4zODQgMTguNjEtMy4wOSAwLTEuNzA3LTguMzMyLTMuMDktMTguNjEtMy4wOS0xMC4yNzggMC0xOC42MSAxLjM4My0xOC42MSAzLjA5IDAgMS43MDYgOC4zMzIgMy4wOSAxOC42MSAzLjA5eiIgZmlsbD0iIzAwMCIvPjxwYXRoIGQ9Ik02NC4wODQgMTQuOTI4djEwLjE2YzAgLjUyLjAzIDEgLjA4IDEuNDJoLTEuNzhjLS4wNS0uMy0uMDgtLjYzLS4wOC0xLjAxLS4xOS4zNS0uNDkuNjQtLjkuODYtLjQxLjIyLS44Ny4zNC0xLjM5LjM0LTEuMTEgMC0yLjAxLS4zOS0yLjctMS4xNy0uNjktLjc4LTEuMDQtMS43Ni0xLjA0LTIuOTRzLjM1LTIuMTEgMS4wNS0yLjljLjctLjc5IDEuNTktMS4xOCAyLjY2LTEuMTguNjIgMCAxLjEyLjExIDEuNS4zNC4zOC4yMy42NC40OS43OS43OHYtNC43aDEuODF6bS01Ljk1IDcuNjZjMCAuNzUuMTkgMS4zNS41OCAxLjguMzguNDUuODkuNjggMS41Mi42OHMxLjEtLjIzIDEuNDktLjY5Yy4zOS0uNDYuNTgtMS4wNi41OC0xLjgxcy0uMTktMS4zMi0uNTYtMS43NmMtLjM3LS40NC0uODctLjY2LTEuNDktLjY2cy0xLjEyLjIyLTEuNTIuNjZjLS4zOS40NC0uNTkgMS4wMy0uNTkgMS43N2wtLjAxLjAxem0xNi4zIDMuOTJsLTEuMDctMi44NWgtNC44bC0xLjA2IDIuODVoLTIuMDNsNC40Mi0xMS4zNGgyLjIybDQuNDIgMTEuMzRoLTIuMXptLTMuNDctOS4yNmwtMS43MyA0LjY0aDMuNDZsLTEuNzMtNC42NHptOC43MiAxMi4zaC0xLjg0di0xMC44M2gxLjc5djEuMDZjLjItLjM1LjUyLS42NC45Ni0uODguNDQtLjIzLjk0LS4zNSAxLjUyLS4zNSAxLjEyIDAgMiAuMzggMi42NCAxLjE0LjY0Ljc2Ljk2IDEuNzQuOTYgMi45MnMtLjM0IDIuMTYtMS4wMSAyLjk0Yy0uNjcuNzctMS41NiAxLjE2LTIuNjYgMS4xNi0uNTMgMC0xLjAxLS4xLTEuNDItLjMtLjQyLS4yLS43My0uNDYtLjk0LS43N3YzLjkyLS4wMXptNC4xOC02Ljk0YzAtLjcyLS4xOS0xLjMxLS41OC0xLjc1LS4zOC0uNDQtLjg5LS42Ni0xLjUyLS42NnMtMS4xMi4yMi0xLjUxLjY2Yy0uMzkuNDQtLjU4IDEuMDMtLjU4IDEuNzVzLjE5IDEuMzMuNTggMS43OGMuMzkuNDUuODkuNjcgMS41MS42N3MxLjEyLS4yMiAxLjUxLS42N2MuMzktLjQ1LjU4LTEuMDQuNTgtMS43OGguMDF6bTUuNDggNi45NGgtMS44NHYtMTAuODNoMS43OXYxLjA2Yy4yLS4zNS41Mi0uNjQuOTYtLjg4LjQ0LS4yMy45NC0uMzUgMS41Mi0uMzUgMS4xMiAwIDIgLjM4IDIuNjQgMS4xNC42NC43Ni45NiAxLjc0Ljk2IDIuOTJzLS4zNCAyLjE2LTEuMDEgMi45NGMtLjY3Ljc3LTEuNTYgMS4xNi0yLjY2IDEuMTYtLjUzIDAtMS4wMS0uMS0xLjQyLS4zLS40Mi0uMi0uNzMtLjQ2LS45NC0uNzd2My45Mi0uMDF6bTQuMTgtNi45NGMwLS43Mi0uMTktMS4zMS0uNTgtMS43NS0uMzgtLjQ0LS44OS0uNjYtMS41Mi0uNjZzLTEuMTIuMjItMS41MS42NmMtLjM5LjQ0LS41OCAxLjAzLS41OCAxLjc1cy4xOSAxLjMzLjU4IDEuNzhjLjM5LjQ1Ljg5LjY3IDEuNTEuNjdzMS4xMi0uMjIgMS41MS0uNjdjLjM5LS40NS41OC0xLjA0LjU4LTEuNzhoLjAxeiIgZmlsbD0iI0JGQkZCRiIvPjxwYXRoIGQ9Ik02Ny4zODQgNDIuMzZjLjkwNi0uMjk5IDEuNjUtLjg3MSAyLjIzLTEuNy41ODItLjgzLjg3My0xLjc5NS44NzMtMi44OCAwLTEuNzEtLjU4MS0zLjA5NS0xLjczNS00LjE2M0M2Ny41OTggMzIuNTUgNjYgMzIuMDIgNjMuOTY2IDMyLjAyaC03LjY5MnYyMS4zOTJoOC4yNjRjMS45OTIgMCAzLjU5LS41NTYgNC43OTUtMS42NzUgMS4yMDUtMS4xMiAxLjgxMi0yLjU0NyAxLjgxMi00LjMgMC0xLjI5LS4zNi0yLjM4NC0xLjA2OS0zLjMwNy0uNzE3LS45MTQtMS42MTUtMS41MDQtMi43LTEuNzY5aC4wMDh6bS03LTYuODQ2aDIuOTI0Yy45ODIgMCAxLjc0My4yNCAyLjI4Mi43MS41My40Ny44MDMgMS4xMi44MDMgMS45NDkgMCAuODI4LS4yNzQgMS40NzgtLjgxMiAxLjk1Ny0uNTQ3LjQ4Ny0xLjI5LjcyNi0yLjIzLjcyNmgtMi45NTh2LTUuMzQyaC0uMDA4em01Ljc1MiAxMy42ODNjLS41NzIuNDk2LTEuMzU4Ljc0NC0yLjM2Ny43NDRoLTMuMzc2di01LjY0aDMuNDM2YzEuMDA5IDAgMS43ODYuMjY0IDIuMzQyLjc4Ni41NTUuNTIuODI5IDEuMjEzLjgyOSAyLjA4NSAwIC44NzItLjI4MiAxLjU0Ny0uODY0IDIuMDM0di0uMDA5eiIgZmlsbD0idXJsKCNwcmVmaXhfX3ByZWZpeF9fcGFpbnQwX2xpbmVhcl8xMTA2XzU1MjgpIi8+PHBhdGggZD0iTTg0LjQgMzEuNTU4Yy0zLjAxNyAwLTUuNTk4IDEuMDI1LTcuNzUxIDMuMDc2LTIuMTU0IDIuMDUxLTMuMjMgNC43NDMtMy4yMyA4LjA4NSAwIDMuMzQyIDEuMDc2IDYuMDA4IDMuMjMgOC4wNiAyLjE1MyAyLjA1IDQuNzM0IDMuMDc2IDcuNzUxIDMuMDc2IDMuMDE3IDAgNS42MzItMS4wMjUgNy43ODYtMy4wNzYgMi4xNTQtMi4wNTIgMy4yMy00LjczNSAzLjIzLTguMDYgMC0zLjMyNC0xLjA3Ni02LjAzNC0zLjIzLTguMDg1LTIuMTU0LTIuMDUtNC43NDMtMy4wNzYtNy43ODYtMy4wNzZ6bTQuNzEgMTYuNGMtMS4zMjUgMS4yNC0yLjg5OCAxLjg1NS00LjcxIDEuODU1LTEuODExIDAtMy4zNS0uNjE2LTQuNjc1LTEuODU1LTEuMzI0LTEuMjQtMS45OS0yLjk5MS0xLjk5LTUuMjY0IDAtMi4yNzQuNjY2LTQuMDI2IDEuOTktNS4yNjUgMS4zMjUtMS4yNCAyLjg5LTEuODU1IDQuNjc1LTEuODU1IDEuNzg3IDAgMy4zNzYuNjE2IDQuNzEgMS44NTUgMS4zMjQgMS4yNCAxLjk5IDIuOTkxIDEuOTkgNS4yNjQgMCAyLjI3NC0uNjY2IDQuMDI2LTEuOTkgNS4yNjV6IiBmaWxsPSJ1cmwoI3ByZWZpeF9fcHJlZml4X19wYWludDFfbGluZWFyXzExMDZfNTUyOCkiLz48cGF0aCBkPSJNMTA4Ljc0OSAzMS41NThjLTMuMDE3IDAtNS41OTggMS4wMjUtNy43NTEgMy4wNzYtMi4xNTQgMi4wNTEtMy4yMyA0Ljc0My0zLjIzIDguMDg1IDAgMy4zNDIgMS4wNzYgNi4wMDggMy4yMyA4LjA2IDIuMTUzIDIuMDUgNC43MzQgMy4wNzYgNy43NTEgMy4wNzYgMy4wMTcgMCA1LjYzMi0xLjAyNSA3Ljc4Ni0zLjA3NiAyLjE1NC0yLjA1MiAzLjIzMS00LjczNSAzLjIzMS04LjA2IDAtMy4zMjQtMS4wNzctNi4wMzQtMy4yMzEtOC4wODUtMi4xNTQtMi4wNS00Ljc0My0zLjA3Ni03Ljc4Ni0zLjA3NnptNC43MDkgMTYuNGMtMS4zMjQgMS4yNC0yLjg5NyAxLjg1NS00LjcwOSAxLjg1NS0xLjgxMiAwLTMuMzUtLjYxNi00LjY3NS0xLjg1NS0xLjMyNC0xLjI0LTEuOTkxLTIuOTkxLTEuOTkxLTUuMjY0IDAtMi4yNzQuNjY3LTQuMDI2IDEuOTkxLTUuMjY1IDEuMzI1LTEuMjQgMi44ODktMS44NTUgNC42NzUtMS44NTUgMS43ODcgMCAzLjM3Ni42MTYgNC43MDkgMS44NTUgMS4zMjUgMS4yNCAxLjk5MiAyLjk5MSAxLjk5MiA1LjI2NCAwIDIuMjc0LS42NjcgNC4wMjYtMS45OTIgNS4yNjV6IiBmaWxsPSJ1cmwoI3ByZWZpeF9fcHJlZml4X19wYWludDJfbGluZWFyXzExMDZfNTUyOCkiLz48cGF0aCBkPSJNMTMyLjA0NyA0MC45NDFsLTIuOTkxLS41NzJjLTEuNTQ3LS4zLTIuMzI1LTEuMTAzLTIuMzI1LTIuNDEgMC0uNzQ0LjMxNi0xLjM4NS45MzItMS45MTUuNjE1LS41MyAxLjQyNy0uODAzIDIuNDEtLjgwMyAxLjIwNSAwIDIuMTE5LjMxNiAyLjc0My45MzIuNjI0LjYyMyAxLjAwOSAxLjMxNiAxLjE0NSAyLjA4NWwzLjc0NC0xLjE0NWE3LjQyMiA3LjQyMiAwIDAwLS42OTMtMS44OThjLS4zMjQtLjYwNi0uNzc3LTEuMTg4LTEuMzU4LTEuNzY5LS41ODItLjU4LTEuMzU5LTEuMDM0LTIuMzI1LTEuMzc2LS45NjYtLjM0Mi0yLjA2LS41MTItMy4yOTEtLjUxMi0yLjA1MSAwLTMuODAzLjY0LTUuMjY0IDEuOTMxLTEuNDYyIDEuMjktMi4xODggMi44OC0yLjE4OCA0Ljc2OSAwIDEuNTkuNTA0IDIuOTE0IDEuNTA0IDMuOTgzIDEuMDA5IDEuMDY4IDIuMzc2IDEuNzc3IDQuMTAyIDIuMTQ1bDIuOTkyLjYwNmMuODIuMTYzIDEuNDYxLjQ3IDEuOTE0LjkyNGEyLjIgMi4yIDAgMDEuNjc1IDEuNjE1YzAgLjc4Ni0uMzA3IDEuNDE5LS45MjMgMS44OTctLjYxNS40NzktMS40NjEuNzI3LTIuNTQ3LjcyNy0xLjQyNyAwLTIuNTM4LS4zODUtMy4zMzMtMS4xNDYtLjc5NS0uNzYtMS4yMzktMS43MTgtMS4zNDEtMi44NjNsLTMuODYzIDEuMDI2YTYuODMgNi44MyAwIDAwLjY3NSAyLjM0MmMuMzc2Ljc1Mi44ODkgMS40NyAxLjU1NSAyLjE0NS42NjcuNjc1IDEuNTM4IDEuMjEzIDIuNjI0IDEuNjE1IDEuMDg1LjQwMiAyLjI5OS42MDcgMy42NDkuNjA3IDIuMzUxIDAgNC4yMzEtLjY1OCA1LjYyNC0xLjk3NCAxLjM5My0xLjMxNiAyLjA5NC0yLjg2MyAyLjA5NC00LjYzMyAwLTEuNTQ3LS41MjItMi44OTctMS41NzMtNC4wNDJzLTIuNTEzLTEuODk3LTQuNDAxLTIuMjY1bC4wMzQtLjAyNnoiIGZpbGw9InVybCgjcHJlZml4X19wcmVmaXhfX3BhaW50M19saW5lYXJfMTEwNl81NTI4KSIvPjxwYXRoIGQ9Ik0xMzkuNTI1IDM1Ljk1OWg2Ljc2djE3LjQ0M2g0LjE5N1YzNS45Nmg2Ljc1MXYtMy45NWgtMTcuNzA4djMuOTQ5eiIgZmlsbD0idXJsKCNwcmVmaXhfX3ByZWZpeF9fcGFpbnQ0X2xpbmVhcl8xMTA2XzU1MjgpIi8+PHBhdGggZD0iTTE2MC4xNjUgNTMuNDAyaDEzLjM5MlY0OS40OGgtOS4yM3YtNC45NWg4LjM1OHYtMy43MWgtOC4zNTh2LTQuODg4aDkuMjNWMzIuMDFoLTEzLjM5MnYyMS4zOTF6IiBmaWxsPSJ1cmwoI3ByZWZpeF9fcHJlZml4X19wYWludDVfbGluZWFyXzExMDZfNTUyOCkiLz48cGF0aCBkPSJNMTg4LjM0MiA0NC42NWMxLjQxMS0uNDAxIDIuNTIyLTEuMTQ1IDMuMzMzLTIuMjEzLjgxMi0xLjA3NyAxLjIyMy0yLjM1OSAxLjIyMy0zLjg0NiAwLTEuODg5LS42MjQtMy40NjEtMS44NzItNC43MDktMS4yNDgtMS4yNDgtMi44OTctMS44NzEtNC45NDgtMS44NzFoLTguMzU5djIxLjM5MWg0LjE5NnYtOC4yMzloMi4xNzFsNC4xOTcgOC4yNGg0LjY0OWwtNC41OS04Ljc1MnptLS41ODktMy44ODhjLS41OS41NDctMS40MTEuODEyLTIuNDYyLjgxMmgtMy4zNzZ2LTUuOTQ4aDMuMzc2YzEuMDQzIDAgMS44NjMuMjczIDIuNDYyLjgxMi41ODkuNTQ2Ljg4OCAxLjI2NC44ODggMi4xNyAwIC45MDYtLjI5OSAxLjU5OS0uODg4IDIuMTQ2di4wMDh6IiBmaWxsPSJ1cmwoI3ByZWZpeF9fcHJlZml4X19wYWludDZfbGluZWFyXzExMDZfNTUyOCkiLz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9InByZWZpeF9fcHJlZml4X19wYWludDBfbGluZWFyXzExMDZfNTUyOCIgeDE9IjYzLjcxOCIgeTE9Ijg4LjM0IiB4Mj0iNjMuNzE4IiB5Mj0iMjUuOTM0IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agc3RvcC1jb2xvcj0iIzY2MjY4MSIvPjxzdG9wIG9mZnNldD0iLjcyIiBzdG9wLWNvbG9yPSIjQjkxQzdCIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9InByZWZpeF9fcHJlZml4X19wYWludDFfbGluZWFyXzExMDZfNTUyOCIgeDE9Ijg0LjQxOCIgeTE9Ijg4LjM0IiB4Mj0iODQuNDE4IiB5Mj0iMjUuOTM0IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agc3RvcC1jb2xvcj0iIzY2MjY4MSIvPjxzdG9wIG9mZnNldD0iLjcyIiBzdG9wLWNvbG9yPSIjQjkxQzdCIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9InByZWZpeF9fcHJlZml4X19wYWludDJfbGluZWFyXzExMDZfNTUyOCIgeDE9IjEwOC43NjYiIHkxPSIxLjU5NCIgeDI9IjEwOC43NjYiIHkyPSIyNS45MzQiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNjYyNjgxIi8+PHN0b3Agb2Zmc2V0PSIuNzIiIHN0b3AtY29sb3I9IiNCOTFDN0IiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0icHJlZml4X19wcmVmaXhfX3BhaW50M19saW5lYXJfMTEwNl81NTI4IiB4MT0iMTI5LjkwMiIgeTE9Ijg4LjM0IiB4Mj0iMTI5LjkwMiIgeTI9IjI1LjkzNCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2NjI2ODEiLz48c3RvcCBvZmZzZXQ9Ii43MiIgc3RvcC1jb2xvcj0iI0I5MUM3QiIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJwcmVmaXhfX3ByZWZpeF9fcGFpbnQ0X2xpbmVhcl8xMTA2XzU1MjgiIHgxPSIxNDguMzc5IiB5MT0iODguMzQiIHgyPSIxNDguMzc5IiB5Mj0iMjUuOTM0IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agc3RvcC1jb2xvcj0iIzY2MjY4MSIvPjxzdG9wIG9mZnNldD0iLjcyIiBzdG9wLWNvbG9yPSIjQjkxQzdCIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9InByZWZpeF9fcHJlZml4X19wYWludDVfbGluZWFyXzExMDZfNTUyOCIgeDE9IjE2Ni44NjUiIHkxPSI4OC4zNCIgeDI9IjE2Ni44NjUiIHkyPSIyNS45MzQiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNjYyNjgxIi8+PHN0b3Agb2Zmc2V0PSIuNzIiIHN0b3AtY29sb3I9IiNCOTFDN0IiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0icHJlZml4X19wcmVmaXhfX3BhaW50Nl9saW5lYXJfMTEwNl81NTI4IiB4MT0iMTg1LjMyNSIgeTE9Ijg4LjM0IiB4Mj0iMTg1LjMyNSIgeTI9IjI1LjkzNCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2NjI2ODEiLz48c3RvcCBvZmZzZXQ9Ii43MiIgc3RvcC1jb2xvcj0iI0I5MUM3QiIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjwvc3ZnPg==' + +const LogoLight = + 'PHN2ZyB3aWR0aD0iMTkzIiBoZWlnaHQ9Ijc3IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik0zNS41NzQgNDIuNDE2Yy0uMTMtLjM0Ni0uMjYtLjY5MS0uMzctMS4wNDctMS4yODctNC4yMjctMi4wMTUtMTMuMzIyLTIuNDc0LTE4LjU4NkMzMS42NzIgMTAuODczIDI3LjU1MSAxLjEwNSAyMS4xODUuNzVWLjY3Yy02LjM2Ny4zNDYtMTAuNDg4IDEwLjEyMy0xMS41NDYgMjIuMDI0LS40NjkgNS4yNzQtMS4xODcgMTQuMzYtMi40NzQgMTguNTg2LS4xMi4zNTYtLjI0LjcxMS0uMzcgMS4wNDctMTkuODE3IDEwLjIyMiA5LjU4IDExLjU2NSAxNC4zOSAxMS42MzR2LjA3OWM0LjgxLS4wNyAzNC4yMTYtMS40MTIgMTQuMzg5LTExLjYzNHYuMDF6IiBmaWxsPSIjMkUzMDQ4Ii8+PHBhdGggZD0iTTMwLjI4NSA1OC4xMmMxLjczNy0zLjEyIDIuOTk0LTcuNTEgMi45OTQtOS45NyAwLS42Mi0uMDYtMS4yNi0uMTYtMS44Ny0uMzUtMi4xNC0xLjUxNy0zLjU5LTIuODE0LTMuNThoLS4wM2MtMS4zMDcgMC0yLjQ2NCAxLjQ0LTIuODE0IDMuNTgtLjEuNjItLjE2IDEuMjUtLjE2IDEuODcgMCAyLjQ2IDEuMjU4IDYuODQgMi45OTQgOS45N2gtLjAxeiIgZmlsbD0iI0MxMUM3OSIvPjxwYXRoIGQ9Ik0zMC4yODUgNTEuODNjLjkzOC0xLjY5IDEuNjE3LTQuMDcgMS42MTctNS40IDAtLjM0LS4wMy0uNjgtLjA4LTEuMDEtLjE5LTEuMTYtLjgxOC0xLjk0LTEuNTI3LTEuOTRoLS4wMmMtLjcwOCAwLTEuMzM3Ljc4LTEuNTI2IDEuOTQtLjA2LjMzLS4wOC42OC0uMDggMS4wMSAwIDEuMzMuNjc4IDMuNzEgMS42MTYgNS40eiIgZmlsbD0iI0ZGNDM4QiIvPjxwYXRoIGQ9Ik0xMi4wMTQgNTguNTljMS43MzctMy4xMiAyLjk5NC03LjUxIDIuOTk0LTkuOTcgMC0uNjItLjA2LTEuMjYtLjE2LTEuODctLjM0OS0yLjE0LTEuNTE3LTMuNTktMi44MTQtMy41OGgtLjAzYy0xLjMwNyAwLTIuNDY0IDEuNDQtMi44MTQgMy41OC0uMS42Mi0uMTYgMS4yNS0uMTYgMS44NyAwIDIuNDYgMS4yNTggNi44NCAyLjk5NCA5Ljk3aC0uMDF6IiBmaWxsPSIjQzExQzc5Ii8+PHBhdGggZD0iTTEyLjAxNCA1MS44M2MuOTM4LTEuNjkgMS42MTctNC4wNyAxLjYxNy01LjQgMC0uMzQtLjAzLS42OC0uMDktMS4wMS0uMTktMS4xNi0uODE4LTEuOTQtMS41MjctMS45NGgtLjAyYy0uNzA4IDAtMS4zMzcuNzgtMS41MjYgMS45NC0uMDYuMzMtLjA5LjY4LS4wOSAxLjAxIDAgMS4zMy42NzggMy43MSAxLjYxNiA1LjRoLjAyeiIgZmlsbD0iI0ZGNDM4QiIvPjxwYXRoIGQ9Ik0yMS4xNDUgNjcuMTRjMi43NzQtNC41OSA0Ljc4LTExLjAyIDQuNzgtMTQuNjMgMC0uOTItLjA5LTEuODUtLjI1LTIuNzUtLjU2OS0zLjE0LTIuNDI1LTUuMjctNC41MS01LjI2aC0uMDVjLTIuMDg2LS4wMS0zLjk0MiAyLjExLTQuNTEgNS4yNi0uMTYuOS0uMjUgMS44My0uMjUgMi43NSAwIDMuNjEgMi4wMDYgMTAuMDUgNC43OCAxNC42M2guMDF6IiBmaWxsPSIjQzExQzc5Ii8+PHBhdGggZD0iTTIxLjE0NSA1OC4wMWMxLjI1Ny0yLjAxIDIuMTY1LTQuODQgMi4xNjUtNi40MyAwLS40LS4wNC0uODEtLjExLTEuMjEtLjI2LTEuMzgtMS4wOTctMi4zMi0yLjAzNS0yLjMxaC0uMDJjLS45MzggMC0xLjc4Ni45My0yLjAzNiAyLjMxLS4wNy40LS4xMS44MS0uMTEgMS4yMSAwIDEuNTkuOTA4IDQuNDEgMi4xNjYgNi40M2gtLjAyeiIgZmlsbD0iI0ZGNDM4QiIvPjxwYXRoIGQ9Ik0yMS4yMDUgMi4wM2MtNS44NzggMC05LjI5IDEyLjM4LTEwLjA3OSAyMS40Ny0uMzQgMy45NS0uODI4IDExLjA2LTEuNDI3IDE0LjMyLS4zNDkgMS45NC0xLjU0NiA1LjEyLTEuNjM2IDUuMzctNS4zNTkgMy4zMi02LjYwNiA0Ljg1LTYuMjk3IDUuMjMuMTQuMTYgMi4xODYtLjI1IDQuNTctLjc1IDEuODU2LS4zOSAzLjk1Mi0uNzcgNS4zMS0xLjQ1IDUuMjk4LTIuNjQgNC41NC04LjE5IDUuNDQ3LTguMjIgMS4wMzgtLjAzLjQyIDEuNjcgMS4xODggMy41MyAxLjAyOCAyLjUgMy43MjIgNS4zIDEwLjExOCA0LjM0LTEuNzc2LTEuNTUtMi4xNTUtNy44MS0xLjAzOC03LjgxLjgzOSAwIC4xNCAzLjI3IDMuNjEzIDYuNjEgMy4xODMgMy4wNyA5LjcxOSAzLjk4IDkuNzY5IDMuNzQuMjItMS4yMS00LjQxLTMuNjYtNi40NDYtNS4xOS0xLjQwNy0zLjMzLTIuNDM1LTE3LjU3LTIuNjc1LTE5Ljc3LTEuMTE3LTEwLjI3LTQuMzMtMjEuNDMtMTAuNDA3LTIxLjQzbC0uMDEuMDF6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTIxLjIwNSAyLjAzYzYuMDc3IDAgOS4zIDExLjE2IDEwLjQwOCAyMS40My4yNCAyLjE5IDEuMjY3IDE2LjQ0IDIuNjc0IDE5Ljc3IDIuMDM1IDEuNTMgNi42NTYgMy45NyA2LjQ0NiA1LjE5IDAgLjAyLS4wNy4wNC0uMi4wNC0xLjEyNyAwLTYuNjk1LTEuMDItOS41NTktMy43OC0zLjQ3My0zLjM1LTIuNzc0LTYuNjEtMy42MTItNi42MS0xLjExOCAwLS43MzkgNi4yNiAxLjAzOCA3LjgxLS44NzkuMTMtMS42ODcuMTktMi40MzUuMTktNC42NyAwLTYuNzk2LTIuMzgtNy42ODQtNC41NC0uNzU4LTEuODUtLjE3LTMuNTMtMS4xNjctMy41M2gtLjAzYy0uOTA4LjAzLS4xNSA1LjU4LTUuNDQ5IDguMjItMS4zNTcuNjgtMy40NDIgMS4wNS01LjMwOCAxLjQ1LTIuMDE2LjQzLTMuODAyLjc5LTQuMzguNzktLjEgMC0uMTctLjAxLS4xOS0uMDQtLjMxLS4zNy45MzctMS45MSA2LjI5Ni01LjIzLjEtLjI2IDEuMjg3LTMuNDMgMS42MzctNS4zNy41ODgtMy4yNiAxLjA3Ny0xMC4zNiAxLjQyNy0xNC4zMi44MDgtOS4xIDQuMjEtMjEuNDcgMTAuMDg4LTIxLjQ3em0wLTEuNjFDMTMuNTYuNDIgMTAuMjY4IDE0Ljg0IDkuNTMgMjMuMzZjLS4wNy44NC0uMTUgMS44MS0uMjQgMi44Ni0uMzE5IDMuOTItLjcxOCA4LjgxLTEuMTY3IDExLjMtLjI0IDEuMzItLjk2OCAzLjQ2LTEuMzc3IDQuNThDLjQ0OSA0Ni4wNi4yNSA0Ny4yOS4xNCA0Ny45NmMtLjA5LjU0LjA1IDEuMDcuMzkgMS40OC4zMzguNDEuODE3LjYyIDEuNDI2LjYyczEuNzY2LS4yIDQuNzEtLjgzbC42OTgtLjE1YzEuNzc3LS4zNyAzLjYyMy0uNzUgNC45OS0xLjQ0IDIuNTc0LTEuMjggMy45NDEtMy4xNyA0LjczLTQuODggMS4wMzggMi4wNyAzLjQ2MiA0LjkgOC44OCA0LjkuODM5IDAgMS43MzctLjA3IDIuNjY1LS4yMWwyLjc1NC0uNDFjMy4zNzMgMi4yMyA4LjAwMyAzLjAxIDkuMTUgMy4wMSAxLjQ5NyAwIDEuNzU3LTEuMjMgMS43ODctMS4zNy4zMzktMS45Mi0xLjg3Ni0zLjM1LTQuOTUtNS4zNC0uNjM4LS40MS0xLjI0Ny0uOC0xLjczNi0xLjE1LTEuMDA4LTMuMS0xLjg4Ni0xMy4wNC0yLjIyNS0xNi44OS0uMDgtLjk0LS4xNS0xLjY2LS4xOS0yLjAzQzMyLjQ3MSAxNi40MSAyOS44MDYuNDIgMjEuMjA1LjQyeiIgZmlsbD0iIzJFMzA0OCIvPjxwYXRoIGQ9Ik0xNy40NTMgMjEuODFjMS4xMyAwIDIuMDQ1LTEuNTk4IDIuMDQ1LTMuNTcgMC0xLjk3Mi0uOTE2LTMuNTctMi4wNDUtMy41Ny0xLjEzIDAtMi4wNDYgMS41OTgtMi4wNDYgMy41NyAwIDEuOTcyLjkxNiAzLjU3IDIuMDQ2IDMuNTd6bTcuNTY0IDBjMS4xMyAwIDIuMDQ1LTEuNTk4IDIuMDQ1LTMuNTcgMC0xLjk3Mi0uOTE2LTMuNTctMi4wNDYtMy41N3MtMi4wNDUgMS41OTgtMi4wNDUgMy41N2MwIDEuOTcyLjkxNiAzLjU3IDIuMDQ2IDMuNTd6IiBmaWxsPSIjMkUzMDQ4Ii8+PHBhdGggZD0iTTE4LjI5IDM3LjY2Yy0uMzQ4IDEuNTktLjQ2OCAyLjg1LjMzIDQuNTYuNjg5IDEuMjggMS44NjYgMi41NCAzLjc4MiAzLjI1LTIuOTA0LTIuOS0zLjE5My00LjUyLTQuMTAxLTcuODFoLS4wMXptLTYuMTc2IDguMjljLjQ4LTEuNjMuOTk4LTQuODUuOTI4LTYuNTgtLjA5LjYyLTEuODQ2IDQuMjMtMS44NDYgNC4yM3MtMS45MDYgMi4xNy00LjQ2IDMuOTdjMS43NTYtLjM3IDMuNjQyLS43NCA0LjktMS4zNi4xNjktLjA4LjMyOC0uMTcuNDc4LS4yNnptMTUuNzc2LTguOTZjMCAxLjIyLjE0IDMuNTggMS4yNjggNS40Mi40MjkuNzIgMS4wMDggMS40OSAxLjgwNiAyLjI2IDIuMzY1IDIuMjggNi41ODYgMy4zNyA4LjYxMSAzLjY4LTcuMjM0LTMuNTktOS41Mi02LjYxLTExLjY4NS0xMS4zNnoiIGZpbGw9IiNFMkUwRTciLz48cGF0aCBkPSJNMjEuMzY0IDI1LjQ1Yy40NjkgMCAuODQ4LS40ODMuODQ4LTEuMDggMC0uNTk2LS4zOC0xLjA4LS44NDgtMS4wOC0uNDY4IDAtLjg0OC40ODQtLjg0OCAxLjA4IDAgLjU5Ni4zOCAxLjA4Ljg0OCAxLjA4eiIgZmlsbD0iIzJFMzA0OCIvPjxwYXRoIGQ9Ik0yNy4yMzkgNi42MzdjLTEuNTg0LTIuODE1LTMuNTcyLTQuNjAxLTYuMDI0LTQuNjAxLTIuMjggMC02LjUyNyAyLjQ5Mi05LjA1IDE0LjYgMy4wMjgtNS41NSA1LjU1LTEyLjEwOCA5LjIxMS0xMS42NzQgMi4wNzkgMCA0LjAzNi41OTUgNS44NTMgMS42NjVsLjAxLjAxeiIgZmlsbD0iI0UyRTBFNyIvPjxwYXRoIGQ9Ik0yMS4xODUgNzYuMzZjMTAuMjU2IDAgMTguNTctMS4zODMgMTguNTctMy4wOSAwLTEuNzA2LTguMzE0LTMuMDktMTguNTctMy4wOXMtMTguNTcgMS4zODMtMTguNTcgMy4wOWMwIDEuNzA3IDguMzE0IDMuMDkgMTguNTcgMy4wOXoiIGZpbGw9IiNDNUMyQ0IiLz48cGF0aCBkPSJNNjIuOTMgMTQuNzI1djEwLjE2YzAgLjUyLjAzIDEgLjA4IDEuNDJoLTEuNzhjLS4wNS0uMy0uMDgtLjYzLS4wOC0xLjAxLS4xOS4zNS0uNDkuNjQtLjkuODYtLjQxLjIyLS44Ny4zNC0xLjM5LjM0LTEuMTEgMC0yLjAxLS4zOS0yLjctMS4xNy0uNjktLjc4LTEuMDQtMS43Ni0xLjA0LTIuOTRzLjM1LTIuMTEgMS4wNS0yLjljLjctLjc5IDEuNTktMS4xOCAyLjY2LTEuMTguNjIgMCAxLjEyLjExIDEuNS4zNC4zOC4yMy42NC40OS43OS43OHYtNC43aDEuODF6bS01Ljk1IDcuNjZjMCAuNzUuMTkgMS4zNS41OCAxLjguMzguNDUuODkuNjggMS41Mi42OHMxLjEtLjIzIDEuNDktLjY5Yy4zOS0uNDYuNTgtMS4wNi41OC0xLjgxcy0uMTktMS4zMi0uNTYtMS43NmMtLjM3LS40NC0uODctLjY2LTEuNDktLjY2cy0xLjEyLjIyLTEuNTIuNjZjLS4zOS40NC0uNTkgMS4wMy0uNTkgMS43N2wtLjAxLjAxem0xNi4zIDMuOTJsLTEuMDctMi44NWgtNC44bC0xLjA2IDIuODVoLTIuMDNsNC40Mi0xMS4zNGgyLjIybDQuNDIgMTEuMzRoLTIuMXptLTMuNDctOS4yNmwtMS43MyA0LjY0aDMuNDZsLTEuNzMtNC42NHptOC43MiAxMi4zaC0xLjg0di0xMC44M2gxLjc5djEuMDZjLjItLjM1LjUyLS42NC45Ni0uODguNDQtLjIzLjk0LS4zNSAxLjUyLS4zNSAxLjEyIDAgMiAuMzggMi42NCAxLjE0LjY0Ljc2Ljk2IDEuNzQuOTYgMi45MnMtLjM0IDIuMTYtMS4wMSAyLjk0Yy0uNjcuNzctMS41NiAxLjE2LTIuNjYgMS4xNi0uNTMgMC0xLjAxLS4xLTEuNDItLjMtLjQyLS4yLS43My0uNDYtLjk0LS43N3YzLjkyLS4wMXptNC4xOC02Ljk0YzAtLjcyLS4xOS0xLjMxLS41OC0xLjc1LS4zOC0uNDQtLjg5LS42Ni0xLjUyLS42NnMtMS4xMi4yMi0xLjUxLjY2Yy0uMzkuNDQtLjU4IDEuMDMtLjU4IDEuNzVzLjE5IDEuMzMuNTggMS43OGMuMzkuNDUuODkuNjcgMS41MS42N3MxLjEyLS4yMiAxLjUxLS42N2MuMzktLjQ1LjU4LTEuMDQuNTgtMS43OGguMDF6bTUuNDggNi45NGgtMS44NHYtMTAuODNoMS43OXYxLjA2Yy4yLS4zNS41Mi0uNjQuOTYtLjg4LjQ0LS4yMy45NC0uMzUgMS41Mi0uMzUgMS4xMiAwIDIgLjM4IDIuNjQgMS4xNC42NC43Ni45NiAxLjc0Ljk2IDIuOTJzLS4zNCAyLjE2LTEuMDEgMi45NGMtLjY3Ljc3LTEuNTYgMS4xNi0yLjY2IDEuMTYtLjUzIDAtMS4wMS0uMS0xLjQyLS4zLS40Mi0uMi0uNzMtLjQ2LS45NC0uNzd2My45Mi0uMDF6bTQuMTgtNi45NGMwLS43Mi0uMTktMS4zMS0uNTgtMS43NS0uMzgtLjQ0LS44OS0uNjYtMS41Mi0uNjZzLTEuMTIuMjItMS41MS42NmMtLjM5LjQ0LS41OCAxLjAzLS41OCAxLjc1cy4xOSAxLjMzLjU4IDEuNzhjLjM5LjQ1Ljg5LjY3IDEuNTEuNjdzMS4xMi0uMjIgMS41MS0uNjdjLjM5LS40NS41OC0xLjA0LjU4LTEuNzhoLjAxeiIgZmlsbD0iIzJFMzA0OCIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNODMuNDAyIDMxLjM1NWMtMy4wMzQgMC01LjYyOSAxLjAzMS03Ljc5NCAzLjA5NC0yLjE2NiAyLjA2Mi0zLjI0OSA0Ljc3LTMuMjQ5IDguMTMgMCAzLjM2IDEuMDgzIDYuMDQgMy4yNDkgOC4xMDMgMi4xNjUgMi4wNjMgNC43NiAzLjA5NCA3Ljc5NCAzLjA5NHM1LjY2My0xLjAzMSA3LjgyOS0zLjA5NGMyLjE2Ni0yLjA2MiAzLjI0OC00Ljc2IDMuMjQ4LTguMTA0IDAtMy4zNDMtMS4wODItNi4wNjctMy4yNDgtOC4xMy0yLjE2Ni0yLjA2Mi00Ljc3LTMuMDkzLTcuODI5LTMuMDkzem00LjczNSAxNi40OTFjLTEuMzMyIDEuMjQ3LTIuOTEzIDEuODY1LTQuNzM1IDEuODY1LTEuODIyIDAtMy4zNjktLjYxOC00LjctMS44NjUtMS4zMzMtMS4yNDYtMi4wMDMtMy4wMDctMi4wMDMtNS4yOTNzLjY3LTQuMDQ4IDIuMDAyLTUuMjk0YzEuMzMyLTEuMjQ2IDIuOTA1LTEuODY1IDQuNzAxLTEuODY1IDEuNzk2IDAgMy4zOTUuNjE5IDQuNzM1IDEuODY1IDEuMzMyIDEuMjQ2IDIuMDAzIDMuMDA4IDIuMDAzIDUuMjk0IDAgMi4yODYtLjY3IDQuMDQ3LTIuMDAzIDUuMjkzem0tMjEuODQ1LTUuNjI4Yy45MS0uMzAxIDEuNjU5LS44NzcgMi4yNDMtMS43MS41ODQtLjgzNC44NzYtMS44MDUuODc2LTIuODk3IDAtMS43MTgtLjU4NC0zLjExLTEuNzQ0LTQuMTg1LTEuMTYtMS4wNzQtMi43NjctMS42MDctNC44MTMtMS42MDdINTUuMTJ2MjEuNTFoOC4zMWMyLjAwMyAwIDMuNjEtLjU1OCA0LjgyMS0xLjY4NCAxLjIxMi0xLjEyNiAxLjgyMi0yLjU2MSAxLjgyMi00LjMyMyAwLTEuMjk3LS4zNi0yLjM5Ny0xLjA3NC0zLjMyNi0uNzIyLS45Mi0xLjYyNC0xLjUxMi0yLjcxNi0xLjc3OGguMDA5em0tNy4wMy02Ljg4NGgyLjkzYy45ODkgMCAxLjc1NC4yNCAyLjI5NS43MTMuNTMzLjQ3My44MDggMS4xMjYuODA4IDEuOTYgMCAuODMzLS4yNzUgMS40ODYtLjgxNiAxLjk2OC0uNTUuNDktMS4yOTguNzMtMi4yNDMuNzNoLTIuOTc0di01LjM3MXptNS43NzUgMTMuNzU5Yy0uNTc2LjQ5OC0xLjM2Ni43NDctMi4zOC43NDdoLTMuMzk1di01LjY3MmgzLjQ1NWMxLjAxNCAwIDEuNzk2LjI2NyAyLjM1NS43OTEuNTU4LjUyNC44MzMgMS4yMi44MzMgMi4wOTcgMCAuODc2LS4yODMgMS41NTUtLjg2OCAyLjA0NXYtLjAwOHptMzUuMDU0LTE0LjY0NGMyLjE2Ni0yLjA2MyA0Ljc2MS0zLjA5NCA3Ljc5NS0zLjA5NCAzLjA1OSAwIDUuNjYzIDEuMDMxIDcuODI5IDMuMDk0IDIuMTY1IDIuMDYyIDMuMjQ4IDQuNzg3IDMuMjQ4IDguMTMgMCAzLjM0Mi0xLjA4MyA2LjA0LTMuMjQ4IDguMTAzLTIuMTY2IDIuMDYzLTQuNzk2IDMuMDk0LTcuODI5IDMuMDk0LTMuMDM0IDAtNS42MjktMS4wMzEtNy43OTUtMy4wOTQtMi4xNjUtMi4wNjItMy4yNDgtNC43NDMtMy4yNDgtOC4xMDQgMC0zLjM2IDEuMDgyLTYuMDY3IDMuMjQ4LTguMTN6bTcuNzk1IDE1LjI2MmMxLjgyMiAwIDMuNDAzLS42MTggNC43MzUtMS44NjUgMS4zMzItMS4yNDYgMi4wMDItMy4wMDcgMi4wMDItNS4yOTNzLS42Ny00LjA0OC0yLjAwMi01LjI5NGMtMS4zNDEtMS4yNDYtMi45MzktMS44NjUtNC43MzUtMS44NjUtMS43OTYgMC0zLjM2OS42MTktNC43MDEgMS44NjUtMS4zMzIgMS4yNDYtMi4wMDIgMy4wMDgtMi4wMDIgNS4yOTQgMCAyLjI4Ni42NyA0LjA0NyAyLjAwMiA1LjI5MyAxLjMzMiAxLjI0NyAyLjg3OSAxLjg2NSA0LjcwMSAxLjg2NXptMjMuNDI2LTguOTJsLTMuMDA3LS41NzZjLTEuNTU2LS4zLTIuMzM4LTEuMTA4LTIuMzM4LTIuNDIzIDAtLjc0OC4zMTgtMS4zOTIuOTM3LTEuOTI1LjYxOC0uNTMzIDEuNDM1LS44MDggMi40MjMtLjgwOCAxLjIxMiAwIDIuMTMxLjMxOCAyLjc1OS45MzcuNjI3LjYyNyAxLjAxNCAxLjMyMyAxLjE1MSAyLjA5NmwzLjc2NC0xLjE1MWE3LjQ2IDcuNDYgMCAwMC0uNjk2LTEuOTA4Yy0uMzI2LS42MS0uNzgyLTEuMTk0LTEuMzY2LTEuNzc5LS41ODQtLjU4NC0xLjM2Ny0xLjA0LTIuMzM4LTEuMzgzLS45NzEtLjM0NC0yLjA3MS0uNTE2LTMuMzA4LS41MTYtMi4wNjMgMC0zLjgyNC42NDUtNS4yOTQgMS45NDItMS40NjkgMS4yOTgtMi4yIDIuODk2LTIuMiA0Ljc5NiAwIDEuNTk4LjUwNyAyLjkzIDEuNTEzIDQuMDA0IDEuMDE0IDEuMDc0IDIuMzg5IDEuNzg4IDQuMTI1IDIuMTU3bDMuMDA3LjYxYy44MjUuMTY0IDEuNDcuNDczIDEuOTI1LjkyOC40NTYuNDU2LjY3OS45OTcuNjc5IDEuNjI1IDAgLjc5LS4zMDkgMS40MjYtLjkyOCAxLjkwNy0uNjE5LjQ4Mi0xLjQ2OS43MzEtMi41NjEuNzMxLTEuNDM1IDAtMi41NTItLjM4Ny0zLjM1MS0xLjE1Mi0uOC0uNzY0LTEuMjQ2LTEuNzI3LTEuMzUtMi44NzhsLTMuODg0IDEuMDNjLjA3Ny44MDkuMzA5IDEuNTkuNjc5IDIuMzU1YTguNDQzIDguNDQzIDAgMDAxLjU2NCAyLjE1OGMuNjcuNjc4IDEuNTQ3IDEuMjIgMi42MzggMS42MjQgMS4wOTIuNDA0IDIuMzEyLjYxIDMuNjcuNjEgMi4zNjMgMCA0LjI1NC0uNjYyIDUuNjU1LTEuOTg1IDEuNC0xLjMyNCAyLjEwNS0yLjg4IDIuMTA1LTQuNjU4IDAtMS41NTYtLjUyNC0yLjkxMy0xLjU4MS00LjA2NS0xLjA1Ny0xLjE1MS0yLjUyNy0xLjkwOC00LjQyNi0yLjI3N2wuMDM0LS4wMjZ6bTE0LjMxOC01LjAxaC02Ljc5OHYtMy45N2gxNy44MDZ2My45N2gtNi43ODl2MTcuNTRoLTQuMjE5VjM1Ljc4em0xMy45NTYgMTcuNTRoMTMuNDY2di0zLjk0NWgtOS4yODFWNDQuNGg4LjQwNXYtMy43M2gtOC40MDV2LTQuOTE1aDkuMjgxVjMxLjgxaC0xMy40NjZ2MjEuNTF6bTMxLjY4NS0xMS4wMjZjLS44MTYgMS4wNzQtMS45MzQgMS44MjItMy4zNTIgMi4yMjZsNC42MTUgOC44aC00LjY3NWwtNC4yMTktOC4yODVoLTIuMTgzdjguMjg1aC00LjIyVjMxLjgxaDguNDA1YzIuMDYzIDAgMy43MjEuNjI3IDQuOTc2IDEuODgyIDEuMjU1IDEuMjU0IDEuODgyIDIuODM1IDEuODgyIDQuNzM1IDAgMS40OTUtLjQxMyAyLjc4NC0xLjIyOSAzLjg2N3ptLTYuNDItLjg2OGMxLjA1OCAwIDEuODgzLS4yNjcgMi40NzUtLjgxN3YtLjAwOGMuNTkzLS41NS44OTQtMS4yNDYuODk0LTIuMTU3cy0uMzAxLTEuNjMzLS44OTQtMi4xODNjLS42MDEtLjU0MS0xLjQyNi0uODE2LTIuNDc1LS44MTZoLTMuMzk0djUuOThoMy4zOTR6IiBmaWxsPSJ1cmwoI3ByZWZpeF9fcHJlZml4X19wYWludDBfbGluZWFyXzEwNTdfMjUxMykiLz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9InByZWZpeF9fcHJlZml4X19wYWludDBfbGluZWFyXzEwNTdfMjUxMyIgeDE9IjQ4Ljc3OSIgeTE9IjY4LjY5NyIgeDI9IjI1Ni43NjEiIHkyPSIxMC4zMjMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNjYyNjgxIi8+PHN0b3Agb2Zmc2V0PSIuODE3IiBzdG9wLWNvbG9yPSIjQjkxQzdCIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PC9zdmc+' + +/** + * @name Logo + * + * @description Default dAppBooster logo + */ +const Logo: FC = ({ ...restProps }) => ( + +) + +export default Logo diff --git a/src/core/ui/Header/MainMenu.tsx b/src/core/ui/Header/MainMenu.tsx new file mode 100644 index 00000000..edaa0eac --- /dev/null +++ b/src/core/ui/Header/MainMenu.tsx @@ -0,0 +1,98 @@ +import { chakra, Flex, type FlexProps, Link, type LinkProps } from '@chakra-ui/react' +import type { FC } from 'react' + +const GitHub = () => ( + + dAppBooster repository + + +) + +const Docs = () => ( + + dAppBooster Documentation + + +) + +const styles = { + 'html.light &': { + '--background-color': '#E2E0E780', + '--color': '#4B4D60', + }, + 'html.dark &': { + '--background-color': '#24263D', + '--color': '#C5C2CB', + }, +} + +const Button: FC = ({ css, children, ...restProps }) => ( + + {children} + +) + +export const MainMenu: FC = ({ ...restProps }) => { + return ( + + + + + ) +} + +export default MainMenu diff --git a/src/core/ui/Header/MobileMenu/MobileMenu.tsx b/src/core/ui/Header/MobileMenu/MobileMenu.tsx new file mode 100644 index 00000000..deb1b182 --- /dev/null +++ b/src/core/ui/Header/MobileMenu/MobileMenu.tsx @@ -0,0 +1,140 @@ +// TODO(task-3): move to app shell — core/ should not import from wallet/ + +import { Box, chakra, Drawer } from '@chakra-ui/react' +import { useTheme } from 'next-themes' +import { useState } from 'react' +import { ConnectWalletButton } from '@/src/wallet/providers' +import { SwitchThemeButton } from '../../SwitchThemeButton' +import Logo from '../Logo' +import MainMenu from '../MainMenu' +import styles from './styles' + +const MenuIcon = () => ( + + Menu Icon + + +) + +const CloseIcon = () => ( + + Menu Icon + + +) + +const Button = chakra( + 'button', + { + base: { + alignItems: 'center', + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + display: 'flex', + height: '30px', + justifyContent: 'center', + padding: '0', + width: '30px', + + '&:active': { + opacity: '0.7', + }, + }, + }, + { + defaultProps: { + children: , + type: 'button', + }, + }, +) + +export const MobileMenu = () => { + const { setTheme, theme } = useTheme() + const [isOpen, setIsOpen] = useState(false) + + return ( + setIsOpen(e.open)} + > + + + + + + + + + + + setTheme(theme === 'light' ? 'dark' : 'light')} + /> + + + + + ) +} + +export default MobileMenu diff --git a/src/core/ui/Header/MobileMenu/styles.ts b/src/core/ui/Header/MobileMenu/styles.ts new file mode 100644 index 00000000..99f5cac5 --- /dev/null +++ b/src/core/ui/Header/MobileMenu/styles.ts @@ -0,0 +1,12 @@ +export const styles = { + 'html.light &': { + '--background-color': '#f7f7f7', + '--color': '#2e3048', + }, + 'html.dark &': { + '--background-color': '#292b43', + '--color': '#fff', + }, +} + +export default styles diff --git a/src/core/ui/Header/index.tsx b/src/core/ui/Header/index.tsx new file mode 100644 index 00000000..cdde1d29 --- /dev/null +++ b/src/core/ui/Header/index.tsx @@ -0,0 +1,69 @@ +// TODO(task-3): move to app shell — core/ should not import from wallet/ + +import { Box, type BoxProps, chakra, Flex } from '@chakra-ui/react' +import { Link } from '@tanstack/react-router' +import { useTheme } from 'next-themes' +import type { FC } from 'react' +import { ConnectWalletButton } from '@/src/wallet/providers' +import { Inner } from '../Inner' +import { SwitchThemeButton } from '../SwitchThemeButton' +import Logo from './Logo' +import MainMenu from './MainMenu' +import MobileMenu from './MobileMenu/MobileMenu' +import styles from './styles' + +const HomeLink = chakra(Link) + +export const Header: FC = ({ css, ...restProps }) => { + const { setTheme, theme } = useTheme() + + return ( + + + + + + + + + + setTheme(theme === 'light' ? 'dark' : 'light')} /> + + + + + + ) +} + +export default Header diff --git a/src/core/ui/Header/styles.ts b/src/core/ui/Header/styles.ts new file mode 100644 index 00000000..ea3782c3 --- /dev/null +++ b/src/core/ui/Header/styles.ts @@ -0,0 +1,10 @@ +export const styles = { + 'html.light &': { + '--text-color': '#2e3048', + }, + 'html.dark &': { + '--text-color': '#fff', + }, +} + +export default styles diff --git a/src/core/ui/Inner.tsx b/src/core/ui/Inner.tsx new file mode 100644 index 00000000..86e52f62 --- /dev/null +++ b/src/core/ui/Inner.tsx @@ -0,0 +1,17 @@ +import { Flex, type FlexProps } from '@chakra-ui/react' +import type { FC } from 'react' + +export const Inner: FC = ({ children, ...restProps }) => ( + + {children} + +) + +export default Inner diff --git a/src/core/ui/Menu/index.tsx b/src/core/ui/Menu/index.tsx new file mode 100644 index 00000000..ad018d36 --- /dev/null +++ b/src/core/ui/Menu/index.tsx @@ -0,0 +1,50 @@ +import { Menu, type MenuContentProps, type MenuItemProps } from '@chakra-ui/react' +import type { FC } from 'react' +import styles from './styles' + +export const MenuContent: FC = ({ children, css, ...restProps }) => ( + + {children} + +) + +export const MenuItem: FC = ({ children, css, ...restProps }) => ( + + {children} + +) diff --git a/src/core/ui/Menu/styles.ts b/src/core/ui/Menu/styles.ts new file mode 100644 index 00000000..d1bda912 --- /dev/null +++ b/src/core/ui/Menu/styles.ts @@ -0,0 +1,32 @@ +export const styles = { + 'html.light &': { + '--background-color': '#fff', + '--border-color': '#fff', + '--box-shadow': '0 0 20px 0 rgb(0 0 0 / 8%)', + '--item-background-color': 'transparent', + '--item-background-color-hover': 'rgb(0 0 0 / 2%)', + '--item-background-color-active': 'rgb(0 0 0 / 5%)', + '--item-color': '#2e3048', + '--item-color-hover': '#2e3048', + '--item-color-active': '#2e3048', + '--item-border-color': '#f0f0f0', + '--item-border-color-hover': '#f0f0f0', + '--item-border-color-active': '#f0f0f0', + }, + 'html.dark &': { + '--background-color': '#292b43', + '--border-color': '#292b43', + '--box-shadow': '0 9.6px 24px 0 rgb(0 0 0 / 24%)', + '--item-background-color': 'transparent', + '--item-background-color-hover': 'rgb(255 255 255 / 2%)', + '--item-background-color-active': 'rgb(255 255 255 / 5%)', + '--item-color': '#fff', + '--item-color-hover': '#fff', + '--item-color-active': '#fff', + '--item-border-color': '#4b4d60', + '--item-border-color-hover': '#4b4d60', + '--item-border-color-active': '#4b4d60', + }, +} + +export default styles diff --git a/src/core/ui/Modal/index.tsx b/src/core/ui/Modal/index.tsx new file mode 100644 index 00000000..7b5f84bc --- /dev/null +++ b/src/core/ui/Modal/index.tsx @@ -0,0 +1,107 @@ +import { + Card as BaseCard, + type ButtonProps, + type CardRootProps, + chakra, + Heading, + Text, +} from '@chakra-ui/react' +import type { FC, ReactNode } from 'react' +import styles from './styles' + +interface Props extends CardRootProps { + onClose?: () => void + text: string | ReactNode + title: string +} + +const CloseIcon = ({ ...restProps }) => ( + + Close + + +) + +export const CloseButton: FC = ({ children, ...restProps }) => ( + + + +) + +export const Modal: FC = ({ css, children, title, onClose, text, ...restProps }: Props) => { + return ( + + + {title} + + {onClose && onClose()} />} + {children ? children : 'No contents'} + + {text} + + + ) +} + +export default Modal diff --git a/src/core/ui/Modal/styles.ts b/src/core/ui/Modal/styles.ts new file mode 100644 index 00000000..4fa46c11 --- /dev/null +++ b/src/core/ui/Modal/styles.ts @@ -0,0 +1,18 @@ +export const styles = { + 'html.light &': { + '--background-color': '#fff', + '--border-color': '#fff', + '--box-shadow': '0 9.6px 13px 0 rgb(0 0 0 / 8%)', + '--title-color': '#2e3048', + '--text-color': '#4b4d60', + }, + 'html.dark &': { + '--background-color': '#2E3048', + '--border-color': '#292B43', + '--box-shadow': '0 9.6px 13px 0 rgb(0 0 0 / 8%)', + '--title-color': '#fff', + '--text-color': '#fff', + }, +} + +export default styles diff --git a/src/core/ui/NotificationToast.tsx b/src/core/ui/NotificationToast.tsx new file mode 100644 index 00000000..17fab839 --- /dev/null +++ b/src/core/ui/NotificationToast.tsx @@ -0,0 +1,51 @@ +'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({ + 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/core/ui/PrimaryButton/index.tsx b/src/core/ui/PrimaryButton/index.tsx new file mode 100644 index 00000000..b127dc92 --- /dev/null +++ b/src/core/ui/PrimaryButton/index.tsx @@ -0,0 +1,27 @@ +import type { ButtonProps } from '@chakra-ui/react' +import type { FC } from 'react' +import Button from '../Button' +import styles from './styles' + +export const PrimaryButton: FC = ({ css, ...restProps }) => ( + + ) +} + +export default ConnectButton diff --git a/src/wallet/components/ConnectButton/styles.ts b/src/wallet/components/ConnectButton/styles.ts new file mode 100644 index 00000000..7b902f04 --- /dev/null +++ b/src/wallet/components/ConnectButton/styles.ts @@ -0,0 +1,26 @@ +export const styles = { + 'html.light &': { + '--background-color': '#fff', + '--background-color-hover': '#fff', + '--border-color': '#fff', + '--border-color-hover': '#fff', + '--color': '#2e3048', + '--color-hover': '#8b46a4', + '--background-color-disabled': '#fff', + '--border-color-disabled': '#fff', + '--color-disabled': '#c5c2cb', + }, + 'html.dark &': { + '--background-color': '#8b46a4', + '--background-color-hover': '#5f6178', + '--border-color': '#8b46a4', + '--border-color-hover': '#5f6178', + '--color': '#fff', + '--color-hover': '#fff', + '--background-color-disabled': '#8b46a4', + '--border-color-disabled': '#8b46a4', + '--color-disabled': '#c5c2cb', + }, +} + +export default styles diff --git a/src/wallet/components/SwitchChainButton.tsx b/src/wallet/components/SwitchChainButton.tsx new file mode 100644 index 00000000..a450fa10 --- /dev/null +++ b/src/wallet/components/SwitchChainButton.tsx @@ -0,0 +1,14 @@ +import { chakra } from '@chakra-ui/react' +import { PrimaryButton } from '@/src/core/components' + +const SwitchChainButton = chakra(PrimaryButton, { + base: { + fontSize: '16px', + fontWeight: 500, + height: '48px', + paddingLeft: 6, + paddingRight: 6, + }, +}) + +export default SwitchChainButton diff --git a/src/wallet/components/SwitchNetwork.tsx b/src/wallet/components/SwitchNetwork.tsx new file mode 100644 index 00000000..b06129d5 --- /dev/null +++ b/src/wallet/components/SwitchNetwork.tsx @@ -0,0 +1,129 @@ +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, MenuContent, MenuItem } from '@/src/core/components' +import { useWeb3Status } from '../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/wallet/connectors/connectkit.config.tsx b/src/wallet/connectors/connectkit.config.tsx new file mode 100644 index 00000000..897d3831 --- /dev/null +++ b/src/wallet/connectors/connectkit.config.tsx @@ -0,0 +1,100 @@ +import type { ButtonProps } from '@chakra-ui/react' +import { ConnectKitButton, ConnectKitProvider, getDefaultConfig, type Types } from 'connectkit' +import type { FC, ReactNode } from 'react' +import type { Address } from 'viem' +import { normalize } from 'viem/ens' +import { createConfig, useEnsAvatar, useEnsName } from 'wagmi' +import { Avatar } from '@/src/core/components' +import { chains, transports } from '@/src/core/types' +import { env } from '@/src/env' +import ConnectButton from '../components/ConnectButton' + +interface Props { + address: Address + size: number +} + +const UserAvatar: FC = ({ address, size }: Props) => { + const { data: ensName } = useEnsName({ address }) + + const { data: avatarImg } = useEnsAvatar({ + name: ensName ? normalize(ensName) : undefined, + }) + + return ( + + ) +} + +export const WalletProvider = ({ children }: { children: ReactNode }) => { + return ( + , + initialChainId: 0, + enforceSupportedChains: false, + }} + > + {children} + + ) +} + +export const ConnectWalletButton = ({ + label = 'Connect', + ...restProps +}: { label?: string } & ButtonProps) => { + return ( + + {({ address, isConnected, isConnecting, show, truncatedAddress }) => { + return ( + + {isConnected ? ( + <> + {address && ( + + )} + {truncatedAddress} + + ) : ( + label + )} + + ) + }} + + ) +} + +const defaultConfig = { + chains, + transports, + + // Required API Keys + walletConnectProjectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID, + + // Required App Info + appName: env.PUBLIC_APP_NAME, + + // Optional App Info + appDescription: env.PUBLIC_APP_DESCRIPTION, + appUrl: env.PUBLIC_APP_URL, + appIcon: env.PUBLIC_APP_LOGO, +} as const + +const connectkitConfig = getDefaultConfig(defaultConfig) + +export const config = createConfig(connectkitConfig) diff --git a/src/wallet/connectors/portoInit.ts b/src/wallet/connectors/portoInit.ts new file mode 100644 index 00000000..916091de --- /dev/null +++ b/src/wallet/connectors/portoInit.ts @@ -0,0 +1,10 @@ +import { Porto } from 'porto' +import { env } from '@/src/env' + +if (env.PUBLIC_ENABLE_PORTO) { + try { + Porto.create() + } catch (error) { + console.error('Failed to initialize Porto:', error) + } +} diff --git a/src/wallet/connectors/rainbowkit.config.tsx b/src/wallet/connectors/rainbowkit.config.tsx new file mode 100644 index 00000000..0e07dc73 --- /dev/null +++ b/src/wallet/connectors/rainbowkit.config.tsx @@ -0,0 +1,43 @@ +/** + * Uncomment to use dAppBooster with RainbowKit + * version used: 2.0.8 + */ + +// import type { ReactNode } from 'react' + +// import { type AvatarComponent, ConnectButton, RainbowKitProvider } from '@rainbow-me/rainbowkit' +// import { getDefaultConfig } from '@rainbow-me/rainbowkit'; + +// import { env } from '@/src/env' +// import { chains, transports } from '@/src/core' + +// import { Avatar as CustomAvatar } from '@/src/core' + +// export const WalletProvider = ({ children }: { children: ReactNode }) => { +// return ( +// {children} +// ) +// } + +// export const ConnectWalletButton = ({ label = 'Connect' }: { label?: string }) => ( +// +// ) + +// const defaultConfig = { +// chains, +// transports, + +// // Required API Keys +// walletConnectProjectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID, +// projectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID, + +// // Required App Info +// appName: env.PUBLIC_APP_NAME, + +// // Optional App Info +// appDescription: env.PUBLIC_APP_DESCRIPTION, +// appUrl: env.PUBLIC_APP_URL, +// appIcon: env.PUBLIC_APP_LOGO, +// } as const + +// export const config = getDefaultConfig(defaultConfig) diff --git a/src/wallet/connectors/reown.config.tsx b/src/wallet/connectors/reown.config.tsx new file mode 100644 index 00000000..ac6aed6c --- /dev/null +++ b/src/wallet/connectors/reown.config.tsx @@ -0,0 +1,59 @@ +/** + * Uncomment to use dAppBooster with web3Modal + * version used: 4.2.1 + */ + +import { createAppKit } from '@reown/appkit/react' + +import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' +import type { DetailedHTMLProps, FC, HTMLAttributes, PropsWithChildren } from 'react' +import type { Chain } from 'viem' + +import { chains } from '@/src/core/types' +import { env } from '@/src/env' + +export const WalletProvider: FC = ({ children }) => children + +declare global { + namespace JSX { + interface IntrinsicElements { + 'w3m-button': DetailedHTMLProps, HTMLElement> + 'appkit-button': DetailedHTMLProps< + HTMLAttributes & { label?: string }, + HTMLElement + > + } + } +} +export const ConnectWalletButton = ({ label = 'Connect' }: { label?: string }) => ( + +) + +// Required API Keys +const projectId = env.PUBLIC_WALLETCONNECT_PROJECT_ID + +const metadata = { + // Required App Info + name: env.PUBLIC_APP_NAME, + description: env.PUBLIC_APP_DESCRIPTION ?? '', + url: env.PUBLIC_APP_URL ?? '', + icons: [env.PUBLIC_APP_LOGO ?? ''], +} + +// TODO avoid readonly types mismatch +const wagmiAdapter = new WagmiAdapter({ + networks: chains as unknown as Chain[], + projectId, +}) + +createAppKit({ + adapters: [wagmiAdapter], + networks: chains as unknown as [Chain, ...Chain[]], + metadata: metadata, + projectId, + features: { + analytics: true, + }, +}) + +export const config = wagmiAdapter.wagmiConfig diff --git a/src/wallet/hooks.ts b/src/wallet/hooks.ts new file mode 100644 index 00000000..9c9a67cc --- /dev/null +++ b/src/wallet/hooks.ts @@ -0,0 +1,2 @@ +export { useWalletStatus } from './hooks/useWalletStatus' +export { useWeb3Status } from './hooks/useWeb3Status' diff --git a/src/wallet/hooks/useWalletStatus.test.ts b/src/wallet/hooks/useWalletStatus.test.ts new file mode 100644 index 00000000..567635ee --- /dev/null +++ b/src/wallet/hooks/useWalletStatus.test.ts @@ -0,0 +1,160 @@ +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('./useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + appChainId: 1, + isWalletConnected: false, + isWalletSynced: false, + switchChain: mockSwitchChain, + walletChainId: undefined, + })), +})) + +vi.mock('@/src/core/types', () => ({ + 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('./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/wallet/hooks/useWalletStatus.ts b/src/wallet/hooks/useWalletStatus.ts new file mode 100644 index 00000000..0f495c3f --- /dev/null +++ b/src/wallet/hooks/useWalletStatus.ts @@ -0,0 +1,40 @@ +import type { Chain } from 'viem' +import { extractChain } from 'viem' + +import { type ChainsIds, chains } from '@/src/core/types' +import { useWeb3Status } from './useWeb3Status' + +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/wallet/hooks/useWeb3Status.tsx b/src/wallet/hooks/useWeb3Status.tsx new file mode 100644 index 00000000..8d0c7f72 --- /dev/null +++ b/src/wallet/hooks/useWeb3Status.tsx @@ -0,0 +1,138 @@ +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/core/types' + +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/wallet/providers.ts b/src/wallet/providers.ts new file mode 100644 index 00000000..fde1a54f --- /dev/null +++ b/src/wallet/providers.ts @@ -0,0 +1 @@ +export { ConnectWalletButton } from '../sdk/react/components/ConnectWalletButton' diff --git a/src/wallet/providers/Web3Provider.tsx b/src/wallet/providers/Web3Provider.tsx new file mode 100644 index 00000000..54f20b44 --- /dev/null +++ b/src/wallet/providers/Web3Provider.tsx @@ -0,0 +1,35 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { FC, PropsWithChildren } from 'react' +import { WagmiProvider } from 'wagmi' + +import '../connectors/portoInit' +import { ConnectWalletButton, config, WalletProvider } from '../connectors/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/wallet/types.ts b/src/wallet/types.ts new file mode 100644 index 00000000..19e65e8a --- /dev/null +++ b/src/wallet/types.ts @@ -0,0 +1,7 @@ +export type { Networks } from './components/SwitchNetwork' +export type { + AppWeb3Status, + WalletWeb3Status, + Web3Actions, + Web3Status, +} from './hooks/useWeb3Status' diff --git a/typedoc.json b/typedoc.json index d93acffc..c21cc3b1 100644 --- a/typedoc.json +++ b/typedoc.json @@ -23,18 +23,16 @@ "**/*{Provider,Devtools}.tsx", "**/*{styles}.ts", "./src/{vite-env.d.ts,main.tsx,routeTree.gen.ts}", - "./src/lib/{wagmi,wallets}/**/*", + "./src/contracts/{wagmi,generated.ts}/**/*", + "./src/contracts/abis/**/*", + "./src/wallet/connectors/**/*", "./src/routes/**/*", - "./src/utils/logger.ts", - "./src/constants/contracts/**/*", - "./src/hooks/generated.ts", - "./src/subgraphs/**/*", - "./src/components/ui/**/*", + "./src/core/utils/{logger,tokenListsCache}.ts", + "./src/data/**/*", + "./src/core/ui/chakra/**/*", "./src/components/pageComponents/**/*", - "./src/components/sharedComponents/ui/**/*", - "./src/components/sharedComponents/TanStackReactQueryDevtools.tsx", - "./src/components/sharedComponents/TanStackRouterDevtools.tsx", - "./src/components/sharedComponents/TokenInput/Components.tsx" + "./src/core/ui/dev/**/*", + "./src/tokens/components/TokenInput/Components.tsx" ], "highlightLanguages": [ "bash", From f79de5b866972bc66fffdecca9fafb28316a4720 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:08:17 +0200 Subject: [PATCH 02/16] feat: add SDK error classes, ChainDescriptor, ChainRegistry, and getExplorerUrl --- src/sdk/core/chain/descriptor.ts | 67 +++++ src/sdk/core/chain/explorer.test.ts | 160 +++++++++++ src/sdk/core/chain/explorer.ts | 40 +++ src/sdk/core/chain/index.ts | 10 + src/sdk/core/chain/registry.test.ts | 208 ++++++++++++++ src/sdk/core/chain/registry.ts | 85 ++++++ src/sdk/core/errors/index.test.ts | 402 ++++++++++++++++++++++++++++ src/sdk/core/errors/index.ts | 176 ++++++++++++ 8 files changed, 1148 insertions(+) create mode 100644 src/sdk/core/chain/descriptor.ts create mode 100644 src/sdk/core/chain/explorer.test.ts create mode 100644 src/sdk/core/chain/explorer.ts create mode 100644 src/sdk/core/chain/index.ts create mode 100644 src/sdk/core/chain/registry.test.ts create mode 100644 src/sdk/core/chain/registry.ts create mode 100644 src/sdk/core/errors/index.test.ts create mode 100644 src/sdk/core/errors/index.ts diff --git a/src/sdk/core/chain/descriptor.ts b/src/sdk/core/chain/descriptor.ts new file mode 100644 index 00000000..572a2e17 --- /dev/null +++ b/src/sdk/core/chain/descriptor.ts @@ -0,0 +1,67 @@ +/** + * TypeScript interface definitions for chain descriptors in the dAppBooster adapter architecture. + * No runtime code — types only. + */ + +/** Denomination and precision info for a chain's currency. */ +export interface CurrencyInfo { + symbol: string + decimals: number + name?: string +} + +/** Block explorer URL template for a chain. */ +export interface ExplorerConfig { + name?: string + /** Base URL, e.g. 'https://etherscan.io' */ + url: string + /** Path template for transactions, e.g. '/tx/{id}' */ + txPath: string + /** Path template for addresses, e.g. '/address/{id}' */ + addressPath: string + /** Path template for blocks, e.g. '/block/{id}' */ + blockPath?: string + /** Extra query params appended to all URLs, e.g. { cluster: 'mainnet-beta' } for Solana */ + queryParams?: Record +} + +/** RPC / REST / GraphQL endpoint configuration for a chain. */ +export interface EndpointConfig { + url: string + protocol: 'json-rpc' | 'rest' | 'graphql' | 'grpc' | 'websocket' + purpose?: 'default' | 'indexer' | 'archive' | 'streaming' +} + +/** Address format and validation rules for a chain. */ +export interface AddressConfig { + /** Encoding/format family for addresses on this chain. */ + format: 'hex' | 'base58' | 'bech32' | 'bech32m' | 'ss58' | 'named' | 'other' + /** Human-readable part for bech32, or SS58 prefix for Substrate, or Cosmos HRP. */ + prefix?: string + /** One or more regex patterns that a valid address must match. */ + patterns: RegExp[] + example?: string +} + +/** + * Canonical descriptor for a blockchain network. + * Chain-type-agnostic: works for EVM, SVM, Cosmos, MoveVM, etc. + */ +export interface ChainDescriptor { + /** CAIP-2 identifier, e.g. 'eip155:1', 'solana:5eykt4U...', 'cosmos:cosmoshub-4' */ + caip2Id: string + /** Native chain ID — number for EVM, string for Solana (genesis hash) and Cosmos */ + chainId: string | number + name: string + /** VM / execution environment family: 'evm' | 'svm' | 'movevm-sui' | 'movevm-aptos' | 'cosmos' | ... */ + chainType: string + nativeCurrency: CurrencyInfo + /** Fee token when it differs from the native currency (e.g. StarkNet, Berachain). */ + feeCurrency?: CurrencyInfo + explorer?: ExplorerConfig + endpoints?: EndpointConfig[] + addressConfig: AddressConfig + /** URL or data URI for the chain's icon. */ + icon?: string + testnet?: boolean +} diff --git a/src/sdk/core/chain/explorer.test.ts b/src/sdk/core/chain/explorer.test.ts new file mode 100644 index 00000000..13ab47f0 --- /dev/null +++ b/src/sdk/core/chain/explorer.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' + +import type { ChainDescriptor } from './descriptor' +import { getExplorerUrl } from './explorer' +import { createChainRegistry } from './registry' + +const ethereum: ChainDescriptor = { + caip2Id: 'eip155:1', + chainId: 1, + name: 'Ethereum', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + explorer: { + name: 'Etherscan', + url: 'https://etherscan.io', + txPath: '/tx/{id}', + addressPath: '/address/{id}', + blockPath: '/block/{id}', + }, + addressConfig: { + format: 'hex', + patterns: [/^0x[0-9a-fA-F]{40}$/], + }, +} + +const solana: ChainDescriptor = { + caip2Id: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + chainId: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + name: 'Solana', + chainType: 'svm', + nativeCurrency: { symbol: 'SOL', decimals: 9 }, + explorer: { + url: 'https://explorer.solana.com', + txPath: '/tx/{id}', + addressPath: '/address/{id}', + queryParams: { cluster: 'mainnet-beta' }, + }, + addressConfig: { + format: 'base58', + patterns: [/^[1-9A-HJ-NP-Za-km-z]{32,44}$/], + }, +} + +const noExplorer: ChainDescriptor = { + caip2Id: 'eip155:31337', + chainId: 31337, + name: 'Hardhat', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + addressConfig: { + format: 'hex', + patterns: [/^0x[0-9a-fA-F]{40}$/], + }, +} + +const noBlockPath: ChainDescriptor = { + caip2Id: 'eip155:11155111', + chainId: 11155111, + name: 'Sepolia', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + explorer: { + url: 'https://sepolia.etherscan.io', + txPath: '/tx/{id}', + addressPath: '/address/{id}', + // no blockPath + }, + addressConfig: { + format: 'hex', + patterns: [/^0x[0-9a-fA-F]{40}$/], + }, +} + +describe('getExplorerUrl', () => { + const registry = createChainRegistry([ethereum, solana, noExplorer, noBlockPath]) + + describe('tx URLs', () => { + it('returns tx URL with {id} replaced', () => { + const hash = '0xabc123def456' + expect(getExplorerUrl(registry, { chainId: 1, tx: hash })).toBe( + `https://etherscan.io/tx/${hash}`, + ) + }) + + it('returns tx URL for solana with queryParams appended', () => { + const sig = 'SomeSolanaSignature123' + expect( + getExplorerUrl(registry, { chainId: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', tx: sig }), + ).toBe(`https://explorer.solana.com/tx/${sig}?cluster=mainnet-beta`) + }) + }) + + describe('address URLs', () => { + it('returns address URL with {id} replaced', () => { + const addr = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' + expect(getExplorerUrl(registry, { chainId: 1, address: addr })).toBe( + `https://etherscan.io/address/${addr}`, + ) + }) + + it('appends queryParams to address URL', () => { + const addr = 'So11111111111111111111111111111111111111112' + expect( + getExplorerUrl(registry, { chainId: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', address: addr }), + ).toBe(`https://explorer.solana.com/address/${addr}?cluster=mainnet-beta`) + }) + }) + + describe('block URLs', () => { + it('returns block URL with {id} replaced for numeric block', () => { + expect(getExplorerUrl(registry, { chainId: 1, block: 12345678 })).toBe( + 'https://etherscan.io/block/12345678', + ) + }) + + it('returns block URL with {id} replaced for string block', () => { + expect(getExplorerUrl(registry, { chainId: 1, block: '12345678' })).toBe( + 'https://etherscan.io/block/12345678', + ) + }) + }) + + describe('null cases', () => { + it('returns null when chain not found', () => { + expect(getExplorerUrl(registry, { chainId: 999, tx: '0xabc' })).toBeNull() + }) + + it('returns null when chain has no explorer config', () => { + expect(getExplorerUrl(registry, { chainId: 31337, tx: '0xabc' })).toBeNull() + }) + + it('returns null when block URL requested but no blockPath', () => { + expect(getExplorerUrl(registry, { chainId: 11155111, block: 123 })).toBeNull() + }) + }) + + describe('queryParams', () => { + it('appends multiple queryParams correctly', () => { + const multiParamChain: ChainDescriptor = { + caip2Id: 'eip155:1337', + chainId: 1337, + name: 'Test', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + explorer: { + url: 'https://example.com', + txPath: '/tx/{id}', + addressPath: '/address/{id}', + queryParams: { network: 'testnet', lang: 'en' }, + }, + addressConfig: { format: 'hex', patterns: [] }, + } + const testRegistry = createChainRegistry([multiParamChain]) + const result = getExplorerUrl(testRegistry, { chainId: 1337, tx: '0xabc' }) + expect(result).toContain('network=testnet') + expect(result).toContain('lang=en') + expect(result).toMatch(/^https:\/\/example\.com\/tx\/0xabc\?/) + }) + }) +}) diff --git a/src/sdk/core/chain/explorer.ts b/src/sdk/core/chain/explorer.ts new file mode 100644 index 00000000..535ce7bf --- /dev/null +++ b/src/sdk/core/chain/explorer.ts @@ -0,0 +1,40 @@ +import type { ChainRegistry } from './registry' + +type ExplorerParams = + | { chainId: string | number; tx: string } + | { chainId: string | number; address: string } + | { chainId: string | number; block: string | number } + +/** + * Builds an explorer URL for a transaction, address, or block. + * + * Returns null if the chain is not found, has no explorer config, or the + * requested path type (e.g. blockPath) is not defined for that explorer. + */ +export function getExplorerUrl(registry: ChainRegistry, params: ExplorerParams): string | null { + const descriptor = registry.getChain(params.chainId) + + if (!descriptor?.explorer) { + return null + } + + const { explorer } = descriptor + + const { path, value } = (() => { + if ('tx' in params) return { path: explorer.txPath, value: params.tx } + if ('address' in params) return { path: explorer.addressPath, value: params.address } + return { path: explorer.blockPath, value: String(params.block) } + })() + + if (!path) return null + + const resolvedPath = path.replace('{id}', value) + const base = `${explorer.url}${resolvedPath}` + + if (!explorer.queryParams) { + return base + } + + const searchParams = new URLSearchParams(explorer.queryParams) + return `${base}?${searchParams.toString()}` +} diff --git a/src/sdk/core/chain/index.ts b/src/sdk/core/chain/index.ts new file mode 100644 index 00000000..c5d8489a --- /dev/null +++ b/src/sdk/core/chain/index.ts @@ -0,0 +1,10 @@ +export type { + AddressConfig, + ChainDescriptor, + CurrencyInfo, + EndpointConfig, + ExplorerConfig, +} from './descriptor' +export { getExplorerUrl } from './explorer' +export type { ChainRegistry } from './registry' +export { createChainRegistry } from './registry' diff --git a/src/sdk/core/chain/registry.test.ts b/src/sdk/core/chain/registry.test.ts new file mode 100644 index 00000000..1a80ed4e --- /dev/null +++ b/src/sdk/core/chain/registry.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest' + +import { ChainRegistryConflictError } from '../errors' +import type { ChainDescriptor } from './descriptor' +import { createChainRegistry } from './registry' + +const ethereum: ChainDescriptor = { + caip2Id: 'eip155:1', + chainId: 1, + name: 'Ethereum', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18, name: 'Ether' }, + addressConfig: { + format: 'hex', + patterns: [/^0x[0-9a-fA-F]{40}$/], + example: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, +} + +const sepolia: ChainDescriptor = { + caip2Id: 'eip155:11155111', + chainId: 11155111, + name: 'Sepolia', + chainType: 'evm', + testnet: true, + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + addressConfig: { + format: 'hex', + patterns: [/^0x[0-9a-fA-F]{40}$/], + }, +} + +const solana: ChainDescriptor = { + caip2Id: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + chainId: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + name: 'Solana', + chainType: 'svm', + nativeCurrency: { symbol: 'SOL', decimals: 9, name: 'Solana' }, + addressConfig: { + format: 'base58', + patterns: [/^[1-9A-HJ-NP-Za-km-z]{32,44}$/], + example: 'So11111111111111111111111111111111111111112', + }, +} + +describe('createChainRegistry', () => { + describe('getChain', () => { + it('returns descriptor by numeric chainId', () => { + const registry = createChainRegistry([ethereum]) + expect(registry.getChain(1)).toBe(ethereum) + }) + + it('returns descriptor by string chainId', () => { + const registry = createChainRegistry([solana]) + expect(registry.getChain('5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp')).toBe(solana) + }) + + it('returns null for unknown chainId', () => { + const registry = createChainRegistry([ethereum]) + expect(registry.getChain(999)).toBeNull() + }) + + it('returns null for unknown string chainId', () => { + const registry = createChainRegistry([ethereum]) + expect(registry.getChain('unknown')).toBeNull() + }) + + it('works with multiple chains by numeric chainId', () => { + const registry = createChainRegistry([ethereum, sepolia]) + expect(registry.getChain(11155111)).toBe(sepolia) + }) + + it('finds numeric chainId when queried with string equivalent', () => { + const registry = createChainRegistry([ethereum]) + expect(registry.getChain('1')).toBe(ethereum) + }) + + it('finds string chainId when queried with numeric equivalent', () => { + const numericStringChain: ChainDescriptor = { + caip2Id: 'cosmos:cosmoshub-4', + chainId: 'cosmoshub-4', + name: 'Cosmos Hub', + chainType: 'cosmos', + nativeCurrency: { symbol: 'ATOM', decimals: 6 }, + addressConfig: { format: 'bech32', patterns: [] }, + } + const registry = createChainRegistry([numericStringChain]) + // Non-numeric string won't match a number — this is expected to return null + expect(registry.getChain('cosmoshub-4')).toBe(numericStringChain) + }) + }) + + describe('getChainByCaip2', () => { + it('returns descriptor by caip2Id', () => { + const registry = createChainRegistry([ethereum, solana]) + expect(registry.getChainByCaip2('eip155:1')).toBe(ethereum) + }) + + it('returns null for unknown caip2Id', () => { + const registry = createChainRegistry([ethereum]) + expect(registry.getChainByCaip2('eip155:999')).toBeNull() + }) + + it('returns solana descriptor by caip2Id', () => { + const registry = createChainRegistry([solana]) + expect(registry.getChainByCaip2('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp')).toBe(solana) + }) + }) + + describe('getChainType', () => { + it('returns chainType for known chainId', () => { + const registry = createChainRegistry([ethereum]) + expect(registry.getChainType(1)).toBe('evm') + }) + + it('returns chainType for string chainId', () => { + const registry = createChainRegistry([solana]) + expect(registry.getChainType('5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp')).toBe('svm') + }) + + it('returns null for unknown chainId', () => { + const registry = createChainRegistry([ethereum]) + expect(registry.getChainType(999)).toBeNull() + }) + }) + + describe('getChainsByType', () => { + it('returns all chains of a given type', () => { + const registry = createChainRegistry([ethereum, sepolia, solana]) + const evmChains = registry.getChainsByType('evm') + expect(evmChains).toHaveLength(2) + expect(evmChains).toContain(ethereum) + expect(evmChains).toContain(sepolia) + }) + + it('returns empty array for unknown type', () => { + const registry = createChainRegistry([ethereum]) + expect(registry.getChainsByType('cosmos')).toEqual([]) + }) + + it('returns single-element array when only one chain matches', () => { + const registry = createChainRegistry([ethereum, solana]) + expect(registry.getChainsByType('svm')).toEqual([solana]) + }) + }) + + describe('getAllChains', () => { + it('returns all registered chains', () => { + const registry = createChainRegistry([ethereum, sepolia, solana]) + const all = registry.getAllChains() + expect(all).toHaveLength(3) + expect(all).toContain(ethereum) + expect(all).toContain(sepolia) + expect(all).toContain(solana) + }) + + it('returns empty array for empty registry', () => { + const registry = createChainRegistry([]) + expect(registry.getAllChains()).toEqual([]) + }) + }) + + describe('conflict detection', () => { + it('throws ChainRegistryConflictError on duplicate numeric chainId', () => { + const duplicate: ChainDescriptor = { + ...ethereum, + caip2Id: 'eip155:1-duplicate', + name: 'Ethereum Duplicate', + } + expect(() => createChainRegistry([ethereum, duplicate])).toThrow(ChainRegistryConflictError) + }) + + it('throws ChainRegistryConflictError on duplicate string chainId', () => { + const duplicate: ChainDescriptor = { + ...solana, + caip2Id: 'solana:duplicate', + name: 'Solana Duplicate', + } + expect(() => createChainRegistry([solana, duplicate])).toThrow(ChainRegistryConflictError) + }) + + it('throws ChainRegistryConflictError on duplicate caip2Id', () => { + const duplicate: ChainDescriptor = { + ...ethereum, + chainId: 99999, + name: 'Ethereum Duplicate', + } + expect(() => createChainRegistry([ethereum, duplicate])).toThrow(ChainRegistryConflictError) + }) + + it('includes conflicting chainId and caip2Id in the error', () => { + const duplicate: ChainDescriptor = { + ...ethereum, + caip2Id: 'eip155:1-dup', + name: 'Duplicate', + } + try { + createChainRegistry([ethereum, duplicate]) + expect.fail('should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(ChainRegistryConflictError) + const conflictError = error as ChainRegistryConflictError + expect(conflictError.chainId).toBe(1) + expect(conflictError.caip2Id).toBe('eip155:1-dup') + } + }) + }) +}) diff --git a/src/sdk/core/chain/registry.ts b/src/sdk/core/chain/registry.ts new file mode 100644 index 00000000..9c350d1f --- /dev/null +++ b/src/sdk/core/chain/registry.ts @@ -0,0 +1,85 @@ +import { ChainRegistryConflictError } from '../errors' +import type { ChainDescriptor } from './descriptor' + +/** Read-only interface for looking up chain descriptors by various keys. */ +export interface ChainRegistry { + /** Returns the descriptor for the given native chainId, or null if not found. */ + getChain(chainId: string | number): ChainDescriptor | null + /** Returns the descriptor for the given CAIP-2 identifier, or null if not found. */ + getChainByCaip2(caip2Id: string): ChainDescriptor | null + /** Returns the chainType string for the given chainId, or null if not found. */ + getChainType(chainId: string | number): string | null + /** Returns all descriptors whose chainType matches the given value. */ + getChainsByType(chainType: string): ChainDescriptor[] + /** Returns all registered chain descriptors. */ + getAllChains(): ChainDescriptor[] +} + +/** + * Creates an immutable ChainRegistry from the provided descriptors. + * + * Throws ChainRegistryConflictError at construction time if any two descriptors + * share the same chainId or the same caip2Id. + */ +export function createChainRegistry(chains: ChainDescriptor[]): ChainRegistry { + const byChainId = new Map() + const byCaip2Id = new Map() + + for (const descriptor of chains) { + if (byChainId.has(descriptor.chainId)) { + throw new ChainRegistryConflictError({ + chainId: descriptor.chainId, + caip2Id: descriptor.caip2Id, + conflictOn: 'chainId', + }) + } + + if (byCaip2Id.has(descriptor.caip2Id)) { + throw new ChainRegistryConflictError({ + chainId: descriptor.chainId, + caip2Id: descriptor.caip2Id, + conflictOn: 'caip2Id', + }) + } + + byChainId.set(descriptor.chainId, descriptor) + byCaip2Id.set(descriptor.caip2Id, descriptor) + } + + const allChains = [...chains] + + function lookupByChainId(chainId: string | number): ChainDescriptor | null { + const direct = byChainId.get(chainId) + if (direct) { + return direct + } + // Try coerced alternative: string "1" → number 1, or number 1 → string "1" + const alt = typeof chainId === 'string' ? Number(chainId) : String(chainId) + if (typeof chainId === 'string' && Number.isNaN(alt as number)) { + return null + } + return byChainId.get(alt) ?? null + } + + return { + getChain(chainId) { + return lookupByChainId(chainId) + }, + + getChainByCaip2(caip2Id) { + return byCaip2Id.get(caip2Id) ?? null + }, + + getChainType(chainId) { + return lookupByChainId(chainId)?.chainType ?? null + }, + + getChainsByType(chainType) { + return allChains.filter((descriptor) => descriptor.chainType === chainType) + }, + + getAllChains() { + return [...allChains] + }, + } +} diff --git a/src/sdk/core/errors/index.test.ts b/src/sdk/core/errors/index.test.ts new file mode 100644 index 00000000..e38f5ab7 --- /dev/null +++ b/src/sdk/core/errors/index.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it } from 'vitest' +import { + AdapterNotFoundError, + AmbiguousAdapterError, + CapabilityNotSupportedError, + ChainNotSupportedError, + ChainRegistryConflictError, + InsufficientFundsError, + InvalidSignerError, + PreStepsNotExecutedError, + SigningRejectedError, + TransactionNotReadyError, + WalletConnectionRejectedError, + WalletNotConnectedError, + WalletNotInstalledError, +} from './index' + +describe('WalletNotConnectedError', () => { + it('extends Error', () => { + const error = new WalletNotConnectedError() + expect(error).toBeInstanceOf(Error) + }) + + it('is instanceof WalletNotConnectedError', () => { + const error = new WalletNotConnectedError() + expect(error).toBeInstanceOf(WalletNotConnectedError) + }) + + it('sets name to class name', () => { + const error = new WalletNotConnectedError() + expect(error.name).toBe('WalletNotConnectedError') + }) + + it('has a human-readable message', () => { + const error = new WalletNotConnectedError() + expect(error.message.length).toBeGreaterThan(0) + }) + + it('accepts a custom message', () => { + const error = new WalletNotConnectedError('custom message') + expect(error.message).toBe('custom message') + }) +}) + +describe('WalletNotInstalledError', () => { + it('extends Error', () => { + expect(new WalletNotInstalledError()).toBeInstanceOf(Error) + }) + + it('is instanceof WalletNotInstalledError', () => { + expect(new WalletNotInstalledError()).toBeInstanceOf(WalletNotInstalledError) + }) + + it('sets name to class name', () => { + expect(new WalletNotInstalledError().name).toBe('WalletNotInstalledError') + }) + + it('has a human-readable message', () => { + expect(new WalletNotInstalledError().message.length).toBeGreaterThan(0) + }) + + it('accepts a custom message', () => { + const error = new WalletNotInstalledError('MetaMask not found') + expect(error.message).toBe('MetaMask not found') + }) +}) + +describe('WalletConnectionRejectedError', () => { + it('extends Error', () => { + expect(new WalletConnectionRejectedError()).toBeInstanceOf(Error) + }) + + it('is instanceof WalletConnectionRejectedError', () => { + expect(new WalletConnectionRejectedError()).toBeInstanceOf(WalletConnectionRejectedError) + }) + + it('sets name to class name', () => { + expect(new WalletConnectionRejectedError().name).toBe('WalletConnectionRejectedError') + }) + + it('has a human-readable message', () => { + expect(new WalletConnectionRejectedError().message.length).toBeGreaterThan(0) + }) + + it('accepts a custom message', () => { + const error = new WalletConnectionRejectedError('user dismissed the popup') + expect(error.message).toBe('user dismissed the popup') + }) +}) + +describe('ChainNotSupportedError', () => { + it('extends Error', () => { + expect(new ChainNotSupportedError(1)).toBeInstanceOf(Error) + }) + + it('is instanceof ChainNotSupportedError', () => { + expect(new ChainNotSupportedError(1)).toBeInstanceOf(ChainNotSupportedError) + }) + + it('sets name to class name', () => { + expect(new ChainNotSupportedError(1).name).toBe('ChainNotSupportedError') + }) + + it('carries the chainId property', () => { + const error = new ChainNotSupportedError(137) + expect(error.chainId).toBe(137) + }) + + it('includes chainId in the message', () => { + const error = new ChainNotSupportedError(137) + expect(error.message).toContain('137') + }) +}) + +describe('SigningRejectedError', () => { + it('extends Error', () => { + expect(new SigningRejectedError()).toBeInstanceOf(Error) + }) + + it('is instanceof SigningRejectedError', () => { + expect(new SigningRejectedError()).toBeInstanceOf(SigningRejectedError) + }) + + it('sets name to class name', () => { + expect(new SigningRejectedError().name).toBe('SigningRejectedError') + }) + + it('has a human-readable message', () => { + expect(new SigningRejectedError().message.length).toBeGreaterThan(0) + }) + + it('accepts a custom message', () => { + const error = new SigningRejectedError('user closed the signing dialog') + expect(error.message).toBe('user closed the signing dialog') + }) +}) + +describe('CapabilityNotSupportedError', () => { + it('extends Error', () => { + expect(new CapabilityNotSupportedError('signMessage')).toBeInstanceOf(Error) + }) + + it('is instanceof CapabilityNotSupportedError', () => { + expect(new CapabilityNotSupportedError('signMessage')).toBeInstanceOf( + CapabilityNotSupportedError, + ) + }) + + it('sets name to class name', () => { + expect(new CapabilityNotSupportedError('signMessage').name).toBe('CapabilityNotSupportedError') + }) + + it('carries the capability property', () => { + const error = new CapabilityNotSupportedError('batchTransactions') + expect(error.capability).toBe('batchTransactions') + }) + + it('includes capability in the message', () => { + const error = new CapabilityNotSupportedError('batchTransactions') + expect(error.message).toContain('batchTransactions') + }) +}) + +describe('InvalidSignerError', () => { + it('extends Error', () => { + expect(new InvalidSignerError('evm')).toBeInstanceOf(Error) + }) + + it('is instanceof InvalidSignerError', () => { + expect(new InvalidSignerError('evm')).toBeInstanceOf(InvalidSignerError) + }) + + it('sets name to class name', () => { + expect(new InvalidSignerError('evm').name).toBe('InvalidSignerError') + }) + + it('carries the expected property', () => { + const error = new InvalidSignerError('solana') + expect(error.expected).toBe('solana') + }) + + it('includes expected signer type in the message', () => { + const error = new InvalidSignerError('evm') + expect(error.message).toContain('evm') + }) +}) + +describe('InsufficientFundsError', () => { + it('extends Error', () => { + expect(new InsufficientFundsError()).toBeInstanceOf(Error) + }) + + it('is instanceof InsufficientFundsError', () => { + expect(new InsufficientFundsError()).toBeInstanceOf(InsufficientFundsError) + }) + + it('sets name to class name', () => { + expect(new InsufficientFundsError().name).toBe('InsufficientFundsError') + }) + + it('has a human-readable message', () => { + expect(new InsufficientFundsError().message.length).toBeGreaterThan(0) + }) + + it('accepts a custom message', () => { + const error = new InsufficientFundsError('Not enough ETH to cover gas') + expect(error.message).toBe('Not enough ETH to cover gas') + }) +}) + +describe('PreStepsNotExecutedError', () => { + it('extends Error', () => { + expect(new PreStepsNotExecutedError(2)).toBeInstanceOf(Error) + }) + + it('is instanceof PreStepsNotExecutedError', () => { + expect(new PreStepsNotExecutedError(2)).toBeInstanceOf(PreStepsNotExecutedError) + }) + + it('sets name to class name', () => { + expect(new PreStepsNotExecutedError(2).name).toBe('PreStepsNotExecutedError') + }) + + it('carries the pendingCount property', () => { + const error = new PreStepsNotExecutedError(3) + expect(error.pendingCount).toBe(3) + }) + + it('includes pendingCount in the message', () => { + const error = new PreStepsNotExecutedError(3) + expect(error.message).toContain('3') + }) +}) + +describe('ChainRegistryConflictError', () => { + it('extends Error', () => { + expect(new ChainRegistryConflictError({ chainId: 1, caip2Id: 'eip155:1' })).toBeInstanceOf( + Error, + ) + }) + + it('is instanceof ChainRegistryConflictError', () => { + expect(new ChainRegistryConflictError({ chainId: 1, caip2Id: 'eip155:1' })).toBeInstanceOf( + ChainRegistryConflictError, + ) + }) + + it('sets name to class name', () => { + expect(new ChainRegistryConflictError({ chainId: 1, caip2Id: 'eip155:1' }).name).toBe( + 'ChainRegistryConflictError', + ) + }) + + it('carries conflicting descriptor info', () => { + const error = new ChainRegistryConflictError({ chainId: 137, caip2Id: 'eip155:137' }) + expect(error.chainId).toBe(137) + expect(error.caip2Id).toBe('eip155:137') + }) + + it('includes the conflicting chainId in the message when conflictOn is chainId', () => { + const error = new ChainRegistryConflictError({ + chainId: 137, + caip2Id: 'eip155:137', + conflictOn: 'chainId', + }) + expect(error.message).toContain('137') + }) + + it('includes the conflicting caip2Id in the message when conflictOn is caip2Id', () => { + const error = new ChainRegistryConflictError({ + chainId: 137, + caip2Id: 'eip155:137', + conflictOn: 'caip2Id', + }) + expect(error.message).toContain('eip155:137') + }) + + it('exposes the conflictOn property', () => { + const error = new ChainRegistryConflictError({ + chainId: 137, + caip2Id: 'eip155:137', + conflictOn: 'caip2Id', + }) + expect(error.conflictOn).toBe('caip2Id') + }) +}) + +describe('AdapterNotFoundError', () => { + it('extends Error', () => { + expect(new AdapterNotFoundError(42161)).toBeInstanceOf(Error) + }) + + it('is instanceof AdapterNotFoundError', () => { + expect(new AdapterNotFoundError(42161)).toBeInstanceOf(AdapterNotFoundError) + }) + + it('sets name to class name', () => { + expect(new AdapterNotFoundError(42161).name).toBe('AdapterNotFoundError') + }) + + it('carries chainId property', () => { + expect(new AdapterNotFoundError(42161).chainId).toBe(42161) + }) + + it('includes adapter kind in message', () => { + expect(new AdapterNotFoundError(1, 'wallet').message).toContain('wallet') + expect(new AdapterNotFoundError(1, 'transaction').message).toContain('transaction') + }) +}) + +describe('TransactionNotReadyError', () => { + it('extends Error', () => { + expect(new TransactionNotReadyError('not enough gas')).toBeInstanceOf(Error) + }) + + it('is instanceof TransactionNotReadyError', () => { + expect(new TransactionNotReadyError('not enough gas')).toBeInstanceOf(TransactionNotReadyError) + }) + + it('sets name to class name', () => { + expect(new TransactionNotReadyError('not enough gas').name).toBe('TransactionNotReadyError') + }) + + it('carries reason property', () => { + expect(new TransactionNotReadyError('not enough gas').reason).toBe('not enough gas') + }) + + it('includes reason in message', () => { + expect(new TransactionNotReadyError('not enough gas').message).toContain('not enough gas') + }) +}) + +describe('AmbiguousAdapterError', () => { + it('extends Error', () => { + expect(new AmbiguousAdapterError(['evm', 'solana'])).toBeInstanceOf(Error) + }) + + it('is instanceof AmbiguousAdapterError', () => { + expect(new AmbiguousAdapterError(['evm', 'solana'])).toBeInstanceOf(AmbiguousAdapterError) + }) + + it('sets name to class name', () => { + expect(new AmbiguousAdapterError(['evm', 'solana']).name).toBe('AmbiguousAdapterError') + }) + + it('carries availableChainTypes property', () => { + const error = new AmbiguousAdapterError(['evm', 'solana', 'cosmos']) + expect(error.availableChainTypes).toEqual(['evm', 'solana', 'cosmos']) + }) + + it('includes available chain types in the message', () => { + const error = new AmbiguousAdapterError(['evm', 'solana']) + expect(error.message).toContain('evm') + expect(error.message).toContain('solana') + }) +}) + +describe('error independence (instanceof isolation)', () => { + const instances = [ + new WalletNotConnectedError(), + new WalletNotInstalledError(), + new WalletConnectionRejectedError(), + new ChainNotSupportedError(1), + new SigningRejectedError(), + new CapabilityNotSupportedError('x'), + new InvalidSignerError('evm'), + new InsufficientFundsError(), + new PreStepsNotExecutedError(1), + new ChainRegistryConflictError({ chainId: 1, caip2Id: 'eip155:1' }), + new AdapterNotFoundError(1), + new TransactionNotReadyError('reason'), + new AmbiguousAdapterError(['evm']), + ] + + const classes = [ + WalletNotConnectedError, + WalletNotInstalledError, + WalletConnectionRejectedError, + ChainNotSupportedError, + SigningRejectedError, + CapabilityNotSupportedError, + InvalidSignerError, + InsufficientFundsError, + PreStepsNotExecutedError, + ChainRegistryConflictError, + AdapterNotFoundError, + TransactionNotReadyError, + AmbiguousAdapterError, + ] + + it('each instance is only instanceof its own class (no cross-matching)', () => { + for (let i = 0; i < instances.length; i++) { + for (let j = 0; j < classes.length; j++) { + if (i === j) { + expect(instances[i]).toBeInstanceOf(classes[j]) + } else { + expect(instances[i]).not.toBeInstanceOf(classes[j]) + } + } + } + }) +}) diff --git a/src/sdk/core/errors/index.ts b/src/sdk/core/errors/index.ts new file mode 100644 index 00000000..801d3807 --- /dev/null +++ b/src/sdk/core/errors/index.ts @@ -0,0 +1,176 @@ +/** + * SDK typed error classes for the dAppBooster adapter architecture. + * + * Each error extends Error directly, sets `this.name` to its class name, + * and carries typed contextual properties in addition to a human-readable message. + */ + +// --------------------------------------------------------------------------- +// Wallet errors +// --------------------------------------------------------------------------- + +/** Thrown when an operation requires a connected wallet but none is connected. */ +export class WalletNotConnectedError extends Error { + constructor( + message = 'Wallet is not connected. Connect a wallet before performing this action.', + ) { + super(message) + this.name = 'WalletNotConnectedError' + } +} + +/** Thrown when the expected wallet extension is not found in the browser. */ +export class WalletNotInstalledError extends Error { + constructor(message = 'Wallet extension not found. Please install the required wallet.') { + super(message) + this.name = 'WalletNotInstalledError' + } +} + +/** Thrown when the user cancels the wallet connection prompt. */ +export class WalletConnectionRejectedError extends Error { + constructor(message = 'Wallet connection was rejected by the user.') { + super(message) + this.name = 'WalletConnectionRejectedError' + } +} + +/** Thrown when a chainId is not in the adapter's supported chains list. */ +export class ChainNotSupportedError extends Error { + readonly chainId: string | number + + constructor(chainId: string | number) { + super(`Chain ${chainId} is not supported by this adapter.`) + this.name = 'ChainNotSupportedError' + this.chainId = chainId + } +} + +/** Thrown when the user cancels a signing request. */ +export class SigningRejectedError extends Error { + constructor(message = 'Signing was rejected by the user.') { + super(message) + this.name = 'SigningRejectedError' + } +} + +/** Thrown when an optional method is called on an adapter that does not support it. */ +export class CapabilityNotSupportedError extends Error { + readonly capability: string + + constructor(capability: string) { + super(`Capability "${capability}" is not supported by this adapter.`) + this.name = 'CapabilityNotSupportedError' + this.capability = capability + } +} + +// --------------------------------------------------------------------------- +// Transaction errors +// --------------------------------------------------------------------------- + +/** Thrown when the signer type does not match what execute() expects. */ +export class InvalidSignerError extends Error { + readonly expected: string + + constructor(expected: string) { + super(`Invalid signer: expected a "${expected}" signer at the execute() boundary.`) + this.name = 'InvalidSignerError' + this.expected = expected + } +} + +/** Thrown when a balance check fails in prepare(). */ +export class InsufficientFundsError extends Error { + constructor(message = 'Insufficient funds to complete this transaction.') { + super(message) + this.name = 'InsufficientFundsError' + } +} + +/** Thrown when prepare() returns ready: false and execution cannot proceed. */ +export class TransactionNotReadyError extends Error { + readonly reason: string + + constructor(reason: string) { + super(`Transaction not ready: ${reason}`) + this.name = 'TransactionNotReadyError' + this.reason = reason + } +} + +/** Thrown when execute() is called with unexecuted preSteps and autoPreSteps is false. */ +export class PreStepsNotExecutedError extends Error { + readonly pendingCount: number + + constructor(pendingCount: number) { + super( + `Cannot execute: ${pendingCount} pre-step(s) have not been executed. Run all pre-steps first or enable autoPreSteps.`, + ) + this.name = 'PreStepsNotExecutedError' + this.pendingCount = pendingCount + } +} + +// --------------------------------------------------------------------------- +// Registry errors +// --------------------------------------------------------------------------- + +// internal +type ConflictAxis = 'chainId' | 'caip2Id' + +interface ConflictingDescriptor { + chainId: string | number + caip2Id: string + conflictOn?: ConflictAxis +} + +/** Thrown when a duplicate chainId or caip2Id is encountered during registry construction. */ +export class ChainRegistryConflictError extends Error { + readonly chainId: string | number + readonly caip2Id: string + readonly conflictOn: ConflictAxis + + constructor({ chainId, caip2Id, conflictOn = 'chainId' }: ConflictingDescriptor) { + const detail = + conflictOn === 'chainId' + ? `a descriptor with chainId ${chainId} is already registered` + : `a descriptor with caip2Id "${caip2Id}" is already registered` + super(`Chain registry conflict: ${detail}.`) + this.name = 'ChainRegistryConflictError' + this.chainId = chainId + this.caip2Id = caip2Id + this.conflictOn = conflictOn + } +} + +// --------------------------------------------------------------------------- +// Provider errors +// --------------------------------------------------------------------------- + +/** Thrown when no registered adapter supports the requested chain. */ +export class AdapterNotFoundError extends Error { + readonly chainId: string | number + + constructor(chainId: string | number, adapterKind: 'wallet' | 'transaction' = 'transaction') { + super(`No ${adapterKind} adapter found for chain ${chainId}.`) + this.name = 'AdapterNotFoundError' + this.chainId = chainId + } +} + +/** + * Thrown when useWallet() is called with no chain type option but multiple adapters are available, + * making it impossible to resolve unambiguously. + */ +export class AmbiguousAdapterError extends Error { + readonly availableChainTypes: string[] + + constructor(availableChainTypes: string[]) { + super( + `Ambiguous adapter: multiple adapters are available (${availableChainTypes.join(', ')}). Specify a chain type in useWallet() options to disambiguate.`, + ) + this.name = 'AmbiguousAdapterError' + this.availableChainTypes = [...availableChainTypes] + } +} From 3a885334bf5f9b22a77b43793e222eb7ae8b6668 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:08:40 +0200 Subject: [PATCH 03/16] feat: add WalletAdapter, TransactionAdapter, and lifecycle interfaces --- src/sdk/core/adapters/index.test.ts | 86 ++++++++++++++++++++++ src/sdk/core/adapters/index.ts | 28 ++++++++ src/sdk/core/adapters/lifecycle.ts | 35 +++++++++ src/sdk/core/adapters/provider.ts | 47 ++++++++++++ src/sdk/core/adapters/transaction.ts | 85 ++++++++++++++++++++++ src/sdk/core/adapters/wallet.ts | 103 +++++++++++++++++++++++++++ 6 files changed, 384 insertions(+) create mode 100644 src/sdk/core/adapters/index.test.ts create mode 100644 src/sdk/core/adapters/index.ts create mode 100644 src/sdk/core/adapters/lifecycle.ts create mode 100644 src/sdk/core/adapters/provider.ts create mode 100644 src/sdk/core/adapters/transaction.ts create mode 100644 src/sdk/core/adapters/wallet.ts diff --git a/src/sdk/core/adapters/index.test.ts b/src/sdk/core/adapters/index.test.ts new file mode 100644 index 00000000..fee218e0 --- /dev/null +++ b/src/sdk/core/adapters/index.test.ts @@ -0,0 +1,86 @@ +import { describe, it } from 'vitest' +import type { + ChainSigner, + ConfirmOptions, + ConnectOptions, + DAppBoosterConfig, + PrepareResult, + PreStep, + ReadClientFactory, + SignatureResult, + SignMessageInput, + SignTypedDataInput, + TransactionAdapter, + TransactionAdapterMetadata, + TransactionLifecycle, + TransactionParams, + TransactionPhase, + TransactionRef, + TransactionResult, + WalletAdapter, + WalletAdapterBundle, + WalletAdapterMetadata, + WalletConnection, + WalletInfo, + WalletLifecycle, + WalletStatus, +} from './index' + +// This file verifies that all types compile and export correctly. +// No runtime assertions needed — TypeScript compilation is the test. + +describe('adapter interfaces', () => { + it('types compile and export correctly', () => { + // TypeScript compilation verifies all types are exported. + // If any export is missing, tsc will fail when this file is compiled. + // No runtime assertions needed. + const _signer: ChainSigner | undefined = undefined + const _options: ConnectOptions | undefined = undefined + const _connection: WalletConnection | undefined = undefined + const _status: WalletStatus | undefined = undefined + const _signMsg: SignMessageInput | undefined = undefined + const _signTyped: SignTypedDataInput | undefined = undefined + const _sigResult: SignatureResult | undefined = undefined + const _walletInfo: WalletInfo | undefined = undefined + const _walletMeta: WalletAdapterMetadata | undefined = undefined + const _w: WalletAdapter | undefined = undefined + const _preStep: PreStep | undefined = undefined + const _params: TransactionParams | undefined = undefined + const _prepare: PrepareResult | undefined = undefined + const _ref: TransactionRef | undefined = undefined + const _confirmOpts: ConfirmOptions | undefined = undefined + const _result: TransactionResult | undefined = undefined + const _txMeta: TransactionAdapterMetadata | undefined = undefined + const _t: TransactionAdapter | undefined = undefined + const _phase: TransactionPhase | undefined = undefined + const _txLifecycle: TransactionLifecycle | undefined = undefined + const _walletLifecycle: WalletLifecycle | undefined = undefined + const _bundle: WalletAdapterBundle | undefined = undefined + const _factory: ReadClientFactory | undefined = undefined + const _config: DAppBoosterConfig | undefined = undefined + void _signer + void _options + void _connection + void _status + void _signMsg + void _signTyped + void _sigResult + void _walletInfo + void _walletMeta + void _w + void _preStep + void _params + void _prepare + void _ref + void _confirmOpts + void _result + void _txMeta + void _t + void _phase + void _txLifecycle + void _walletLifecycle + void _bundle + void _factory + void _config + }) +}) diff --git a/src/sdk/core/adapters/index.ts b/src/sdk/core/adapters/index.ts new file mode 100644 index 00000000..15fd8950 --- /dev/null +++ b/src/sdk/core/adapters/index.ts @@ -0,0 +1,28 @@ +export type { TransactionLifecycle, TransactionPhase, WalletLifecycle } from './lifecycle' +export type { + DAppBoosterConfig, + ReadClientFactory, + WalletAdapterBundle, +} from './provider' +export type { + ConfirmOptions, + PrepareResult, + PreStep, + TransactionAdapter, + TransactionAdapterMetadata, + TransactionParams, + TransactionRef, + TransactionResult, +} from './transaction' +export type { + ChainSigner, + ConnectOptions, + SignatureResult, + SignMessageInput, + SignTypedDataInput, + WalletAdapter, + WalletAdapterMetadata, + WalletConnection, + WalletInfo, + WalletStatus, +} from './wallet' diff --git a/src/sdk/core/adapters/lifecycle.ts b/src/sdk/core/adapters/lifecycle.ts new file mode 100644 index 00000000..37e4715d --- /dev/null +++ b/src/sdk/core/adapters/lifecycle.ts @@ -0,0 +1,35 @@ +/** + * Lifecycle hook interfaces for wallet and transaction operations. + * No runtime code — types only. + */ + +import type { PrepareResult, PreStep, TransactionRef, TransactionResult } from './transaction' +import type { SignatureResult, SignMessageInput, SignTypedDataInput } from './wallet' + +/** Identifies which phase of the transaction flow an error occurred in. */ +export type TransactionPhase = 'prepare' | 'preStep' | 'submit' | 'confirm' + +/** + * Optional callbacks injected into the transaction execution flow. + * All hooks are fire-and-forget — return values are ignored. + */ +export interface TransactionLifecycle { + onPrepare?: (result: PrepareResult) => void + onPreStep?: (step: PreStep, index: number) => void + onPreStepComplete?: (step: PreStep, index: number, result: TransactionResult) => void + onSubmit?: (ref: TransactionRef) => void + onConfirm?: (result: TransactionResult) => void + onError?: (phase: TransactionPhase, error: Error) => void + /** Called when a transaction is replaced (e.g. speed-up or cancellation). */ + onReplace?: (oldRef: TransactionRef, newRef: TransactionRef, reason: string) => void +} + +/** + * Optional callbacks injected into the wallet signing flow. + * All hooks are fire-and-forget — return values are ignored. + */ +export interface WalletLifecycle { + onSign?: (type: 'message' | 'typedData', input: SignMessageInput | SignTypedDataInput) => void + onSignComplete?: (result: SignatureResult) => void + onSignError?: (error: Error) => void +} diff --git a/src/sdk/core/adapters/provider.ts b/src/sdk/core/adapters/provider.ts new file mode 100644 index 00000000..db115b58 --- /dev/null +++ b/src/sdk/core/adapters/provider.ts @@ -0,0 +1,47 @@ +/** + * Provider and top-level configuration types for the dAppBooster adapter architecture. + * No runtime code — types only. + */ + +import type { FC, ReactNode } from 'react' +import type { ChainDescriptor, EndpointConfig } from '../chain' +import type { TransactionLifecycle, WalletLifecycle } from './lifecycle' +import type { TransactionAdapter } from './transaction' +import type { WalletAdapter } from './wallet' + +/** A wallet adapter paired with its optional React context provider. */ +export interface WalletAdapterBundle { + adapter: WalletAdapter + /** Omit for non-React or headless adapters. */ + Provider?: FC<{ children: ReactNode }> + /** Hook that returns functions to open the connector's connect and account modals. */ + useConnectModal?: () => { open: () => void; openAccount?: () => void } +} + +/** + * Factory for creating chain-type-specific read-only RPC clients. + * Used to configure read operations without requiring a connected wallet. + */ +export interface ReadClientFactory { + readonly chainType: string + createClient(endpoint: EndpointConfig, chainId: string | number): unknown +} + +/** + * Top-level configuration object for DAppBoosterProvider. + * All fields are optional — only configure what your app needs. + */ +export interface DAppBoosterConfig { + /** Wallet adapters keyed by an arbitrary consumer-defined name. */ + wallets?: Record + /** Transaction adapters keyed by chain type (e.g. 'evm', 'svm'). */ + transactions?: Record + /** Chains the app operates on. Merged with each adapter's supportedChains at runtime. */ + chains?: ChainDescriptor[] + /** Factories for constructing read-only RPC clients per chain type. */ + readClientFactories?: ReadClientFactory[] + /** Global transaction lifecycle hooks applied to all transactions. */ + lifecycle?: TransactionLifecycle + /** Global wallet lifecycle hooks applied to all signing operations. */ + walletLifecycle?: WalletLifecycle +} diff --git a/src/sdk/core/adapters/transaction.ts b/src/sdk/core/adapters/transaction.ts new file mode 100644 index 00000000..a137fd00 --- /dev/null +++ b/src/sdk/core/adapters/transaction.ts @@ -0,0 +1,85 @@ +/** + * TransactionAdapter interface and supporting types for the dAppBooster adapter architecture. + * No runtime code — types only. + */ + +import type { ChainDescriptor } from '../chain' +import type { ChainSigner } from './wallet' + +/** A preliminary transaction that must be executed before the main one (e.g. token approval). */ +export interface PreStep { + label: string + params: TransactionParams +} + +/** Chain-agnostic description of a transaction to be prepared or executed. */ +export interface TransactionParams { + chainId: string | number + /** Chain-specific transaction payload (e.g. viem TransactionRequest for EVM). */ + payload: unknown + /** Steps that must succeed before this transaction can be submitted. */ + preSteps?: PreStep[] +} + +/** Result of prepare() — indicates whether the transaction is ready to execute. */ +export interface PrepareResult { + ready: boolean + /** Human-readable reason when ready is false. */ + reason?: string + estimatedFee?: { + amount: string + symbol: string + decimals: number + } +} + +/** Chain-agnostic reference to an in-flight or completed transaction. */ +export interface TransactionRef { + chainType: string + /** Transaction hash or equivalent chain-specific identifier. */ + id: string + chainId: string | number +} + +/** Options for confirm() polling/timeout behaviour. */ +export interface ConfirmOptions { + /** Number of block confirmations to wait for. */ + confirmations?: number + /** Maximum wait time in milliseconds before resolving with status 'timeout'. */ + timeout?: number +} + +/** Final outcome returned by confirm(). */ +export interface TransactionResult { + status: 'success' | 'reverted' | 'timeout' + ref: TransactionRef + /** Chain-specific receipt (e.g. viem TransactionReceipt for EVM). */ + receipt: unknown +} + +/** Static metadata exposed by a TransactionAdapter implementation. */ +export interface TransactionAdapterMetadata { + chainType: string + /** Description of the fee model used (e.g. 'eip1559', 'priorityFee', 'gasless'). */ + feeModel: string + /** Description of the confirmation model used (e.g. 'blockConfirmations', 'finality'). */ + confirmationModel: string +} + +/** + * Adapter interface for submitting and confirming on-chain transactions. + * Works with the ChainSigner produced by the matching WalletAdapter. + * TChainType narrows the chainType discriminant for registry lookups. + */ +export interface TransactionAdapter { + readonly chainType: TChainType + readonly supportedChains: ChainDescriptor[] + readonly metadata: TransactionAdapterMetadata + + /** Validates and estimates a transaction before execution. */ + prepare(params: TransactionParams): Promise + /** Submits the transaction using the provided signer. Returns a ref immediately. */ + execute(params: TransactionParams, signer: ChainSigner): Promise + /** Polls until the transaction reaches a terminal state. */ + confirm(ref: TransactionRef, options?: ConfirmOptions): Promise +} diff --git a/src/sdk/core/adapters/wallet.ts b/src/sdk/core/adapters/wallet.ts new file mode 100644 index 00000000..e1d4b5e3 --- /dev/null +++ b/src/sdk/core/adapters/wallet.ts @@ -0,0 +1,103 @@ +/** + * WalletAdapter interface and supporting types for the dAppBooster adapter architecture. + * No runtime code — types only. + */ + +import type { ChainDescriptor } from '../chain' + +/** + * Opaque signer handle produced by WalletAdapter and consumed by TransactionAdapter. + * Concrete adapter implementations narrow this to their chain-specific signer type. + */ +export type ChainSigner = unknown + +/** Options passed to connect(). */ +export interface ConnectOptions { + /** Preferred chain to activate after connecting. */ + chainId?: string | number +} + +/** Returned by connect() and reconnect() on success. */ +export interface WalletConnection { + accounts: string[] + activeAccount: string + chainId?: string | number +} + +/** Live wallet state snapshot returned by getStatus() and emitted via onStatusChange(). */ +export interface WalletStatus { + connected: boolean + /** null when disconnected. */ + activeAccount: string | null + /** Empty array when disconnected. */ + connectedChainIds: (string | number)[] + connecting: boolean +} + +/** Input for plain message signing. */ +export interface SignMessageInput { + message: string | Uint8Array +} + +/** Input for EIP-712 / structured typed-data signing. */ +export interface SignTypedDataInput { + domain: Record + types: Record + primaryType: string + message: Record +} + +/** Result returned by all signing operations. */ +export interface SignatureResult { + signature: string + address: string + meta?: Record +} + +/** Describes a wallet that can be presented to the user. */ +export interface WalletInfo { + id: string + name: string + icon?: string + installed: boolean + installUrl?: string +} + +/** Static metadata exposed by a WalletAdapter implementation. */ +export interface WalletAdapterMetadata { + chainType: string + capabilities: { + signTypedData: boolean + switchChain: boolean + } + /** Format a raw address for display on this chain type. */ + formatAddress(address: string): string + /** List wallets available in the current environment. */ + availableWallets(): WalletInfo[] +} + +/** + * Adapter interface for wallet connectivity on a single chain type. + * Implementations provide connect/disconnect, signing, and signer access. + * TChainType narrows the chainType discriminant for registry lookups. + */ +export interface WalletAdapter { + readonly chainType: TChainType + readonly supportedChains: ChainDescriptor[] + readonly metadata: WalletAdapterMetadata + + connect(options?: ConnectOptions): Promise + reconnect(): Promise + disconnect(): Promise + + getStatus(): WalletStatus + /** Subscribe to status changes. Returns an unsubscribe function. */ + onStatusChange(listener: (status: WalletStatus) => void): () => void + + signMessage(input: SignMessageInput): Promise + /** Optional — only present when metadata.capabilities.signTypedData is true. */ + signTypedData?(input: SignTypedDataInput): Promise + /** Returns the active chain signer, or null when disconnected. */ + getSigner(): Promise + switchChain(chainId: string | number): Promise +} From 4d2cdb2a476f343ab0a6ec0c8adac45c79c44457 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:08:52 +0200 Subject: [PATCH 04/16] feat: add EVM types, fromViemChain factory, and connector wrappers --- src/sdk/core/evm/chains.test.ts | 138 ++++++++++++++++++ src/sdk/core/evm/chains.ts | 37 +++++ src/sdk/core/evm/connectors/connectkit.tsx | 41 ++++++ .../core/evm/connectors/connectors.test.ts | 54 +++++++ src/sdk/core/evm/connectors/index.ts | 3 + src/sdk/core/evm/connectors/rainbowkit.tsx | 44 ++++++ src/sdk/core/evm/connectors/reown.tsx | 48 ++++++ src/sdk/core/evm/index.ts | 17 +++ src/sdk/core/evm/types.ts | 42 ++++++ 9 files changed, 424 insertions(+) create mode 100644 src/sdk/core/evm/chains.test.ts create mode 100644 src/sdk/core/evm/chains.ts create mode 100644 src/sdk/core/evm/connectors/connectkit.tsx create mode 100644 src/sdk/core/evm/connectors/connectors.test.ts create mode 100644 src/sdk/core/evm/connectors/index.ts create mode 100644 src/sdk/core/evm/connectors/rainbowkit.tsx create mode 100644 src/sdk/core/evm/connectors/reown.tsx create mode 100644 src/sdk/core/evm/index.ts create mode 100644 src/sdk/core/evm/types.ts diff --git a/src/sdk/core/evm/chains.test.ts b/src/sdk/core/evm/chains.test.ts new file mode 100644 index 00000000..aea63844 --- /dev/null +++ b/src/sdk/core/evm/chains.test.ts @@ -0,0 +1,138 @@ +import { mainnet, sepolia } from 'viem/chains' +import { describe, expect, it } from 'vitest' + +import { fromViemChain } from './chains' + +describe('fromViemChain', () => { + describe('chainId and caip2Id', () => { + it('maps chain.id to chainId as a number', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.chainId).toBe(1) + }) + + it('maps caip2Id as eip155:${id}', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.caip2Id).toBe('eip155:1') + }) + + it('maps sepolia chain.id to chainId', () => { + const descriptor = fromViemChain(sepolia) + expect(descriptor.chainId).toBe(11155111) + }) + + it('maps sepolia caip2Id correctly', () => { + const descriptor = fromViemChain(sepolia) + expect(descriptor.caip2Id).toBe('eip155:11155111') + }) + }) + + describe('chainType', () => { + it('sets chainType to "evm"', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.chainType).toBe('evm') + }) + }) + + describe('name', () => { + it('maps chain.name', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.name).toBe('Ethereum') + }) + }) + + describe('nativeCurrency', () => { + it('maps nativeCurrency symbol', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.nativeCurrency.symbol).toBe('ETH') + }) + + it('maps nativeCurrency decimals', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.nativeCurrency.decimals).toBe(18) + }) + + it('maps nativeCurrency name', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.nativeCurrency.name).toBe('Ether') + }) + + it('maps sepolia nativeCurrency name', () => { + const descriptor = fromViemChain(sepolia) + expect(descriptor.nativeCurrency.name).toBe('Sepolia Ether') + }) + }) + + describe('explorer', () => { + it('sets explorer txPath to "/tx/{id}" when blockExplorers exist', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.explorer?.txPath).toBe('/tx/{id}') + }) + + it('sets explorer addressPath to "/address/{id}" when blockExplorers exist', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.explorer?.addressPath).toBe('/address/{id}') + }) + + it('sets explorer blockPath to "/block/{id}" when blockExplorers exist', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.explorer?.blockPath).toBe('/block/{id}') + }) + + it('sets explorer name from chain.blockExplorers.default.name', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.explorer?.name).toBe('Etherscan') + }) + + it('sets explorer url from chain.blockExplorers.default.url', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.explorer?.url).toBe('https://etherscan.io') + }) + + it('sets sepolia explorer url correctly', () => { + const descriptor = fromViemChain(sepolia) + expect(descriptor.explorer?.url).toBe('https://sepolia.etherscan.io') + }) + + it('returns undefined for explorer when chain has no blockExplorers', () => { + const chainWithoutExplorers = { + ...mainnet, + blockExplorers: undefined, + } + const descriptor = fromViemChain(chainWithoutExplorers) + expect(descriptor.explorer).toBeUndefined() + }) + }) + + describe('addressConfig', () => { + it('sets addressConfig.format to "hex"', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.addressConfig.format).toBe('hex') + }) + + it('sets addressConfig.patterns to match EVM addresses', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.addressConfig.patterns).toHaveLength(1) + expect( + descriptor.addressConfig.patterns[0].test('0x0000000000000000000000000000000000000000'), + ).toBe(true) + expect(descriptor.addressConfig.patterns[0].test('not-an-address')).toBe(false) + }) + + it('sets addressConfig.example to zero address', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.addressConfig.example).toBe('0x0000000000000000000000000000000000000000') + }) + }) + + describe('testnet', () => { + it('sets testnet to false when not specified', () => { + const descriptor = fromViemChain(mainnet) + expect(descriptor.testnet).toBe(false) + }) + + it('sets testnet to true when chain.testnet is true', () => { + const descriptor = fromViemChain(sepolia) + expect(descriptor.testnet).toBe(true) + }) + }) +}) diff --git a/src/sdk/core/evm/chains.ts b/src/sdk/core/evm/chains.ts new file mode 100644 index 00000000..5ceb5ca6 --- /dev/null +++ b/src/sdk/core/evm/chains.ts @@ -0,0 +1,37 @@ +import type { Chain } from 'viem' + +import type { ChainDescriptor } from '../chain' + +/** + * Converts a viem Chain object into a ChainDescriptor for use in the dAppBooster adapter layer. + */ +export function fromViemChain(chain: Chain): ChainDescriptor { + const explorer = chain.blockExplorers?.default + ? { + name: chain.blockExplorers.default.name, + url: chain.blockExplorers.default.url, + txPath: '/tx/{id}', + addressPath: '/address/{id}', + blockPath: '/block/{id}', + } + : undefined + + return { + caip2Id: `eip155:${chain.id}`, + chainId: chain.id, + name: chain.name, + chainType: 'evm', + nativeCurrency: { + symbol: chain.nativeCurrency.symbol, + decimals: chain.nativeCurrency.decimals, + name: chain.nativeCurrency.name, + }, + explorer, + addressConfig: { + format: 'hex', + patterns: [/^0x[0-9a-fA-F]{40}$/], + example: '0x0000000000000000000000000000000000000000', + }, + testnet: chain.testnet ?? false, + } +} diff --git a/src/sdk/core/evm/connectors/connectkit.tsx b/src/sdk/core/evm/connectors/connectkit.tsx new file mode 100644 index 00000000..c40ca359 --- /dev/null +++ b/src/sdk/core/evm/connectors/connectkit.tsx @@ -0,0 +1,41 @@ +import { ConnectKitProvider, getDefaultConfig, useModal } from 'connectkit' +import type { FC, ReactNode } from 'react' +import type { Chain, Transport } from 'viem' +import { createConfig } from 'wagmi' +import { env } from '@/src/env' + +import type { EvmConnectorConfig } from '../types' + +const WalletProvider: FC<{ children: ReactNode }> = ({ children }) => ( + + {children} + +) + +function useConnectModal() { + const { setOpen } = useModal() + return { open: () => setOpen(true) } +} + +/** ConnectKit-backed EVM connector. */ +export const connectkitConnector: EvmConnectorConfig = { + createConfig(chains: Chain[], transports: Record) { + const connectkitParams = getDefaultConfig({ + chains: chains as [Chain, ...Chain[]], + transports, + walletConnectProjectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID, + appName: env.PUBLIC_APP_NAME, + appDescription: env.PUBLIC_APP_DESCRIPTION, + appUrl: env.PUBLIC_APP_URL, + appIcon: env.PUBLIC_APP_LOGO, + }) + return createConfig(connectkitParams) + }, + WalletProvider, + useConnectModal, +} diff --git a/src/sdk/core/evm/connectors/connectors.test.ts b/src/sdk/core/evm/connectors/connectors.test.ts new file mode 100644 index 00000000..7e2d148a --- /dev/null +++ b/src/sdk/core/evm/connectors/connectors.test.ts @@ -0,0 +1,54 @@ +import { http } from 'viem' +import { mainnet } from 'viem/chains' +import { describe, expect, it } from 'vitest' + +import { connectkitConnector } from './connectkit' +import { rainbowkitConnector } from './rainbowkit' +import { reownConnector } from './reown' + +describe('connectkitConnector', () => { + it('createConfig returns a wagmi Config containing the supplied chain', () => { + const config = connectkitConnector.createConfig([mainnet], { [mainnet.id]: http() }) + expect(config.chains).toContain(mainnet) + }) + + it('WalletProvider is a function', () => { + expect(typeof connectkitConnector.WalletProvider).toBe('function') + }) + + it('useConnectModal is a function', () => { + expect(typeof connectkitConnector.useConnectModal).toBe('function') + }) +}) + +describe('rainbowkitConnector', () => { + it('createConfig returns a wagmi Config containing the supplied chain', () => { + const config = rainbowkitConnector.createConfig([mainnet], { [mainnet.id]: http() }) + expect(config.chains).toContain(mainnet) + }) + + it('WalletProvider is a function', () => { + expect(typeof rainbowkitConnector.WalletProvider).toBe('function') + }) + + it('useConnectModal is a function', () => { + expect(typeof rainbowkitConnector.useConnectModal).toBe('function') + }) +}) + +describe('reownConnector', () => { + it('createConfig returns a wagmi Config containing the supplied chain id', () => { + const config = reownConnector.createConfig([mainnet], { [mainnet.id]: http() }) + // Reown's WagmiAdapter wraps chains with extra fields (chainNamespace, caipNetworkId, assets), + // so reference equality fails — assert by chain id instead. + expect(config.chains).toContainEqual(expect.objectContaining({ id: mainnet.id })) + }) + + it('WalletProvider is a function', () => { + expect(typeof reownConnector.WalletProvider).toBe('function') + }) + + it('useConnectModal is a function', () => { + expect(typeof reownConnector.useConnectModal).toBe('function') + }) +}) diff --git a/src/sdk/core/evm/connectors/index.ts b/src/sdk/core/evm/connectors/index.ts new file mode 100644 index 00000000..ec866b10 --- /dev/null +++ b/src/sdk/core/evm/connectors/index.ts @@ -0,0 +1,3 @@ +export { connectkitConnector } from './connectkit' +export { rainbowkitConnector } from './rainbowkit' +export { reownConnector } from './reown' diff --git a/src/sdk/core/evm/connectors/rainbowkit.tsx b/src/sdk/core/evm/connectors/rainbowkit.tsx new file mode 100644 index 00000000..76866eb2 --- /dev/null +++ b/src/sdk/core/evm/connectors/rainbowkit.tsx @@ -0,0 +1,44 @@ +import type { AvatarComponent } from '@rainbow-me/rainbowkit' +import { + getDefaultConfig, + RainbowKitProvider, + useAccountModal as useRainbowAccountModal, + useConnectModal as useRainbowConnectModal, +} from '@rainbow-me/rainbowkit' +import { env } from '@/src/env' +import '@rainbow-me/rainbowkit/styles.css' +import type { FC, ReactNode } from 'react' +import type { Chain, Transport } from 'viem' + +import { Avatar as CustomAvatar } from '@/src/core/components' +import type { EvmConnectorConfig } from '../types' + +const WalletProvider: FC<{ children: ReactNode }> = ({ children }) => ( + {children} +) + +function useConnectModal() { + const { openConnectModal } = useRainbowConnectModal() + const { openAccountModal } = useRainbowAccountModal() + return { + open: () => openConnectModal?.(), + openAccount: () => openAccountModal?.(), + } +} + +/** RainbowKit-backed EVM connector. */ +export const rainbowkitConnector: EvmConnectorConfig = { + createConfig(chains: Chain[], transports: Record) { + return getDefaultConfig({ + chains: chains as [Chain, ...Chain[]], + transports, + projectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID, + appName: env.PUBLIC_APP_NAME, + appDescription: env.PUBLIC_APP_DESCRIPTION, + appUrl: env.PUBLIC_APP_URL, + appIcon: env.PUBLIC_APP_LOGO, + }) + }, + WalletProvider, + useConnectModal, +} diff --git a/src/sdk/core/evm/connectors/reown.tsx b/src/sdk/core/evm/connectors/reown.tsx new file mode 100644 index 00000000..460b777a --- /dev/null +++ b/src/sdk/core/evm/connectors/reown.tsx @@ -0,0 +1,48 @@ +import { createAppKit, useAppKit } from '@reown/appkit/react' +import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' +import type { FC, PropsWithChildren } from 'react' +import type { Chain, Transport } from 'viem' +import { env } from '@/src/env' + +import type { EvmConnectorConfig } from '../types' + +const WalletProvider: FC = ({ children }) => <>{children} + +function useConnectModal() { + const { open } = useAppKit() + return { open } +} + +/** Reown/AppKit-backed EVM connector. */ +export const reownConnector: EvmConnectorConfig = { + createConfig(chains: Chain[], transports: Record) { + const projectId = env.PUBLIC_WALLETCONNECT_PROJECT_ID + + const metadata = { + name: env.PUBLIC_APP_NAME, + description: env.PUBLIC_APP_DESCRIPTION ?? '', + url: env.PUBLIC_APP_URL ?? '', + icons: [env.PUBLIC_APP_LOGO ?? ''], + } + + const wagmiAdapter = new WagmiAdapter({ + networks: chains as unknown as Chain[], + transports, + projectId, + }) + + createAppKit({ + adapters: [wagmiAdapter], + networks: chains as unknown as [Chain, ...Chain[]], + metadata, + projectId, + features: { + analytics: true, + }, + }) + + return wagmiAdapter.wagmiConfig + }, + WalletProvider, + useConnectModal, +} diff --git a/src/sdk/core/evm/index.ts b/src/sdk/core/evm/index.ts new file mode 100644 index 00000000..dca95906 --- /dev/null +++ b/src/sdk/core/evm/index.ts @@ -0,0 +1,17 @@ +export { fromViemChain } from './chains' +export { connectkitConnector, rainbowkitConnector, reownConnector } from './connectors' +export type { ApprovalPreStepParams, PermitPreStepParams } from './pre-steps' +export { createApprovalPreStep, createPermitPreStep } from './pre-steps' +export type { EvmServerWalletConfig } from './server-wallet' +export { createEvmServerWallet } from './server-wallet' +export type { EvmTransactionConfig } from './transaction' +export { createEvmTransactionAdapter } from './transaction' +export type { + EvmConnectorConfig, + EvmContractCall, + EvmCoreConnectorConfig, + EvmRawTransaction, + EvmTransactionPayload, +} from './types' +export type { EvmWalletConfig } from './wallet' +export { createEvmWalletAdapter } from './wallet' diff --git a/src/sdk/core/evm/types.ts b/src/sdk/core/evm/types.ts new file mode 100644 index 00000000..ec9d814f --- /dev/null +++ b/src/sdk/core/evm/types.ts @@ -0,0 +1,42 @@ +import type { FC, ReactNode } from 'react' +import type { Abi, Address, Chain, Hex, Transport } from 'viem' +import type { Config } from 'wagmi' + +/** Raw EVM transaction — direct calldata to an address. */ +export interface EvmRawTransaction { + to: Address + data?: Hex + value?: bigint + gas?: bigint + maxFeePerGas?: bigint + maxPriorityFeePerGas?: bigint +} + +/** EVM contract call — typed ABI invocation. */ +export interface EvmContractCall { + contract: { + address: Address + abi: Abi + functionName: string + args?: unknown[] + } + value?: bigint + gas?: bigint + maxFeePerGas?: bigint + maxPriorityFeePerGas?: bigint +} + +/** EVM transaction payload — discriminated union of raw tx or contract call. */ +export type EvmTransactionPayload = EvmRawTransaction | EvmContractCall + +/** Core (framework-agnostic) EVM connector config. */ +export interface EvmCoreConnectorConfig { + createConfig(chains: Chain[], transports: Record): Config +} + +/** React-layer EVM connector config — extends core with UI components. */ +export interface EvmConnectorConfig extends EvmCoreConnectorConfig { + WalletProvider: FC<{ children: ReactNode }> + /** Hook that returns functions to open the connector's connect and account modals. */ + useConnectModal: () => { open: () => void; openAccount?: () => void } +} From 7f92183e5dbb7a027ff0595a665420142052cb65 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:09:08 +0200 Subject: [PATCH 05/16] feat: implement createEvmWalletAdapter with wagmi/actions --- src/sdk/core/evm/wallet.integration.test.ts | 64 ++++ src/sdk/core/evm/wallet.test.ts | 308 ++++++++++++++++++++ src/sdk/core/evm/wallet.tsx | 267 +++++++++++++++++ 3 files changed, 639 insertions(+) create mode 100644 src/sdk/core/evm/wallet.integration.test.ts create mode 100644 src/sdk/core/evm/wallet.test.ts create mode 100644 src/sdk/core/evm/wallet.tsx diff --git a/src/sdk/core/evm/wallet.integration.test.ts b/src/sdk/core/evm/wallet.integration.test.ts new file mode 100644 index 00000000..45a8c3f4 --- /dev/null +++ b/src/sdk/core/evm/wallet.integration.test.ts @@ -0,0 +1,64 @@ +/** + * Integration tests for createEvmWalletAdapter. + * Uses real wagmi config with the wagmi mock connector — no module mocking. + */ + +import { http } from 'viem' +import { mainnet } from 'viem/chains' +import { describe, expect, it } from 'vitest' +import { createConfig } from 'wagmi' +import { mock } from 'wagmi/connectors' + +import type { WalletStatus } from '../adapters/wallet' +import { connectkitConnector } from './connectors' +import { createEvmWalletAdapter } from './wallet' + +const TEST_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as const + +function makeRealConfig() { + return createConfig({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + connectors: [mock({ accounts: [TEST_ADDRESS] })], + }) +} + +describe('createEvmWalletAdapter — integration tests', () => { + it('connect → getStatus → disconnect lifecycle', async () => { + const wagmiConfig = makeRealConfig() + const { adapter } = createEvmWalletAdapter({ + connector: connectkitConnector, + chains: [mainnet], + transports: { [mainnet.id]: http() }, + wagmiConfig, + }) + + expect(adapter.getStatus().connected).toBe(false) + + await adapter.connect() + expect(adapter.getStatus().connected).toBe(true) + expect(adapter.getStatus().activeAccount?.toLowerCase()).toBe(TEST_ADDRESS.toLowerCase()) + + await adapter.disconnect() + expect(adapter.getStatus().connected).toBe(false) + }) + + it('onStatusChange subscription fires on connect', async () => { + const wagmiConfig = makeRealConfig() + const { adapter } = createEvmWalletAdapter({ + connector: connectkitConnector, + chains: [mainnet], + transports: { [mainnet.id]: http() }, + wagmiConfig, + }) + + const statuses: WalletStatus[] = [] + const unsubscribe = adapter.onStatusChange((status) => statuses.push(status)) + + await adapter.connect() + unsubscribe() + + expect(statuses.length).toBeGreaterThan(0) + expect(statuses[statuses.length - 1].connected).toBe(true) + }) +}) diff --git a/src/sdk/core/evm/wallet.test.ts b/src/sdk/core/evm/wallet.test.ts new file mode 100644 index 00000000..c64fb3c1 --- /dev/null +++ b/src/sdk/core/evm/wallet.test.ts @@ -0,0 +1,308 @@ +import type { ReactNode } from 'react' +import { http } from 'viem' +import { mainnet } from 'viem/chains' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createConfig } from 'wagmi' +import { + connect, + getAccount, + getConnectors, + getWalletClient, + signMessage, + watchAccount, + watchChainId, +} from 'wagmi/actions' +import { mock } from 'wagmi/connectors' + +import type { WalletStatus } from '../adapters/wallet' +import { + ChainNotSupportedError, + SigningRejectedError, + WalletConnectionRejectedError, + WalletNotConnectedError, + WalletNotInstalledError, +} from '../errors' +import { connectkitConnector } from './connectors' +import { createEvmWalletAdapter } from './wallet' + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +vi.mock('wagmi/actions', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getAccount: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + reconnect: vi.fn(), + watchAccount: vi.fn(), + watchChainId: vi.fn(), + signMessage: vi.fn(), + signTypedData: vi.fn(), + switchChain: vi.fn(), + getConnectors: vi.fn(), + getWalletClient: vi.fn(), + } +}) + +vi.mock('wagmi', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + WagmiProvider: ({ children }: { children: ReactNode }) => children, + } +}) + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const TEST_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as const + +function makeConfig({ withConnector = false } = {}) { + return createConfig({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + connectors: withConnector ? [mock({ accounts: [TEST_ADDRESS] })] : [], + }) +} + +function makeDisconnectedAccount() { + return { + isConnected: false as const, + address: undefined, + chainId: undefined, + isConnecting: false, + isDisconnected: true as const, + isReconnecting: false, + connector: undefined, + addresses: [] as readonly `0x${string}`[], + status: 'disconnected' as const, + } as unknown as ReturnType +} + +function makeConnectedAccount(address: `0x${string}` = TEST_ADDRESS, chainId = 1) { + return { + isConnected: true as const, + address, + chainId, + isConnecting: false, + isDisconnected: false as const, + isReconnecting: false, + connector: undefined, + addresses: [address] as readonly [`0x${string}`, ...`0x${string}`[]], + status: 'connected' as const, + } as unknown as ReturnType +} + +function makeConnectingAccount() { + return { + isConnected: false as const, + address: undefined, + chainId: undefined, + isConnecting: true, + isDisconnected: false as const, + isReconnecting: false, + connector: undefined, + addresses: [] as readonly `0x${string}`[], + status: 'connecting' as const, + } as unknown as ReturnType +} + +// --------------------------------------------------------------------------- +// Unit tests (mocked @wagmi/core) +// --------------------------------------------------------------------------- + +describe('createEvmWalletAdapter — unit tests', () => { + let wagmiConfig: ReturnType + + beforeEach(() => { + wagmiConfig = makeConfig() + vi.mocked(getAccount).mockReturnValue(makeDisconnectedAccount()) + vi.mocked(watchAccount).mockReturnValue(() => undefined) + vi.mocked(watchChainId).mockReturnValue(() => undefined) + vi.mocked(getConnectors).mockReturnValue([]) + }) + + function makeAdapter() { + return createEvmWalletAdapter({ + connector: connectkitConnector, + chains: [mainnet], + transports: { [mainnet.id]: http() }, + wagmiConfig, + }) + } + + // ------------------------------------------------------------------------- + // getStatus() + // ------------------------------------------------------------------------- + + describe('getStatus()', () => { + it('maps connected account to WalletStatus', () => { + vi.mocked(getAccount).mockReturnValue(makeConnectedAccount('0xabc' as `0x${string}`, 1)) + const { adapter } = makeAdapter() + expect(adapter.getStatus()).toEqual({ + connected: true, + activeAccount: '0xabc', + connectedChainIds: [1], + connecting: false, + }) + }) + + it('maps disconnected state', () => { + vi.mocked(getAccount).mockReturnValue(makeDisconnectedAccount()) + const { adapter } = makeAdapter() + expect(adapter.getStatus()).toEqual({ + connected: false, + activeAccount: null, + connectedChainIds: [], + connecting: false, + }) + }) + + it('maps connecting state', () => { + vi.mocked(getAccount).mockReturnValue(makeConnectingAccount()) + const { adapter } = makeAdapter() + expect(adapter.getStatus()).toEqual({ + connected: false, + activeAccount: null, + connectedChainIds: [], + connecting: true, + }) + }) + }) + + // ------------------------------------------------------------------------- + // metadata + // ------------------------------------------------------------------------- + + describe('metadata', () => { + it('chainType is "evm"', () => { + const { adapter } = makeAdapter() + expect(adapter.metadata.chainType).toBe('evm') + }) + + it('capabilities has signTypedData and switchChain true', () => { + const { adapter } = makeAdapter() + expect(adapter.metadata.capabilities).toEqual({ signTypedData: true, switchChain: true }) + }) + }) + + // ------------------------------------------------------------------------- + // signMessage() + // ------------------------------------------------------------------------- + + describe('signMessage()', () => { + it('throws WalletNotConnectedError when disconnected', async () => { + vi.mocked(getAccount).mockReturnValue(makeDisconnectedAccount()) + const { adapter } = makeAdapter() + await expect(adapter.signMessage({ message: 'hello' })).rejects.toThrow( + WalletNotConnectedError, + ) + }) + + it('throws SigningRejectedError on user rejection', async () => { + vi.mocked(getAccount).mockReturnValue(makeConnectedAccount()) + vi.mocked(signMessage).mockRejectedValue( + Object.assign(new Error('User rejected request'), { name: 'UserRejectedRequestError' }), + ) + const { adapter } = makeAdapter() + await expect(adapter.signMessage({ message: 'hello' })).rejects.toThrow(SigningRejectedError) + }) + + it('returns SignatureResult with signature and address when connected', async () => { + vi.mocked(getAccount).mockReturnValue(makeConnectedAccount(TEST_ADDRESS, 1)) + vi.mocked(signMessage).mockResolvedValue('0xsig' as `0x${string}`) + const { adapter } = makeAdapter() + const result = await adapter.signMessage({ message: 'test' }) + expect(result).toEqual({ signature: '0xsig', address: TEST_ADDRESS }) + }) + + it('passes Uint8Array message as raw form', async () => { + vi.mocked(getAccount).mockReturnValue(makeConnectedAccount(TEST_ADDRESS, 1)) + vi.mocked(signMessage).mockResolvedValue('0xsig' as `0x${string}`) + const { adapter } = makeAdapter() + const bytes = new Uint8Array([1, 2, 3]) + await adapter.signMessage({ message: bytes }) + expect(vi.mocked(signMessage)).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ message: { raw: bytes } }), + ) + }) + }) + + // ------------------------------------------------------------------------- + // connect() + // ------------------------------------------------------------------------- + + describe('connect()', () => { + it('throws WalletConnectionRejectedError on user rejection', async () => { + wagmiConfig = makeConfig({ withConnector: true }) + vi.mocked(connect).mockRejectedValue( + Object.assign(new Error('User rejected'), { name: 'UserRejectedRequestError' }), + ) + const { adapter } = makeAdapter() + await expect(adapter.connect()).rejects.toThrow(WalletConnectionRejectedError) + }) + + it('throws WalletNotInstalledError when connector is not found', async () => { + wagmiConfig = makeConfig({ withConnector: true }) + vi.mocked(connect).mockRejectedValue( + Object.assign(new Error('Connector not found'), { name: 'ConnectorNotFoundError' }), + ) + const { adapter } = makeAdapter() + await expect(adapter.connect()).rejects.toThrow(WalletNotInstalledError) + }) + + it('throws ChainNotSupportedError when options.chainId is not in supportedChains', async () => { + wagmiConfig = makeConfig({ withConnector: true }) + const { adapter } = makeAdapter() + await expect(adapter.connect({ chainId: 999999 })).rejects.toThrow(ChainNotSupportedError) + }) + }) + + // ------------------------------------------------------------------------- + // getSigner() + // ------------------------------------------------------------------------- + + describe('getSigner()', () => { + it('returns WalletClient (non-null) when connected', async () => { + vi.mocked(getAccount).mockReturnValue(makeConnectedAccount()) + vi.mocked(getWalletClient).mockResolvedValue({ type: 'walletClient' } as unknown as Awaited< + ReturnType + >) + const { adapter } = makeAdapter() + const signer = await adapter.getSigner() + expect(signer).not.toBeNull() + }) + + it('returns null when disconnected', async () => { + vi.mocked(getAccount).mockReturnValue(makeDisconnectedAccount()) + const { adapter } = makeAdapter() + const signer = await adapter.getSigner() + expect(signer).toBeNull() + }) + }) + + // ------------------------------------------------------------------------- + // switchChain() + // ------------------------------------------------------------------------- + + describe('switchChain()', () => { + it('throws ChainNotSupportedError for unsupported chainId', async () => { + const { adapter } = makeAdapter() + await expect(adapter.switchChain(999999)).rejects.toThrow(ChainNotSupportedError) + }) + }) + + // ------------------------------------------------------------------------- + // chainType property + // ------------------------------------------------------------------------- + + it('chainType is "evm"', () => { + const { adapter } = makeAdapter() + expect(adapter.chainType).toBe('evm') + }) +}) diff --git a/src/sdk/core/evm/wallet.tsx b/src/sdk/core/evm/wallet.tsx new file mode 100644 index 00000000..67652bd2 --- /dev/null +++ b/src/sdk/core/evm/wallet.tsx @@ -0,0 +1,267 @@ +/** + * EVM implementation of the WalletAdapter interface using @wagmi/core actions. + */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { FC, ReactNode } from 'react' +import type { Chain, Transport } from 'viem' +import { type Config, WagmiProvider } from 'wagmi' +import { + connect, + disconnect, + getAccount, + getConnectors, + getWalletClient, + reconnect, + signMessage, + signTypedData, + switchChain, + watchAccount, + watchChainId, +} from 'wagmi/actions' + +import type { WalletAdapterBundle } from '../adapters/provider' +import type { + ChainSigner, + ConnectOptions, + SignatureResult, + SignMessageInput, + SignTypedDataInput, + WalletAdapter, + WalletConnection, + WalletInfo, + WalletStatus, +} from '../adapters/wallet' +import { + ChainNotSupportedError, + SigningRejectedError, + WalletConnectionRejectedError, + WalletNotConnectedError, + WalletNotInstalledError, +} from '../errors' +import { fromViemChain } from './chains' +import type { EvmConnectorConfig } from './types' + +// --------------------------------------------------------------------------- +// Public config interface +// --------------------------------------------------------------------------- + +export interface EvmWalletConfig { + connector: EvmConnectorConfig + chains: Chain[] + transports: Record + /** Pre-created wagmi Config. If provided, used directly instead of calling connector.createConfig(). */ + wagmiConfig?: Config +} + +// --------------------------------------------------------------------------- +// Error mapping helpers +// --------------------------------------------------------------------------- + +function isUserRejection(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + return error.name === 'UserRejectedRequestError' || error.message.includes('User rejected') +} + +function isConnectorNotFound(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + return error.name === 'ConnectorNotFoundError' +} + +function mapConnectError(error: unknown): never { + if (isUserRejection(error)) { + throw new WalletConnectionRejectedError() + } + if (isConnectorNotFound(error)) { + throw new WalletNotInstalledError() + } + throw error +} + +function mapSignError(error: unknown): never { + if (isUserRejection(error)) { + throw new SigningRejectedError() + } + throw error +} + +// --------------------------------------------------------------------------- +// Internal: map wagmi account state to WalletStatus +// --------------------------------------------------------------------------- + +function toWalletStatus(account: ReturnType): WalletStatus { + return { + connected: account.isConnected, + activeAccount: account.address ?? null, + connectedChainIds: account.chainId !== undefined ? [account.chainId] : [], + connecting: account.isConnecting, + } +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBundle { + const wagmiConfig = + config.wagmiConfig ?? config.connector.createConfig(config.chains, config.transports) + const supportedChains = config.chains.map(fromViemChain) + const queryClient = new QueryClient() + + const adapter: WalletAdapter<'evm'> = { + chainType: 'evm', + supportedChains, + + metadata: { + chainType: 'evm', + capabilities: { signTypedData: true, switchChain: true }, + formatAddress(address: string): string { + return address + }, + availableWallets(): WalletInfo[] { + return getConnectors(wagmiConfig).map((connector) => ({ + id: connector.id, + name: connector.name, + icon: connector.icon, + installed: true, + })) + }, + }, + + async connect(options?: ConnectOptions): Promise { + const connector = wagmiConfig.connectors[0] + if (!connector) { + throw new WalletNotInstalledError() + } + const chainId = options?.chainId !== undefined ? Number(options.chainId) : undefined + + if (chainId !== undefined) { + const isSupported = supportedChains.some((chain) => chain.chainId === chainId) + if (!isSupported) { + throw new ChainNotSupportedError(chainId) + } + } + + try { + const result = await connect(wagmiConfig, { connector, chainId }) + const activeAccount = result.accounts[0] + return { + accounts: [...result.accounts], + activeAccount, + chainId: result.chainId, + } + } catch (error) { + mapConnectError(error) + } + }, + + async reconnect(): Promise { + const results = await reconnect(wagmiConfig) + if (results.length === 0) { + return null + } + const first = results[0] + const activeAccount = first.accounts[0] + return { + accounts: [...first.accounts], + activeAccount, + chainId: first.chainId, + } + }, + + async disconnect(): Promise { + await disconnect(wagmiConfig) + }, + + getStatus(): WalletStatus { + return toWalletStatus(getAccount(wagmiConfig)) + }, + + onStatusChange(listener: (status: WalletStatus) => void): () => void { + const unsubAccount = watchAccount(wagmiConfig, { + onChange(account) { + listener(toWalletStatus(account)) + }, + }) + const unsubChain = watchChainId(wagmiConfig, { + onChange() { + listener(toWalletStatus(getAccount(wagmiConfig))) + }, + }) + return () => { + unsubAccount() + unsubChain() + } + }, + + async signMessage(input: SignMessageInput): Promise { + const account = getAccount(wagmiConfig) + if (!account.isConnected || !account.address) { + throw new WalletNotConnectedError() + } + + const message = input.message instanceof Uint8Array ? { raw: input.message } : input.message + + try { + const signature = await signMessage(wagmiConfig, { message }) + return { signature, address: account.address } + } catch (error) { + mapSignError(error) + } + }, + + async signTypedData(input: SignTypedDataInput): Promise { + const account = getAccount(wagmiConfig) + if (!account.isConnected || !account.address) { + throw new WalletNotConnectedError() + } + + try { + // Cast needed: public interface uses loose Record types; viem expects its own deep typed-data shapes. + const signature = await signTypedData(wagmiConfig, { + domain: input.domain as Parameters[1]['domain'], + types: input.types as Parameters[1]['types'], + primaryType: input.primaryType, + message: input.message as Parameters[1]['message'], + }) + return { signature, address: account.address } + } catch (error) { + mapSignError(error) + } + }, + + async getSigner(): Promise { + const account = getAccount(wagmiConfig) + if (!account.isConnected) { + return null + } + return getWalletClient(wagmiConfig) + }, + + async switchChain(chainId: string | number): Promise { + const numericId = typeof chainId === 'string' ? Number.parseInt(chainId, 10) : chainId + const isSupported = supportedChains.some((chain) => chain.chainId === numericId) + if (!isSupported) { + throw new ChainNotSupportedError(chainId) + } + // Cast needed: numericId is number; wagmi expects its branded ChainId union type. + await switchChain(wagmiConfig, { + chainId: numericId as Parameters[1]['chainId'], + }) + }, + } + + const Provider: FC<{ children: ReactNode }> = ({ children }) => ( + + + {children} + + + ) + + return { adapter, Provider, useConnectModal: config.connector.useConnectModal } +} From f90f84b1baa3625567cac537a5a84acc3d2047b3 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:09:23 +0200 Subject: [PATCH 06/16] feat: implement createEvmTransactionAdapter with viem --- src/sdk/core/evm/transaction.test.ts | 296 +++++++++++++++++++++++++++ src/sdk/core/evm/transaction.ts | 217 ++++++++++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 src/sdk/core/evm/transaction.test.ts create mode 100644 src/sdk/core/evm/transaction.ts diff --git a/src/sdk/core/evm/transaction.test.ts b/src/sdk/core/evm/transaction.test.ts new file mode 100644 index 00000000..6288dadf --- /dev/null +++ b/src/sdk/core/evm/transaction.test.ts @@ -0,0 +1,296 @@ +import { createPublicClient, http } from 'viem' +import { mainnet } from 'viem/chains' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { TransactionRef } from '../adapters/transaction' +import { InsufficientFundsError, InvalidSignerError } from '../errors' +import { createEvmTransactionAdapter } from './transaction' +import type { EvmContractCall, EvmRawTransaction } from './types' + +vi.mock('viem', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createPublicClient: vi.fn(), + } +}) + +const makePublicClient = (overrides: Record = {}) => ({ + estimateGas: vi.fn().mockResolvedValue(21000n), + estimateContractGas: vi.fn().mockResolvedValue(21000n), + getGasPrice: vi.fn().mockResolvedValue(1_000_000_000n), + waitForTransactionReceipt: vi + .fn() + .mockResolvedValue({ status: 'success', transactionHash: '0xhash' }), + ...overrides, +}) + +describe('createEvmTransactionAdapter', () => { + let mockPublicClient: ReturnType + + beforeEach(() => { + mockPublicClient = makePublicClient() + vi.mocked(createPublicClient).mockReturnValue(mockPublicClient as never) + }) + + // --------------------------------------------------------------------------- + // structural / metadata + // --------------------------------------------------------------------------- + + it('exposes chainType = "evm"', () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + expect(adapter.chainType).toBe('evm') + }) + + it('exposes metadata.chainType = "evm"', () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + expect(adapter.metadata.chainType).toBe('evm') + }) + + it('exposes metadata.feeModel = "eip1559"', () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + expect(adapter.metadata.feeModel).toBe('eip1559') + }) + + it('supportedChains contains the mainnet descriptor', () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + expect(adapter.supportedChains).toHaveLength(1) + expect(adapter.supportedChains[0].chainId).toBe(mainnet.id) + expect(adapter.supportedChains[0].chainType).toBe('evm') + }) + + // --------------------------------------------------------------------------- + // prepare() + // --------------------------------------------------------------------------- + + it('returns ready: true with estimatedFee for a raw transaction', async () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + + const result = await adapter.prepare({ + chainId: mainnet.id, + payload: { to: '0xabc' as `0x${string}`, value: 0n } as EvmRawTransaction, + }) + + expect(result.ready).toBe(true) + expect(result.estimatedFee?.amount).toBe((21000n * 1_000_000_000n).toString()) + expect(result.estimatedFee?.symbol).toBe('ETH') + expect(result.estimatedFee?.decimals).toBe(18) + }) + + it('returns ready: false when chain is not configured', async () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + + const result = await adapter.prepare({ chainId: 999, payload: { to: '0xabc' } as never }) + + expect(result.ready).toBe(false) + expect(result.reason).toBeTruthy() + }) + + it('throws InsufficientFundsError when estimateGas reports insufficient funds', async () => { + mockPublicClient.estimateGas.mockRejectedValue(new Error('insufficient funds for gas')) + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + + await expect( + adapter.prepare({ chainId: mainnet.id, payload: { to: '0xabc' } as never }), + ).rejects.toThrow(InsufficientFundsError) + }) + + it('returns ready: false with reason on generic errors', async () => { + mockPublicClient.estimateGas.mockRejectedValue(new Error('call reverted')) + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + + const result = await adapter.prepare({ chainId: mainnet.id, payload: { to: '0xabc' } as never }) + + expect(result.ready).toBe(false) + expect(result.reason).toContain('call reverted') + }) + + it('returns ready: false when a preStep targets an unsupported chain', async () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + const result = await adapter.prepare({ + chainId: mainnet.id, + payload: { to: '0xabc' as `0x${string}` } as EvmRawTransaction, + preSteps: [{ label: 'Approve', params: { chainId: 999, payload: {} } }], + }) + expect(result.ready).toBe(false) + expect(result.reason).toContain('Pre-step') + }) + + it('uses estimateContractGas for EvmContractCall payload', async () => { + mockPublicClient.estimateContractGas = vi.fn().mockResolvedValue(50000n) + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + + const result = await adapter.prepare({ + chainId: mainnet.id, + payload: { + contract: { + address: '0xabc' as `0x${string}`, + abi: [], + functionName: 'transfer', + args: [], + }, + } as EvmContractCall, + }) + + expect(mockPublicClient.estimateContractGas).toHaveBeenCalled() + expect(result.ready).toBe(true) + }) + + // --------------------------------------------------------------------------- + // execute() + // --------------------------------------------------------------------------- + + it('throws InvalidSignerError when signer is a plain object (not WalletClient)', async () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + + await expect( + adapter.execute({ chainId: mainnet.id, payload: { to: '0xabc' } as never }, {}), + ).rejects.toThrow(InvalidSignerError) + }) + + it('throws InvalidSignerError when signer is null', async () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + + await expect( + adapter.execute({ chainId: mainnet.id, payload: { to: '0xabc' } as never }, null as never), + ).rejects.toThrow(InvalidSignerError) + }) + + it('returns TransactionRef with hash for EvmRawTransaction', async () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + + const mockWalletClient = { + sendTransaction: vi.fn().mockResolvedValue('0xhash'), + writeContract: vi.fn(), + } + + const result = await adapter.execute( + { chainId: mainnet.id, payload: { to: '0xabc' as `0x${string}` } as EvmRawTransaction }, + mockWalletClient as never, + ) + + expect(result).toEqual({ chainType: 'evm', id: '0xhash', chainId: mainnet.id }) + expect(mockWalletClient.sendTransaction).toHaveBeenCalled() + }) + + it('uses writeContract for EvmContractCall payload', async () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + + const mockWalletClient = { + writeContract: vi.fn().mockResolvedValue('0xhash'), + sendTransaction: vi.fn(), + } + + await adapter.execute( + { + chainId: mainnet.id, + payload: { + contract: { + address: '0xabc' as `0x${string}`, + abi: [], + functionName: 'approve', + args: [], + }, + } as EvmContractCall, + }, + mockWalletClient as never, + ) + + expect(mockWalletClient.writeContract).toHaveBeenCalled() + expect(mockWalletClient.sendTransaction).not.toHaveBeenCalled() + }) + + // --------------------------------------------------------------------------- + // confirm() + // --------------------------------------------------------------------------- + + it('returns status: "success" for a successful receipt', async () => { + mockPublicClient.waitForTransactionReceipt = vi.fn().mockResolvedValue({ + status: 'success', + transactionHash: '0xhash', + }) + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + const ref: TransactionRef = { chainType: 'evm', id: '0xhash', chainId: mainnet.id } + + const result = await adapter.confirm(ref) + + expect(result.status).toBe('success') + expect(result.ref).toEqual(ref) + }) + + it('returns status: "reverted" for a reverted receipt', async () => { + mockPublicClient.waitForTransactionReceipt = vi.fn().mockResolvedValue({ + status: 'reverted', + transactionHash: '0xhash', + }) + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + const ref: TransactionRef = { chainType: 'evm', id: '0xhash', chainId: mainnet.id } + + const result = await adapter.confirm(ref) + + expect(result.status).toBe('reverted') + }) + + it('returns status: "timeout" when tx is not confirmed within timeout', async () => { + mockPublicClient.waitForTransactionReceipt = vi + .fn() + .mockImplementation(() => new Promise(() => {})) + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + const ref: TransactionRef = { chainType: 'evm', id: '0xhash', chainId: mainnet.id } + + const result = await adapter.confirm(ref, { timeout: 1 }) + + expect(result.status).toBe('timeout') + }) +}) diff --git a/src/sdk/core/evm/transaction.ts b/src/sdk/core/evm/transaction.ts new file mode 100644 index 00000000..c571c094 --- /dev/null +++ b/src/sdk/core/evm/transaction.ts @@ -0,0 +1,217 @@ +import { + type Chain, + createPublicClient, + type Hex, + type PublicClient, + type Transport, + type WalletClient, +} from 'viem' + +import type { + ConfirmOptions, + PrepareResult, + TransactionAdapter, + TransactionParams, + TransactionRef, + TransactionResult, +} from '../adapters/transaction' +import type { ChainSigner } from '../adapters/wallet' +import { InsufficientFundsError, InvalidSignerError } from '../errors' +import { fromViemChain } from './chains' +import type { EvmContractCall, EvmRawTransaction, EvmTransactionPayload } from './types' + +/** Configuration for the EVM transaction adapter. */ +export interface EvmTransactionConfig { + chains: Chain[] + transports: Record +} + +function isEvmContractCall(payload: EvmTransactionPayload): payload is EvmContractCall { + return 'contract' in payload && payload.contract !== undefined +} + +function isWalletClient(signer: unknown): signer is WalletClient { + return ( + typeof signer === 'object' && + signer !== null && + 'sendTransaction' in signer && + typeof (signer as { sendTransaction: unknown }).sendTransaction === 'function' + ) +} + +/** + * Creates an EVM TransactionAdapter backed by viem's PublicClient (reads) and WalletClient (writes). + */ +export function createEvmTransactionAdapter( + config: EvmTransactionConfig = { chains: [], transports: {} }, +): TransactionAdapter<'evm'> { + const publicClients = new Map( + config.chains.map((chain) => [ + chain.id, + createPublicClient({ chain, transport: config.transports[chain.id] }), + ]), + ) + + const supportedChains = config.chains.map(fromViemChain) + + function getPublicClient(chainId: string | number): PublicClient { + const numericId = typeof chainId === 'string' ? Number.parseInt(chainId, 10) : chainId + const client = publicClients.get(numericId) + if (!client) { + throw new Error(`Chain ${chainId} is not configured in this adapter.`) + } + return client + } + + return { + chainType: 'evm', + supportedChains, + metadata: { + chainType: 'evm', + feeModel: 'eip1559', + confirmationModel: 'blockConfirmations', + }, + + async prepare(params: TransactionParams): Promise { + const numericId = + typeof params.chainId === 'string' ? Number.parseInt(params.chainId, 10) : params.chainId + const publicClient = publicClients.get(numericId) + if (!publicClient) { + return { + ready: false, + reason: `Chain ${params.chainId} is not configured in this adapter.`, + } + } + + if (params.preSteps) { + for (const preStep of params.preSteps) { + const preStepNumericId = + typeof preStep.params.chainId === 'string' + ? Number.parseInt(preStep.params.chainId, 10) + : preStep.params.chainId + if (!publicClients.has(preStepNumericId)) { + return { + ready: false, + reason: `Pre-step "${preStep.label}" targets unsupported chain ${preStep.params.chainId}.`, + } + } + } + } + + const payload = params.payload as EvmTransactionPayload + + try { + const estimatedGas = isEvmContractCall(payload) + ? await publicClient.estimateContractGas({ + address: payload.contract.address, + abi: payload.contract.abi, + functionName: payload.contract.functionName, + args: payload.contract.args, + value: payload.value, + }) + : await publicClient.estimateGas({ + to: (payload as EvmRawTransaction).to, + data: (payload as EvmRawTransaction).data, + value: (payload as EvmRawTransaction).value, + }) + + const gasPrice = await publicClient.getGasPrice() + const estimatedFeeAmount = estimatedGas * gasPrice + + const chainDescriptor = supportedChains.find((c) => c.chainId === numericId) + const nativeCurrency = chainDescriptor?.nativeCurrency + + return { + ready: true, + estimatedFee: { + amount: estimatedFeeAmount.toString(), + symbol: nativeCurrency?.symbol ?? 'ETH', + decimals: nativeCurrency?.decimals ?? 18, + }, + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes('insufficient funds')) { + throw new InsufficientFundsError() + } + return { ready: false, reason: message } + } + }, + + async execute(params: TransactionParams, signer: ChainSigner): Promise { + if (!isWalletClient(signer)) { + throw new InvalidSignerError('WalletClient') + } + + const payload = params.payload as EvmTransactionPayload + + const hash = isEvmContractCall(payload) + ? await signer.writeContract({ + address: payload.contract.address, + // Cast needed: EvmContractCall.contract.abi is untyped Abi; viem expects a specific generic. + abi: payload.contract.abi as Parameters[0]['abi'], + functionName: payload.contract.functionName as Parameters< + typeof signer.writeContract + >[0]['functionName'], + args: payload.contract.args as Parameters[0]['args'], + value: payload.value, + gas: payload.gas, + maxFeePerGas: payload.maxFeePerGas, + maxPriorityFeePerGas: payload.maxPriorityFeePerGas, + } as Parameters[0]) + : await signer.sendTransaction({ + to: (payload as EvmRawTransaction).to, + data: (payload as EvmRawTransaction).data, + value: (payload as EvmRawTransaction).value, + gas: (payload as EvmRawTransaction).gas, + maxFeePerGas: (payload as EvmRawTransaction).maxFeePerGas, + maxPriorityFeePerGas: (payload as EvmRawTransaction).maxPriorityFeePerGas, + } as Parameters[0]) + + return { chainType: 'evm', id: hash as string, chainId: params.chainId } + }, + + async confirm(ref: TransactionRef, options?: ConfirmOptions): Promise { + const publicClient = getPublicClient(ref.chainId) + const hash = ref.id as Hex + const timeout = options?.timeout ?? 60_000 + const confirmations = options?.confirmations ?? 1 + + let replaced: TransactionResult | undefined + + const receiptPromise = publicClient + .waitForTransactionReceipt({ + hash, + confirmations, + onReplaced(replacement) { + replaced = { + status: replacement.reason === 'cancelled' ? 'reverted' : 'success', + ref: { + chainType: 'evm', + id: replacement.transaction.hash, + chainId: ref.chainId, + }, + receipt: replacement.transactionReceipt, + } + }, + }) + .then((receipt): TransactionResult => { + if (replaced) { + return replaced + } + return { + status: receipt.status === 'success' ? 'success' : 'reverted', + ref, + receipt, + } + }) + + const timeoutResult: TransactionResult = { status: 'timeout', ref, receipt: null } + const timeoutPromise = new Promise((resolve) => + setTimeout(() => resolve(timeoutResult), timeout), + ) + + return Promise.race([receiptPromise, timeoutPromise]) + }, + } +} From 02c4a7ea7015bd3ee51c9744fa52df1ac6adcca9 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:09:35 +0200 Subject: [PATCH 07/16] feat: add createEvmServerWallet, pre-step helpers, and wrapAdapter utility --- src/sdk/core/evm/pre-steps.test.ts | 90 ++++++++++++++++ src/sdk/core/evm/pre-steps.ts | 114 ++++++++++++++++++++ src/sdk/core/evm/server-wallet.test.ts | 129 +++++++++++++++++++++++ src/sdk/core/evm/server-wallet.ts | 132 ++++++++++++++++++++++++ src/sdk/core/index.ts | 5 + src/sdk/core/utils/index.ts | 1 + src/sdk/core/utils/wrap-adapter.test.ts | 111 ++++++++++++++++++++ src/sdk/core/utils/wrap-adapter.ts | 87 ++++++++++++++++ 8 files changed, 669 insertions(+) create mode 100644 src/sdk/core/evm/pre-steps.test.ts create mode 100644 src/sdk/core/evm/pre-steps.ts create mode 100644 src/sdk/core/evm/server-wallet.test.ts create mode 100644 src/sdk/core/evm/server-wallet.ts create mode 100644 src/sdk/core/index.ts create mode 100644 src/sdk/core/utils/index.ts create mode 100644 src/sdk/core/utils/wrap-adapter.test.ts create mode 100644 src/sdk/core/utils/wrap-adapter.ts diff --git a/src/sdk/core/evm/pre-steps.test.ts b/src/sdk/core/evm/pre-steps.test.ts new file mode 100644 index 00000000..e704154b --- /dev/null +++ b/src/sdk/core/evm/pre-steps.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' + +import { createApprovalPreStep, createPermitPreStep } from './pre-steps' + +const TOKEN_ADDRESS = '0xTokenAddress000000000000000000000000000' +const SPENDER_ADDRESS = '0xSpenderAddress00000000000000000000000000' +const OWNER_ADDRESS = '0xOwnerAddress000000000000000000000000000' +const CHAIN_ID = 1 + +describe('createApprovalPreStep', () => { + it('returns a PreStep with label containing the token address', () => { + const result = createApprovalPreStep({ + token: TOKEN_ADDRESS as `0x${string}`, + spender: SPENDER_ADDRESS as `0x${string}`, + amount: BigInt(1000), + chainId: CHAIN_ID, + }) + expect(result.label).toContain(TOKEN_ADDRESS) + }) + + it('returns a PreStep with the correct chainId', () => { + const result = createApprovalPreStep({ + token: TOKEN_ADDRESS as `0x${string}`, + spender: SPENDER_ADDRESS as `0x${string}`, + amount: BigInt(1000), + chainId: CHAIN_ID, + }) + expect(result.params.chainId).toBe(CHAIN_ID) + }) + + it('payload contract functionName is "approve"', () => { + const result = createApprovalPreStep({ + token: TOKEN_ADDRESS as `0x${string}`, + spender: SPENDER_ADDRESS as `0x${string}`, + amount: BigInt(1000), + chainId: CHAIN_ID, + }) + const payload = result.params.payload as { contract: { functionName: string; args: unknown[] } } + expect(payload.contract.functionName).toBe('approve') + }) + + it('payload contract args are [spender, amount]', () => { + const amount = BigInt(1000) + const result = createApprovalPreStep({ + token: TOKEN_ADDRESS as `0x${string}`, + spender: SPENDER_ADDRESS as `0x${string}`, + amount, + chainId: CHAIN_ID, + }) + const payload = result.params.payload as { contract: { args: unknown[] } } + expect(payload.contract.args).toEqual([SPENDER_ADDRESS, amount]) + }) +}) + +describe('createPermitPreStep', () => { + const permitParams = { + token: TOKEN_ADDRESS as `0x${string}`, + owner: OWNER_ADDRESS as `0x${string}`, + spender: SPENDER_ADDRESS as `0x${string}`, + value: BigInt(500), + deadline: BigInt(9999999), + v: 27, + r: '0xr000000000000000000000000000000000000000000000000000000000000000' as `0x${string}`, + s: '0xs000000000000000000000000000000000000000000000000000000000000000' as `0x${string}`, + chainId: CHAIN_ID, + } + + it('returns a PreStep with label containing the token address', () => { + const result = createPermitPreStep(permitParams) + expect(result.label).toContain(TOKEN_ADDRESS) + }) + + it('payload contract functionName is "permit"', () => { + const result = createPermitPreStep(permitParams) + const payload = result.params.payload as { contract: { functionName: string } } + expect(payload.contract.functionName).toBe('permit') + }) + + it('payload contract args[0] is the owner address', () => { + const result = createPermitPreStep(permitParams) + const payload = result.params.payload as { contract: { args: unknown[] } } + expect(payload.contract.args[0]).toBe(OWNER_ADDRESS) + }) + + it('payload contract args has 7 elements', () => { + const result = createPermitPreStep(permitParams) + const payload = result.params.payload as { contract: { args: unknown[] } } + expect(payload.contract.args).toHaveLength(7) + }) +}) diff --git a/src/sdk/core/evm/pre-steps.ts b/src/sdk/core/evm/pre-steps.ts new file mode 100644 index 00000000..96cc91cb --- /dev/null +++ b/src/sdk/core/evm/pre-steps.ts @@ -0,0 +1,114 @@ +/** + * Helpers that produce PreStep objects for common EVM pre-transaction steps. + */ + +import type { Address, Hex } from 'viem' + +import type { PreStep } from '../adapters/transaction' +import type { EvmContractCall } from './types' + +// --------------------------------------------------------------------------- +// ERC-20 approve +// --------------------------------------------------------------------------- + +const ERC20_APPROVE_ABI = [ + { + type: 'function', + name: 'approve', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'nonpayable', + }, +] as const + +export interface ApprovalPreStepParams { + token: Address + spender: Address + amount: bigint + chainId: string | number +} + +/** + * Creates a PreStep for ERC-20 approve(spender, amount). + */ +export function createApprovalPreStep(params: ApprovalPreStepParams): PreStep { + return { + label: `Approve ${params.token}`, + params: { + chainId: params.chainId, + payload: { + contract: { + address: params.token, + abi: ERC20_APPROVE_ABI, + functionName: 'approve', + args: [params.spender, params.amount], + }, + } satisfies EvmContractCall, + }, + } +} + +// --------------------------------------------------------------------------- +// EIP-2612 permit +// --------------------------------------------------------------------------- + +const ERC20_PERMIT_ABI = [ + { + type: 'function', + name: 'permit', + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'v', type: 'uint8' }, + { name: 'r', type: 'bytes32' }, + { name: 's', type: 'bytes32' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, +] as const + +export interface PermitPreStepParams { + token: Address + owner: Address + spender: Address + value: bigint + deadline: bigint + v: number + r: Hex + s: Hex + chainId: string | number +} + +/** + * Creates a PreStep for EIP-2612 permit(owner, spender, value, deadline, v, r, s). + */ +export function createPermitPreStep(params: PermitPreStepParams): PreStep { + return { + label: `Permit ${params.token}`, + params: { + chainId: params.chainId, + payload: { + contract: { + address: params.token, + abi: ERC20_PERMIT_ABI, + functionName: 'permit', + args: [ + params.owner, + params.spender, + params.value, + params.deadline, + params.v, + params.r, + params.s, + ], + }, + } satisfies EvmContractCall, + }, + } +} diff --git a/src/sdk/core/evm/server-wallet.test.ts b/src/sdk/core/evm/server-wallet.test.ts new file mode 100644 index 00000000..fc901e35 --- /dev/null +++ b/src/sdk/core/evm/server-wallet.test.ts @@ -0,0 +1,129 @@ +import type { WalletClient } from 'viem' +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' + +import { CapabilityNotSupportedError } from '../errors' +import { createEvmServerWallet } from './server-wallet' + +vi.mock('viem', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createWalletClient: vi.fn(), + } +}) + +vi.mock('viem/accounts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + privateKeyToAccount: vi.fn(), + } +}) + +const MOCK_ADDRESS = '0xDeadBeefDeadBeefDeadBeefDeadBeefDeadBeef' +const MOCK_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +const mockSignMessage = vi.fn() +const mockSignTypedData = vi.fn() + +const mockWalletClient: Partial = { + signMessage: mockSignMessage, + signTypedData: mockSignTypedData, +} + +const mockAccount = { + address: MOCK_ADDRESS, +} + +const mockChain = { + id: 1, + name: 'Ethereum', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://eth.example.com'] } }, +} + +describe('createEvmServerWallet', () => { + beforeEach(async () => { + vi.clearAllMocks() + const viemAccounts = await import('viem/accounts') + ;(viemAccounts.privateKeyToAccount as Mock).mockReturnValue(mockAccount) + const viem = await import('viem') + ;(viem.createWalletClient as Mock).mockReturnValue(mockWalletClient) + }) + + it('getStatus() returns connected: true with the account address', async () => { + const bundle = createEvmServerWallet({ + privateKey: MOCK_PRIVATE_KEY, + chain: mockChain as never, + }) + const status = bundle.adapter.getStatus() + expect(status.connected).toBe(true) + expect(status.activeAccount).toBe(MOCK_ADDRESS) + expect(status.connecting).toBe(false) + expect(status.connectedChainIds).toContain(mockChain.id) + }) + + it('connect() returns WalletConnection with account address', async () => { + const bundle = createEvmServerWallet({ + privateKey: MOCK_PRIVATE_KEY, + chain: mockChain as never, + }) + const connection = await bundle.adapter.connect() + expect(connection.activeAccount).toBe(MOCK_ADDRESS) + expect(connection.accounts).toContain(MOCK_ADDRESS) + expect(connection.chainId).toBe(mockChain.id) + }) + + it('signMessage() delegates to walletClient.signMessage and returns SignatureResult', async () => { + mockSignMessage.mockResolvedValue('0xsignature') + const bundle = createEvmServerWallet({ + privateKey: MOCK_PRIVATE_KEY, + chain: mockChain as never, + }) + const result = await bundle.adapter.signMessage({ message: 'hello' }) + expect(result.signature).toBe('0xsignature') + expect(result.address).toBe(MOCK_ADDRESS) + expect(mockSignMessage).toHaveBeenCalledOnce() + }) + + it('getSigner() returns the walletClient', async () => { + const bundle = createEvmServerWallet({ + privateKey: MOCK_PRIVATE_KEY, + chain: mockChain as never, + }) + const signer = await bundle.adapter.getSigner() + expect(signer).toBe(mockWalletClient) + }) + + it('switchChain() throws CapabilityNotSupportedError', async () => { + const bundle = createEvmServerWallet({ + privateKey: MOCK_PRIVATE_KEY, + chain: mockChain as never, + }) + await expect(bundle.adapter.switchChain(1)).rejects.toThrow(CapabilityNotSupportedError) + }) + + it('metadata.capabilities.switchChain is false', () => { + const bundle = createEvmServerWallet({ + privateKey: MOCK_PRIVATE_KEY, + chain: mockChain as never, + }) + expect(bundle.adapter.metadata.capabilities.switchChain).toBe(false) + }) + + it('metadata.capabilities.signTypedData is true', () => { + const bundle = createEvmServerWallet({ + privateKey: MOCK_PRIVATE_KEY, + chain: mockChain as never, + }) + expect(bundle.adapter.metadata.capabilities.signTypedData).toBe(true) + }) + + it('bundle.Provider is undefined (no UI for server wallet)', () => { + const bundle = createEvmServerWallet({ + privateKey: MOCK_PRIVATE_KEY, + chain: mockChain as never, + }) + expect(bundle.Provider).toBeUndefined() + }) +}) diff --git a/src/sdk/core/evm/server-wallet.ts b/src/sdk/core/evm/server-wallet.ts new file mode 100644 index 00000000..5903635d --- /dev/null +++ b/src/sdk/core/evm/server-wallet.ts @@ -0,0 +1,132 @@ +/** + * Private-key EVM wallet adapter for server-side usage. + * No UI, no browser connector — always-connected via supplied private key. + */ + +import type { Chain, Hex, Transport } from 'viem' +import { createWalletClient, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' + +import type { WalletAdapterBundle } from '../adapters/provider' +import type { + ChainSigner, + ConnectOptions, + SignatureResult, + SignMessageInput, + SignTypedDataInput, + WalletAdapter, + WalletConnection, + WalletInfo, + WalletStatus, +} from '../adapters/wallet' +import { CapabilityNotSupportedError } from '../errors' +import { fromViemChain } from './chains' + +export interface EvmServerWalletConfig { + privateKey: Hex + chain: Chain + transport?: Transport +} + +/** + * Creates a server-side EVM wallet adapter backed by a private key. + * Returns no Provider — server wallets have no UI layer. + */ +export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdapterBundle { + const account = privateKeyToAccount(config.privateKey) + const walletClient = createWalletClient({ + account, + chain: config.chain, + transport: config.transport ?? http(), + }) + + const supportedChains = [fromViemChain(config.chain)] + + const adapter: WalletAdapter<'evm'> = { + chainType: 'evm', + supportedChains, + + metadata: { + chainType: 'evm', + capabilities: { signTypedData: true, switchChain: false }, + formatAddress(address: string): string { + return address + }, + availableWallets(): WalletInfo[] { + // Server wallets have no user-facing wallet list. + return [] + }, + }, + + async connect(_options?: ConnectOptions): Promise { + return { + accounts: [account.address], + activeAccount: account.address, + chainId: config.chain.id, + } + }, + + // Server wallet is always connected — reconnect() always returns a connection, never null. + async reconnect(): Promise { + return { + accounts: [account.address], + activeAccount: account.address, + chainId: config.chain.id, + } + }, + + async disconnect(): Promise { + // no-op: private key wallet is always connected + }, + + getStatus(): WalletStatus { + return { + connected: true, + activeAccount: account.address, + connectedChainIds: [config.chain.id], + connecting: false, + } + }, + + onStatusChange(listener: (status: WalletStatus) => void): () => void { + // Emit current status immediately so callers receive initial state without polling. + listener({ + connected: true, + activeAccount: account.address, + connectedChainIds: [config.chain.id], + connecting: false, + }) + return () => { + // no-op: status never changes for a private key wallet + } + }, + + async signMessage(input: SignMessageInput): Promise { + const message = input.message instanceof Uint8Array ? { raw: input.message } : input.message + const signature = await walletClient.signMessage({ message } as Parameters< + typeof walletClient.signMessage + >[0]) + return { signature, address: account.address } + }, + + async signTypedData(input: SignTypedDataInput): Promise { + const signature = await walletClient.signTypedData({ + domain: input.domain, + types: input.types, + primaryType: input.primaryType, + message: input.message, + } as Parameters[0]) + return { signature, address: account.address } + }, + + async getSigner(): Promise { + return walletClient + }, + + async switchChain(_chainId: string | number): Promise { + throw new CapabilityNotSupportedError('switchChain') + }, + } + + return { adapter } +} diff --git a/src/sdk/core/index.ts b/src/sdk/core/index.ts new file mode 100644 index 00000000..5cc9ab31 --- /dev/null +++ b/src/sdk/core/index.ts @@ -0,0 +1,5 @@ +export * from './adapters' +export * from './chain' +export * from './errors' +export * from './evm' +export * from './utils' diff --git a/src/sdk/core/utils/index.ts b/src/sdk/core/utils/index.ts new file mode 100644 index 00000000..2c1d56ba --- /dev/null +++ b/src/sdk/core/utils/index.ts @@ -0,0 +1 @@ +export { wrapAdapter } from './wrap-adapter' diff --git a/src/sdk/core/utils/wrap-adapter.test.ts b/src/sdk/core/utils/wrap-adapter.test.ts new file mode 100644 index 00000000..a0b401f0 --- /dev/null +++ b/src/sdk/core/utils/wrap-adapter.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' + +import { wrapAdapter } from './wrap-adapter' + +describe('wrapAdapter', () => { + it('calls onBefore hook before the method runs', async () => { + const calls: string[] = [] + const adapter = { greet: async (name: string) => `hello ${name}` } + const wrapped = wrapAdapter(adapter, { + onBefore(method, args) { + calls.push(`before:${method}:${args[0]}`) + }, + }) + await wrapped.greet('world') + expect(calls).toContain('before:greet:world') + }) + + it('calls onAfter hook after the method returns', async () => { + const results: unknown[] = [] + const adapter = { getValue: async () => 42 } + const wrapped = wrapAdapter(adapter, { + onAfter(_method, result) { + results.push(result) + }, + }) + await wrapped.getValue() + expect(results).toContain(42) + }) + + it('fires hooks in order: onBefore → method → onAfter', async () => { + const order: string[] = [] + const adapter = { + doWork: async () => { + order.push('method') + return 'done' + }, + } + const wrapped = wrapAdapter(adapter, { + onBefore() { + order.push('before') + }, + onAfter() { + order.push('after') + }, + }) + await wrapped.doWork() + expect(order).toEqual(['before', 'method', 'after']) + }) + + it('onError fires when method throws, and the error is still propagated', async () => { + const errors: string[] = [] + const adapter = { + failingMethod: async () => { + throw new Error('boom') + }, + } + const wrapped = wrapAdapter(adapter, { + onError(_method, error) { + errors.push(error.message) + }, + }) + await expect(wrapped.failingMethod()).rejects.toThrow('boom') + expect(errors).toContain('boom') + }) + + it('hook errors are silently swallowed — method still succeeds', async () => { + const adapter = { getValue: async () => 42 } + const wrapped = wrapAdapter(adapter, { + onBefore() { + throw new Error('hook error') + }, + }) + const result = await wrapped.getValue() + expect(result).toBe(42) + }) + + it('non-function properties are preserved', () => { + const adapter = { chainType: 'evm', connect: async () => 'ok' } + const wrapped = wrapAdapter(adapter, {}) + expect(wrapped.chainType).toBe('evm') + }) + + it('preserves synchronous methods as synchronous', () => { + const adapter = { getStatus: () => ({ connected: true }) } + const wrapped = wrapAdapter(adapter, {}) + const result = wrapped.getStatus() + // result should NOT be a Promise — it's a sync return + expect(result).toEqual({ connected: true }) + expect(result).not.toBeInstanceOf(Promise) + }) + + it('hooks fire in order for synchronous methods', () => { + const order: string[] = [] + const adapter = { + getChainId: () => { + order.push('method') + return 1 + }, + } + const wrapped = wrapAdapter(adapter, { + onBefore() { + order.push('before') + }, + onAfter() { + order.push('after') + }, + }) + wrapped.getChainId() + expect(order).toEqual(['before', 'method', 'after']) + }) +}) diff --git a/src/sdk/core/utils/wrap-adapter.ts b/src/sdk/core/utils/wrap-adapter.ts new file mode 100644 index 00000000..515c1e4b --- /dev/null +++ b/src/sdk/core/utils/wrap-adapter.ts @@ -0,0 +1,87 @@ +/** + * Wraps every function method on an adapter with optional before/after/error hooks. + * Returns a new object with identical interface; original adapter is untouched. + * Hooks are fire-and-forget: hook errors are caught and ignored to avoid aborting adapter calls. + */ + +/** Collects own + inherited enumerable property keys up to (but not including) Object.prototype. */ +function collectMethodKeys(obj: object): string[] { + const keys = new Set() + let current: object | null = obj + while (current !== null && current !== Object.prototype) { + for (const key of Object.getOwnPropertyNames(current)) { + keys.add(key) + } + current = Object.getPrototypeOf(current) as object | null + } + return [...keys] +} + +export function wrapAdapter( + adapter: T, + hooks: { + onBefore?(method: string, args: unknown[]): void + onAfter?(method: string, result: unknown): void + onError?(method: string, error: Error): void + }, +): T { + const wrapped: Record = {} + + for (const key of collectMethodKeys(adapter as object)) { + const value = (adapter as Record)[key] + if (typeof value !== 'function') { + wrapped[key] = value + continue + } + wrapped[key] = function wrappedMethod(...args: unknown[]) { + try { + hooks.onBefore?.(key, args) + } catch { + // hook errors are caught and ignored + } + + let result: unknown + try { + result = (value as (...a: unknown[]) => unknown).apply(adapter, args) + } catch (error) { + try { + hooks.onError?.(key, error instanceof Error ? error : new Error(String(error))) + } catch { + // hook errors are caught and ignored + } + throw error + } + + // Async method: wire after/error hooks onto the returned Promise + if (result instanceof Promise) { + return (result as Promise) + .then((resolved: unknown) => { + try { + hooks.onAfter?.(key, resolved) + } catch { + // hook errors are caught and ignored + } + return resolved + }) + .catch((error: unknown) => { + try { + hooks.onError?.(key, error instanceof Error ? error : new Error(String(error))) + } catch { + // hook errors are caught and ignored + } + throw error + }) + } + + // Synchronous method: fire after hook immediately + try { + hooks.onAfter?.(key, result) + } catch { + // hook errors are caught and ignored + } + return result + } + } + + return wrapped as T +} From 5bdbc02a9c868ca818bb34802899a4344b6573a4 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:09:48 +0200 Subject: [PATCH 08/16] feat: add DAppBoosterProvider with per-adapter connect modal resolution --- .../provider/DAppBoosterProvider.test.tsx | 212 ++++++++++++++++++ .../react/provider/DAppBoosterProvider.tsx | 159 +++++++++++++ src/sdk/react/provider/context.ts | 28 +++ src/sdk/react/provider/index.ts | 3 + 4 files changed, 402 insertions(+) create mode 100644 src/sdk/react/provider/DAppBoosterProvider.test.tsx create mode 100644 src/sdk/react/provider/DAppBoosterProvider.tsx create mode 100644 src/sdk/react/provider/context.ts create mode 100644 src/sdk/react/provider/index.ts diff --git a/src/sdk/react/provider/DAppBoosterProvider.test.tsx b/src/sdk/react/provider/DAppBoosterProvider.test.tsx new file mode 100644 index 00000000..48f1ec03 --- /dev/null +++ b/src/sdk/react/provider/DAppBoosterProvider.test.tsx @@ -0,0 +1,212 @@ +import { renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { WalletAdapter } from '../../core/adapters/wallet' +import { ChainRegistryConflictError } from '../../core/errors' +import { useProviderContext } from './context' +import { DAppBoosterProvider } from './DAppBoosterProvider' + +vi.mock('@/src/wallet/providers', () => ({ + Web3Provider: ({ children }: { children: ReactNode }) => <>{children}, +})) + +const mockChain = { + caip2Id: 'eip155:1', + chainId: 1, + name: 'Ethereum', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + addressConfig: { format: 'hex', patterns: [], example: '0x...' }, +} + +const mockAdapter = { + chainType: 'evm', + supportedChains: [mockChain], + metadata: { + chainType: 'evm', + capabilities: { signTypedData: false, switchChain: false }, + formatAddress: (addr: string) => addr, + availableWallets: () => [], + }, + connect: vi.fn(), + reconnect: vi.fn(), + disconnect: vi.fn(), + getStatus: vi.fn(), + onStatusChange: vi.fn(), + signMessage: vi.fn(), + getSigner: vi.fn(), + switchChain: vi.fn(), +} as unknown as WalletAdapter + +describe('useProviderContext', () => { + it('throws when called outside DAppBoosterProvider', () => { + expect(() => renderHook(() => useProviderContext())).toThrow( + 'useProviderContext must be called inside a DAppBoosterProvider.', + ) + }) + + it('returns the context when inside DAppBoosterProvider', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const { result } = renderHook(() => useProviderContext(), { wrapper }) + expect(result.current.registry.getChain(1)).not.toBeNull() + }) + + it('builds registry from adapter supportedChains', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const { result } = renderHook(() => useProviderContext(), { wrapper }) + expect(result.current.registry.getChain(1)?.name).toBe('Ethereum') + expect(result.current.walletAdapters.evm).toBe(mockAdapter) + }) + + it('passes lifecycle hooks through to context', () => { + const onSubmit = vi.fn() + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useProviderContext(), { wrapper }) + expect(result.current.lifecycle?.onSubmit).toBe(onSubmit) + }) + + it('works with no config (empty default)', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useProviderContext(), { wrapper }) + expect(result.current.registry.getAllChains()).toHaveLength(0) + expect(result.current.walletAdapters).toEqual({}) + }) + + it('deduplicates structurally identical chain descriptors from wallet and transaction adapters', () => { + const sharedChain = { ...mockChain } + const walletAdapter = { + ...mockAdapter, + supportedChains: [sharedChain], + } as unknown as WalletAdapter + + const txAdapter = { + chainType: 'evm', + supportedChains: [{ ...sharedChain }], // same content, different reference + metadata: { chainType: 'evm', feeModel: 'eip1559', confirmationModel: 'blockConfirmations' }, + prepare: vi.fn(), + execute: vi.fn(), + confirm: vi.fn(), + } + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook(() => useProviderContext(), { wrapper }) + expect(result.current.registry.getAllChains()).toHaveLength(1) + expect(result.current.registry.getChain(1)?.name).toBe('Ethereum') + }) + + it('throws ChainRegistryConflictError when same caip2Id has structurally different descriptors', () => { + const walletAdapter = { + ...mockAdapter, + supportedChains: [mockChain], + } as unknown as WalletAdapter + + const txAdapter = { + chainType: 'evm', + supportedChains: [ + { + ...mockChain, + name: 'Ethereum (custom)', // same caip2Id, different content + }, + ], + metadata: { chainType: 'evm', feeModel: 'eip1559', confirmationModel: 'blockConfirmations' }, + prepare: vi.fn(), + execute: vi.fn(), + confirm: vi.fn(), + } + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + expect(() => renderHook(() => useProviderContext(), { wrapper })).toThrow( + ChainRegistryConflictError, + ) + }) + + it('throws ChainRegistryConflictError when two adapters register the same chainId', () => { + const conflictingAdapter = { + ...mockAdapter, + supportedChains: [ + { + caip2Id: 'eip155:1-duplicate', // different caip2Id so dedup doesn't catch it + chainId: 1, // same chainId as mockAdapter's chain — CONFLICT + name: 'Ethereum (duplicate)', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + addressConfig: { format: 'hex', patterns: [], example: '0x...' }, + }, + ], + } as unknown as WalletAdapter + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + expect(() => renderHook(() => useProviderContext(), { wrapper })).toThrow( + ChainRegistryConflictError, + ) + }) + + it('throws when adapter supportedChains contain a chainType that does not match the adapter chainType', () => { + const mismatchedAdapter = { + ...mockAdapter, + chainType: 'evm', + supportedChains: [ + { + caip2Id: 'solana:mainnet', + chainId: 'solana:mainnet', + name: 'Solana', + chainType: 'svm', // doesn't match adapter's 'evm' + nativeCurrency: { symbol: 'SOL', decimals: 9 }, + addressConfig: { format: 'base58', patterns: [], example: '...' }, + }, + ], + } as unknown as WalletAdapter + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + expect(() => renderHook(() => useProviderContext(), { wrapper })).toThrow('chainType mismatch') + }) +}) diff --git a/src/sdk/react/provider/DAppBoosterProvider.tsx b/src/sdk/react/provider/DAppBoosterProvider.tsx new file mode 100644 index 00000000..620934ba --- /dev/null +++ b/src/sdk/react/provider/DAppBoosterProvider.tsx @@ -0,0 +1,159 @@ +import { + type FC, + type PropsWithChildren, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react' +import type { DAppBoosterConfig, WalletAdapterBundle } from '../../core/adapters/provider' +import type { ChainDescriptor } from '../../core/chain/descriptor' +import { createChainRegistry } from '../../core/chain/registry' +import type { DAppBoosterContextValue } from './context' +import { DAppBoosterContext } from './context' + +interface DAppBoosterProviderProps extends PropsWithChildren { + config?: DAppBoosterConfig +} + +/** + * Bridge component rendered once per wallet bundle that has a `useConnectModal` hook. + * Calls the connector-specific hook (ConnectKit's useModal, RainbowKit's useConnectModal, etc.) + * inside the bundle's Provider tree, and registers the resulting `open` function in a shared ref. + */ +function ConnectModalBridge({ + adapterKey, + hook, + register, + unregister, +}: { + adapterKey: string + hook: () => { open: () => void; openAccount?: () => void } + register: (key: string, modals: { open: () => void; openAccount?: () => void }) => void + unregister: (key: string) => void +}) { + const modals = hook() + useEffect(() => { + register(adapterKey, modals) + return () => unregister(adapterKey) + }, [adapterKey, modals, register, unregister]) + return null +} + +/** + * Root provider for the dAppBooster adapter architecture. + * + * Automatically mounts each wallet bundle's Provider (wagmi, query client, + * wallet-specific UI provider) so the active connector is determined entirely + * by the `config.wallets` you pass in — no hardcoded Web3Provider. + * + * Each bundle's `useConnectModal` hook is called via a bridge component inside + * the bundle's Provider tree. The resulting `open` functions are stored per + * adapter key so that `useWallet` can resolve the correct modal for any adapter. + * + * @note Memoize the `config` prop (e.g. with `useMemo`) to avoid rebuilding + * the chain registry on every parent re-render. + */ +export const DAppBoosterProvider: FC = ({ config = {}, children }) => { + const walletEntries = Object.entries(config.wallets ?? {}) + const connectModalsRef = useRef void; openAccount?: () => void }>>( + {}, + ) + + const registerModal = useCallback( + (key: string, modals: { open: () => void; openAccount?: () => void }) => { + connectModalsRef.current[key] = modals + }, + [], + ) + + const unregisterModal = useCallback((key: string) => { + delete connectModalsRef.current[key] + }, []) + + const contextValue = useMemo(() => { + const wallets = Object.entries(config.wallets ?? {}) + const txAdapters = Object.entries(config.transactions ?? {}) + + for (const [key, bundle] of wallets) { + for (const chain of bundle.adapter.supportedChains) { + if (chain.chainType !== bundle.adapter.chainType) { + throw new Error( + `Wallet adapter "${key}" has chainType "${bundle.adapter.chainType}" but supportedChains contains "${chain.name}" with chainType "${chain.chainType}" — chainType mismatch.`, + ) + } + } + } + for (const [key, adapter] of txAdapters) { + for (const chain of adapter.supportedChains) { + if (chain.chainType !== adapter.chainType) { + throw new Error( + `Transaction adapter "${key}" has chainType "${adapter.chainType}" but supportedChains contains "${chain.name}" with chainType "${chain.chainType}" — chainType mismatch.`, + ) + } + } + } + + const allChains = [ + ...(config.chains ?? []), + ...wallets.flatMap(([, bundle]) => bundle.adapter.supportedChains), + ...txAdapters.flatMap(([, adapter]) => adapter.supportedChains), + ] + + const seen = new Map() + const deduped: ChainDescriptor[] = [] + + for (const chain of allChains) { + const existing = seen.get(chain.caip2Id) + if (!existing) { + seen.set(chain.caip2Id, chain) + deduped.push(chain) + } else if (JSON.stringify(existing) !== JSON.stringify(chain)) { + // Structurally different descriptors with the same caip2Id — let the registry throw + deduped.push(chain) + } + // Otherwise structurally identical — skip the duplicate + } + + const registry = createChainRegistry(deduped) + + return { + walletAdapters: Object.fromEntries(wallets.map(([key, bundle]) => [key, bundle.adapter])), + transactionAdapters: config.transactions ?? {}, + registry, + lifecycle: config.lifecycle, + walletLifecycle: config.walletLifecycle, + readClientFactories: config.readClientFactories ?? [], + connectModalsRef, + } + }, [config]) + + const bridges = walletEntries + .filter(([, bundle]) => bundle.useConnectModal) + .map(([key, bundle]) => ( + } + register={registerModal} + unregister={unregisterModal} + /> + )) + + let wrapped: ReactNode = ( + + {bridges} + {children} + + ) + + for (const [, bundle] of walletEntries) { + if (bundle.Provider) { + const BundleProvider = bundle.Provider + wrapped = {wrapped} + } + } + + return wrapped +} diff --git a/src/sdk/react/provider/context.ts b/src/sdk/react/provider/context.ts new file mode 100644 index 00000000..a86defc2 --- /dev/null +++ b/src/sdk/react/provider/context.ts @@ -0,0 +1,28 @@ +import type { RefObject } from 'react' +import { createContext, useContext } from 'react' +import type { TransactionLifecycle, WalletLifecycle } from '../../core/adapters/lifecycle' +import type { ReadClientFactory } from '../../core/adapters/provider' +import type { TransactionAdapter } from '../../core/adapters/transaction' +import type { WalletAdapter } from '../../core/adapters/wallet' +import type { ChainRegistry } from '../../core/chain/registry' + +export interface DAppBoosterContextValue { + walletAdapters: Record + transactionAdapters: Record + registry: ChainRegistry + lifecycle: TransactionLifecycle | undefined + walletLifecycle: WalletLifecycle | undefined + readClientFactories: ReadClientFactory[] + /** Per-adapter modal openers, populated by bridge components inside bundle providers. */ + connectModalsRef: RefObject void; openAccount?: () => void }>> +} + +export const DAppBoosterContext = createContext(null) + +export function useProviderContext(): DAppBoosterContextValue { + const context = useContext(DAppBoosterContext) + if (!context) { + throw new Error('useProviderContext must be called inside a DAppBoosterProvider.') + } + return context +} diff --git a/src/sdk/react/provider/index.ts b/src/sdk/react/provider/index.ts new file mode 100644 index 00000000..8c89d629 --- /dev/null +++ b/src/sdk/react/provider/index.ts @@ -0,0 +1,3 @@ +export type { DAppBoosterContextValue } from './context' +export { useProviderContext } from './context' +export { DAppBoosterProvider } from './DAppBoosterProvider' From 326c023d3c89e884e8c1e08a75140a52aad13c50 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:10:11 +0200 Subject: [PATCH 09/16] feat: implement useWallet, useTransaction, useMultiWallet, useReadOnly, and useChainRegistry --- src/sdk/react/hooks/index.ts | 14 + src/sdk/react/hooks/useChainRegistry.test.ts | 66 ++++ src/sdk/react/hooks/useChainRegistry.ts | 7 + src/sdk/react/hooks/useMultiWallet.test.tsx | 155 ++++++++ src/sdk/react/hooks/useMultiWallet.ts | 146 ++++++++ src/sdk/react/hooks/useReadOnly.test.ts | 90 +++++ src/sdk/react/hooks/useReadOnly.ts | 41 +++ src/sdk/react/hooks/useTransaction.test.tsx | 361 +++++++++++++++++++ src/sdk/react/hooks/useTransaction.ts | 207 +++++++++++ src/sdk/react/hooks/useWallet.test.tsx | 212 +++++++++++ src/sdk/react/hooks/useWallet.ts | 215 +++++++++++ src/sdk/react/index.ts | 4 + 12 files changed, 1518 insertions(+) create mode 100644 src/sdk/react/hooks/index.ts create mode 100644 src/sdk/react/hooks/useChainRegistry.test.ts create mode 100644 src/sdk/react/hooks/useChainRegistry.ts create mode 100644 src/sdk/react/hooks/useMultiWallet.test.tsx create mode 100644 src/sdk/react/hooks/useMultiWallet.ts create mode 100644 src/sdk/react/hooks/useReadOnly.test.ts create mode 100644 src/sdk/react/hooks/useReadOnly.ts create mode 100644 src/sdk/react/hooks/useTransaction.test.tsx create mode 100644 src/sdk/react/hooks/useTransaction.ts create mode 100644 src/sdk/react/hooks/useWallet.test.tsx create mode 100644 src/sdk/react/hooks/useWallet.ts create mode 100644 src/sdk/react/index.ts diff --git a/src/sdk/react/hooks/index.ts b/src/sdk/react/hooks/index.ts new file mode 100644 index 00000000..b2a754d6 --- /dev/null +++ b/src/sdk/react/hooks/index.ts @@ -0,0 +1,14 @@ +export type { ChainRegistry } from '../../core/chain/registry' +export { useChainRegistry } from './useChainRegistry' +export type { UseMultiWalletReturn } from './useMultiWallet' +export { useMultiWallet } from './useMultiWallet' +export type { UseReadOnlyOptions, UseReadOnlyReturn } from './useReadOnly' +export { useReadOnly } from './useReadOnly' +export type { + TransactionExecutionPhase, + UseTransactionOptions, + UseTransactionReturn, +} from './useTransaction' +export { useTransaction } from './useTransaction' +export type { UseWalletOptions, UseWalletReturn } from './useWallet' +export { useWallet } from './useWallet' diff --git a/src/sdk/react/hooks/useChainRegistry.test.ts b/src/sdk/react/hooks/useChainRegistry.test.ts new file mode 100644 index 00000000..ad23ea15 --- /dev/null +++ b/src/sdk/react/hooks/useChainRegistry.test.ts @@ -0,0 +1,66 @@ +import { renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { WalletAdapter } from '../../core/adapters/wallet' +import { DAppBoosterProvider } from '../provider/DAppBoosterProvider' +import { useChainRegistry } from './useChainRegistry' + +vi.mock('@/src/wallet/providers', () => ({ + Web3Provider: ({ children }: { children: ReactNode }) => children, +})) + +const mockChain = { + caip2Id: 'eip155:1', + chainId: 1, + name: 'Ethereum', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + addressConfig: { format: 'hex', patterns: [], example: '0x...' }, +} + +const makeMockAdapter = (): WalletAdapter => + ({ + chainType: 'evm', + supportedChains: [mockChain], + metadata: { + chainType: 'evm', + capabilities: { signTypedData: false, switchChain: false }, + formatAddress: (addr: string) => addr, + availableWallets: () => [], + }, + connect: vi.fn(), + reconnect: vi.fn(), + disconnect: vi.fn(), + getStatus: vi.fn(), + onStatusChange: vi.fn(() => vi.fn()), + signMessage: vi.fn(), + getSigner: vi.fn(), + switchChain: vi.fn(), + }) as unknown as WalletAdapter + +const makeWrapper = + (adapter: WalletAdapter) => + ({ children }: { children: ReactNode }) => + createElement(DAppBoosterProvider, { config: { wallets: { evm: { adapter } } } }, children) + +describe('useChainRegistry', () => { + it('returns the registry from context', () => { + const adapter = makeMockAdapter() + const wrapper = makeWrapper(adapter) + const { result } = renderHook(() => useChainRegistry(), { wrapper }) + expect(result.current).toBeDefined() + expect(typeof result.current.getChain).toBe('function') + expect(typeof result.current.getAllChains).toBe('function') + }) + + it('registry has correct chains from adapter', () => { + const adapter = makeMockAdapter() + const wrapper = makeWrapper(adapter) + const { result } = renderHook(() => useChainRegistry(), { wrapper }) + const chain = result.current.getChain(1) + expect(chain).not.toBeNull() + expect(chain?.name).toBe('Ethereum') + expect(chain?.caip2Id).toBe('eip155:1') + }) +}) diff --git a/src/sdk/react/hooks/useChainRegistry.ts b/src/sdk/react/hooks/useChainRegistry.ts new file mode 100644 index 00000000..aa39b859 --- /dev/null +++ b/src/sdk/react/hooks/useChainRegistry.ts @@ -0,0 +1,7 @@ +import type { ChainRegistry } from '../../core/chain/registry' +import { useProviderContext } from '../provider/context' + +/** Returns the ChainRegistry from the nearest DAppBoosterProvider. */ +export function useChainRegistry(): ChainRegistry { + return useProviderContext().registry +} diff --git a/src/sdk/react/hooks/useMultiWallet.test.tsx b/src/sdk/react/hooks/useMultiWallet.test.tsx new file mode 100644 index 00000000..b39154f6 --- /dev/null +++ b/src/sdk/react/hooks/useMultiWallet.test.tsx @@ -0,0 +1,155 @@ +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { WalletAdapter, WalletStatus } from '../../core/adapters/wallet' +import { DAppBoosterProvider } from '../provider/DAppBoosterProvider' +import { useMultiWallet } from './useMultiWallet' + +vi.mock('@/src/wallet/providers', () => ({ + Web3Provider: ({ children }: { children: ReactNode }) => children, +})) + +const mockStatus: WalletStatus = { + connected: false, + activeAccount: null, + connectedChainIds: [], + connecting: false, +} + +const makeMockAdapter = (overrides?: Partial): WalletAdapter => { + const unsubscribe = vi.fn() + return { + chainType: 'evm', + supportedChains: [ + { + caip2Id: 'eip155:1', + chainId: 1, + name: 'Ethereum', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + addressConfig: { format: 'hex', patterns: [], example: '0x...' }, + }, + ], + metadata: { + chainType: 'evm', + capabilities: { signTypedData: false, switchChain: false }, + formatAddress: (addr: string) => addr, + availableWallets: () => [], + }, + connect: vi.fn(), + reconnect: vi.fn(), + disconnect: vi.fn(), + getStatus: vi.fn(() => mockStatus), + onStatusChange: vi.fn(() => unsubscribe), + signMessage: vi.fn(), + getSigner: vi.fn(), + switchChain: vi.fn(), + ...overrides, + } as unknown as WalletAdapter +} + +const makeWrapper = + (wallets: Record) => + ({ children }: { children: ReactNode }) => + createElement(DAppBoosterProvider, { config: { wallets } }, children) + +const makeEmptyWrapper = + () => + ({ children }: { children: ReactNode }) => + createElement(DAppBoosterProvider, { config: {} }, children) + +describe('useMultiWallet', () => { + it('returns empty record with no adapters', () => { + const wrapper = makeEmptyWrapper() + const { result } = renderHook(() => useMultiWallet(), { wrapper }) + expect(result.current).toEqual({}) + }) + + it('returns one entry per adapter', () => { + const evmAdapter = makeMockAdapter({ chainType: 'evm' }) + const solAdapter = makeMockAdapter({ + chainType: 'solana', + supportedChains: [ + { + caip2Id: 'solana:mainnet', + chainId: 'solana:mainnet', + name: 'Solana', + chainType: 'solana', + nativeCurrency: { symbol: 'SOL', decimals: 9 }, + addressConfig: { format: 'base58', patterns: [], example: '...' }, + }, + ], + }) + const wrapper = makeWrapper({ evm: { adapter: evmAdapter }, solana: { adapter: solAdapter } }) + const { result } = renderHook(() => useMultiWallet(), { wrapper }) + expect(Object.keys(result.current)).toHaveLength(2) + expect(result.current).toHaveProperty('evm') + expect(result.current).toHaveProperty('solana') + }) + + it('status is correct for connected adapter', () => { + const connectedStatus: WalletStatus = { + connected: true, + activeAccount: '0xabc', + connectedChainIds: [1], + connecting: false, + } + const adapter = makeMockAdapter({ getStatus: vi.fn(() => connectedStatus) }) + const wrapper = makeWrapper({ evm: { adapter } }) + const { result } = renderHook(() => useMultiWallet(), { wrapper }) + expect(result.current.evm.status).toEqual(connectedStatus) + expect(result.current.evm.isReady).toBe(true) + expect(result.current.evm.needsConnect).toBe(false) + }) + + it('status subscription updates when onStatusChange fires', () => { + let capturedListener: ((status: WalletStatus) => void) | null = null + const adapter = makeMockAdapter({ + onStatusChange: vi.fn((listener) => { + capturedListener = listener + return vi.fn() + }), + }) + const wrapper = makeWrapper({ evm: { adapter } }) + const { result } = renderHook(() => useMultiWallet(), { wrapper }) + + const updatedStatus: WalletStatus = { + connected: true, + activeAccount: '0xabc', + connectedChainIds: [1], + connecting: false, + } + + act(() => { + capturedListener?.(updatedStatus) + }) + + expect(result.current.evm.status).toEqual(updatedStatus) + }) + + it('calls unsubscribes for all adapters on unmount', () => { + const unsubscribeEvm = vi.fn() + const unsubscribeSol = vi.fn() + const evmAdapter = makeMockAdapter({ onStatusChange: vi.fn(() => unsubscribeEvm) }) + const solAdapter = makeMockAdapter({ + chainType: 'solana', + supportedChains: [ + { + caip2Id: 'solana:mainnet', + chainId: 'solana:mainnet', + name: 'Solana', + chainType: 'solana', + nativeCurrency: { symbol: 'SOL', decimals: 9 }, + addressConfig: { format: 'base58', patterns: [], example: '...' }, + }, + ], + onStatusChange: vi.fn(() => unsubscribeSol), + }) + const wrapper = makeWrapper({ evm: { adapter: evmAdapter }, solana: { adapter: solAdapter } }) + const { unmount } = renderHook(() => useMultiWallet(), { wrapper }) + unmount() + expect(unsubscribeEvm).toHaveBeenCalledOnce() + expect(unsubscribeSol).toHaveBeenCalledOnce() + }) +}) diff --git a/src/sdk/react/hooks/useMultiWallet.ts b/src/sdk/react/hooks/useMultiWallet.ts new file mode 100644 index 00000000..9114e4ef --- /dev/null +++ b/src/sdk/react/hooks/useMultiWallet.ts @@ -0,0 +1,146 @@ +import { useEffect, useMemo, useState } from 'react' +import type { WalletLifecycle } from '../../core/adapters/lifecycle' +import type { + SignatureResult, + SignMessageInput, + SignTypedDataInput, + WalletAdapter, + WalletStatus, +} from '../../core/adapters/wallet' +import { useProviderContext } from '../provider/context' +import type { UseWalletReturn } from './useWallet' + +function fireWalletLifecycle( + key: K, + lifecycle: WalletLifecycle | undefined, + ...args: Parameters> +): void { + const fn = lifecycle?.[key] as ((...a: unknown[]) => void) | undefined + if (!fn) { + return + } + try { + fn(...(args as unknown[])) + } catch (err) { + console.error(`useMultiWallet lifecycle hook "${key}" threw:`, err) + } +} + +function wrapSignMessage( + adapter: WalletAdapter, + lifecycle: WalletLifecycle | undefined, +): (input: SignMessageInput) => Promise { + return async (input) => { + fireWalletLifecycle('onSign', lifecycle, 'message', input) + try { + const result = await adapter.signMessage(input) + fireWalletLifecycle('onSignComplete', lifecycle, result) + return result + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + fireWalletLifecycle('onSignError', lifecycle, error) + throw err + } + } +} + +function wrapSignTypedData( + adapter: WalletAdapter, + lifecycle: WalletLifecycle | undefined, +): ((input: SignTypedDataInput) => Promise) | undefined { + if (!adapter.signTypedData) { + return undefined + } + const { signTypedData } = adapter + return async (input) => { + fireWalletLifecycle('onSign', lifecycle, 'typedData', input) + try { + const result = await signTypedData(input) + fireWalletLifecycle('onSignComplete', lifecycle, result) + return result + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + fireWalletLifecycle('onSignError', lifecycle, error) + throw err + } + } +} + +/** Returns one UseWalletReturn entry per registered wallet adapter, keyed by adapter name. */ +export type UseMultiWalletReturn = Record + +/** + * Returns a record of UseWalletReturn for every registered wallet adapter. + * Keys match the names used in DAppBoosterConfig.wallets (e.g. 'evm', 'solana'). + */ +export function useMultiWallet(): UseMultiWalletReturn { + const { walletAdapters, walletLifecycle, connectModalsRef } = useProviderContext() + + const [statuses, setStatuses] = useState>(() => + Object.fromEntries( + Object.entries(walletAdapters).map(([key, adapter]) => [key, adapter.getStatus()]), + ), + ) + + useEffect(() => { + // Re-read current statuses on mount (guards against changes between render and effect) + setStatuses( + Object.fromEntries( + Object.entries(walletAdapters).map(([key, adapter]) => [key, adapter.getStatus()]), + ), + ) + // Subscribe to all adapters + const unsubscribes = Object.entries(walletAdapters).map(([key, adapter]) => + adapter.onStatusChange((status) => { + setStatuses((previous) => ({ ...previous, [key]: status })) + }), + ) + return () => { + for (const unsub of unsubscribes) { + unsub() + } + } + }, [walletAdapters]) + + return useMemo( + () => + Object.fromEntries( + Object.entries(walletAdapters).map(([key, adapter]) => { + const status = statuses[key] ?? adapter.getStatus() + const isReady = status.connected + const needsConnect = !status.connected && !status.connecting + const needsChainSwitch = false + + return [ + key, + { + adapter, + adapterKey: key, + status, + isReady, + needsConnect, + needsChainSwitch, + connect: adapter.connect, + disconnect: adapter.disconnect, + signMessage: wrapSignMessage(adapter, walletLifecycle), + signTypedData: wrapSignTypedData(adapter, walletLifecycle), + getSigner: adapter.getSigner, + switchChain: adapter.switchChain, + openConnectModal: () => { + connectModalsRef.current[key]?.open() + }, + openAccountModal: () => { + const modals = connectModalsRef.current[key] + if (modals?.openAccount) { + modals.openAccount() + } else { + modals?.open() + } + }, + }, + ] + }), + ), + [statuses, walletAdapters, walletLifecycle, connectModalsRef], + ) +} diff --git a/src/sdk/react/hooks/useReadOnly.test.ts b/src/sdk/react/hooks/useReadOnly.test.ts new file mode 100644 index 00000000..43cb2368 --- /dev/null +++ b/src/sdk/react/hooks/useReadOnly.test.ts @@ -0,0 +1,90 @@ +import { renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { ReadClientFactory } from '../../core/adapters/provider' +import { DAppBoosterProvider } from '../provider/DAppBoosterProvider' +import { useReadOnly } from './useReadOnly' + +vi.mock('@/src/wallet/providers', () => ({ + Web3Provider: ({ children }: { children: ReactNode }) => children, +})) + +const mockChain = { + caip2Id: 'eip155:1', + chainId: 1, + name: 'Ethereum', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' }, +} + +const mockChainWithEndpoint = { + ...mockChain, + endpoints: [{ url: 'https://rpc.example.com', protocol: 'json-rpc' as const }], +} + +const makeWrapper = + (config: Parameters[0]['config']) => + ({ children }: { children: ReactNode }) => + createElement(DAppBoosterProvider, { config }, children) + +describe('useReadOnly', () => { + it('returns null chain when chainId not in registry', () => { + const wrapper = makeWrapper({ chains: [] }) + const { result } = renderHook(() => useReadOnly({ chainId: 999 }), { wrapper }) + expect(result.current.chain).toBeNull() + expect(result.current.client).toBeNull() + }) + + it('returns chain descriptor when chainId found', () => { + const wrapper = makeWrapper({ chains: [mockChain] }) + const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper }) + expect(result.current.chain).not.toBeNull() + expect(result.current.chain?.name).toBe('Ethereum') + }) + + it('client is null when no factory registered', () => { + const wrapper = makeWrapper({ chains: [mockChainWithEndpoint] }) + const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper }) + expect(result.current.chain).not.toBeNull() + expect(result.current.client).toBeNull() + }) + + it('client is null when chain has no endpoints', () => { + const mockFactory: ReadClientFactory = { + chainType: 'evm', + createClient: vi.fn(), + } + const wrapper = makeWrapper({ + chains: [mockChain], + readClientFactories: [mockFactory], + }) + const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper }) + expect(result.current.chain).not.toBeNull() + expect(result.current.client).toBeNull() + expect(mockFactory.createClient).not.toHaveBeenCalled() + }) + + it('client is created from factory when factory and endpoint exist', () => { + const mockFactory: ReadClientFactory = { + chainType: 'evm', + createClient: vi.fn((endpoint, chainId) => ({ endpoint, chainId })), + } + const wrapper = makeWrapper({ + chains: [mockChainWithEndpoint], + readClientFactories: [mockFactory], + }) + const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper }) + expect(result.current.chain).not.toBeNull() + expect(result.current.client).not.toBeNull() + expect(mockFactory.createClient).toHaveBeenCalledWith( + mockChainWithEndpoint.endpoints[0], + mockChainWithEndpoint.chainId, + ) + expect(result.current.client).toEqual({ + endpoint: mockChainWithEndpoint.endpoints[0], + chainId: mockChainWithEndpoint.chainId, + }) + }) +}) diff --git a/src/sdk/react/hooks/useReadOnly.ts b/src/sdk/react/hooks/useReadOnly.ts new file mode 100644 index 00000000..3971caab --- /dev/null +++ b/src/sdk/react/hooks/useReadOnly.ts @@ -0,0 +1,41 @@ +import { useMemo } from 'react' +import type { ChainDescriptor } from '../../core/chain' +import { useProviderContext } from '../provider/context' + +export interface UseReadOnlyOptions { + chainId: string | number +} + +export interface UseReadOnlyReturn { + chain: ChainDescriptor | null + /** Opaque read-only client created by the matching ReadClientFactory. null if no factory registered. */ + client: unknown +} + +/** + * Returns the ChainDescriptor and a read-only client for the given chainId. + * The client is created by the matching ReadClientFactory registered in DAppBoosterConfig. + * Returns null for client if no factory is registered or the chain has no endpoints. + */ +export function useReadOnly(options: UseReadOnlyOptions): UseReadOnlyReturn { + const { registry, readClientFactories } = useProviderContext() + + const chain = useMemo(() => registry.getChain(options.chainId), [registry, options.chainId]) + + const client = useMemo(() => { + if (!chain) { + return null + } + const factory = readClientFactories.find((f) => f.chainType === chain.chainType) + if (!factory) { + return null + } + const endpoint = chain.endpoints?.[0] + if (!endpoint) { + return null + } + return factory.createClient(endpoint, chain.chainId) + }, [chain, readClientFactories]) + + return { chain, client } +} diff --git a/src/sdk/react/hooks/useTransaction.test.tsx b/src/sdk/react/hooks/useTransaction.test.tsx new file mode 100644 index 00000000..4f06421f --- /dev/null +++ b/src/sdk/react/hooks/useTransaction.test.tsx @@ -0,0 +1,361 @@ +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { TransactionLifecycle } from '../../core/adapters/lifecycle' +import type { + PreStep, + TransactionAdapter, + TransactionParams, +} from '../../core/adapters/transaction' +import type { WalletAdapter } from '../../core/adapters/wallet' +import { PreStepsNotExecutedError, TransactionNotReadyError } from '../../core/errors' +import { DAppBoosterProvider } from '../provider/DAppBoosterProvider' +import { useTransaction } from './useTransaction' + +vi.mock('@/src/wallet/providers', () => ({ + Web3Provider: ({ children }: { children: ReactNode }) => <>{children}, +})) + +const mockSigner = {} + +const mockChain = { + caip2Id: 'eip155:1', + chainId: 1, + name: 'Ethereum', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' }, +} + +const makeMockTxAdapter = (overrides?: Partial): TransactionAdapter => ({ + chainType: 'evm', + supportedChains: [mockChain], + metadata: { chainType: 'evm', feeModel: 'eip1559', confirmationModel: 'blockConfirmations' }, + prepare: vi.fn(async () => ({ ready: true })), + execute: vi.fn(async () => ({ chainType: 'evm', id: '0xhash', chainId: 1 })), + confirm: vi.fn(async () => ({ + status: 'success' as const, + ref: { chainType: 'evm', id: '0xhash', chainId: 1 }, + receipt: {}, + })), + ...overrides, +}) + +const makeMockWalletAdapter = (): WalletAdapter => + ({ + chainType: 'evm', + supportedChains: [mockChain], + metadata: { + chainType: 'evm', + capabilities: { signTypedData: false, switchChain: false }, + formatAddress: (a: string) => a, + availableWallets: () => [], + }, + connect: vi.fn(), + reconnect: vi.fn(), + disconnect: vi.fn(), + getStatus: vi.fn(() => ({ + connected: true, + activeAccount: '0xabc', + connectedChainIds: [1], + connecting: false, + })), + onStatusChange: vi.fn(() => vi.fn()), + signMessage: vi.fn(), + getSigner: vi.fn(async () => mockSigner), + switchChain: vi.fn(), + }) as unknown as WalletAdapter + +const makeWrapper = ( + config: { + txAdapter?: TransactionAdapter + walletAdapter?: WalletAdapter + lifecycle?: TransactionLifecycle + } = {}, +) => { + const txAdapter = config.txAdapter ?? makeMockTxAdapter() + const walletAdapter = config.walletAdapter ?? makeMockWalletAdapter() + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const testParams: TransactionParams = { chainId: 1, payload: { to: '0x1234', value: '0' } } + +describe('useTransaction', () => { + it('starts in idle phase', () => { + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper() }) + expect(result.current.phase).toBe('idle') + }) + + it('has null initial state', () => { + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper() }) + expect(result.current.prepareResult).toBeNull() + expect(result.current.ref).toBeNull() + expect(result.current.result).toBeNull() + expect(result.current.preStepResults).toEqual([]) + expect(result.current.explorerUrl).toBeNull() + expect(result.current.error).toBeNull() + }) + + it('transitions through prepare → submit → confirm → idle on successful execute', async () => { + const phases: string[] = [] + + const txAdapter = makeMockTxAdapter({ + prepare: vi.fn(async () => { + phases.push('during-prepare') + return { ready: true } + }), + execute: vi.fn(async () => { + phases.push('during-execute') + return { chainType: 'evm', id: '0xhash', chainId: 1 } + }), + confirm: vi.fn(async () => { + phases.push('during-confirm') + return { + status: 'success' as const, + ref: { chainType: 'evm', id: '0xhash', chainId: 1 }, + receipt: {}, + } + }), + }) + + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper({ txAdapter }) }) + + await act(async () => { + await result.current.execute(testParams) + }) + + expect(result.current.phase).toBe('idle') + expect(result.current.result?.status).toBe('success') + expect(phases).toEqual(['during-prepare', 'during-execute', 'during-confirm']) + }) + + it('returns the result from confirm', async () => { + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper() }) + + await act(async () => { + await result.current.execute(testParams) + }) + + expect(result.current.result?.status).toBe('success') + }) + + it('sets ref after execute succeeds', async () => { + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper() }) + + await act(async () => { + await result.current.execute(testParams) + }) + + expect(result.current.ref?.id).toBe('0xhash') + }) + + it('throws TransactionNotReadyError when prepare returns ready: false', async () => { + const txAdapter = makeMockTxAdapter({ + prepare: vi.fn(async () => ({ ready: false, reason: 'Insufficient gas' })), + }) + + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper({ txAdapter }) }) + + let caught: unknown + await act(async () => { + try { + await result.current.execute(testParams) + } catch (error) { + caught = error + } + }) + + expect(caught).toBeInstanceOf(TransactionNotReadyError) + expect(result.current.error).toBeInstanceOf(TransactionNotReadyError) + expect(result.current.error?.message).toContain('Insufficient gas') + }) + + it('throws PreStepsNotExecutedError when autoPreSteps is false and params has preSteps', async () => { + const preStep: PreStep = { label: 'Approve', params: { chainId: 1, payload: {} } } + const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), { + wrapper: makeWrapper(), + }) + + await expect( + act(async () => { + await result.current.execute({ ...testParams, preSteps: [preStep] }) + }), + ).rejects.toThrow(PreStepsNotExecutedError) + }) + + it('auto-executes preSteps when autoPreSteps is true (default)', async () => { + const txAdapter = makeMockTxAdapter() + const preStep: PreStep = { label: 'Approve', params: { chainId: 1, payload: {} } } + + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper({ txAdapter }) }) + + await act(async () => { + await result.current.execute({ ...testParams, preSteps: [preStep] }) + }) + + // execute called twice: once for preStep, once for main tx + expect(txAdapter.execute).toHaveBeenCalledTimes(2) + expect(result.current.preStepResults).toHaveLength(1) + }) + + it('sets error and resets to idle phase when execute throws', async () => { + const txAdapter = makeMockTxAdapter({ + prepare: vi.fn(async () => { + throw new Error('prepare failed') + }), + }) + + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper({ txAdapter }) }) + + await act(async () => { + try { + await result.current.execute(testParams) + } catch { + // expected to throw + } + }) + + expect(result.current.phase).toBe('idle') + expect(result.current.error?.message).toBe('prepare failed') + }) + + it('calls global lifecycle.onSubmit then per-operation lifecycle.onSubmit in order', async () => { + const globalOnSubmit = vi.fn() + const localOnSubmit = vi.fn() + + const { result } = renderHook( + () => useTransaction({ lifecycle: { onSubmit: localOnSubmit } }), + { wrapper: makeWrapper({ lifecycle: { onSubmit: globalOnSubmit } }) }, + ) + + await act(async () => { + await result.current.execute(testParams) + }) + + expect(globalOnSubmit).toHaveBeenCalledOnce() + expect(localOnSubmit).toHaveBeenCalledOnce() + expect(globalOnSubmit.mock.invocationCallOrder[0]).toBeLessThan( + localOnSubmit.mock.invocationCallOrder[0], + ) + }) + + it('reset() clears all state', async () => { + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper() }) + + await act(async () => { + await result.current.execute(testParams) + }) + + expect(result.current.result).not.toBeNull() + + act(() => { + result.current.reset() + }) + + expect(result.current.phase).toBe('idle') + expect(result.current.prepareResult).toBeNull() + expect(result.current.ref).toBeNull() + expect(result.current.result).toBeNull() + expect(result.current.preStepResults).toEqual([]) + expect(result.current.explorerUrl).toBeNull() + expect(result.current.error).toBeNull() + }) + + it('explorerUrl is null when no ref', () => { + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper() }) + expect(result.current.explorerUrl).toBeNull() + }) + + it('explorerUrl is null when chain has no explorer config', async () => { + // mockChain has no explorer config, so getExplorerUrl returns null + const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper() }) + + await act(async () => { + await result.current.execute(testParams) + }) + + // ref is set but chain has no explorer — explorerUrl should be null + expect(result.current.ref).not.toBeNull() + expect(result.current.explorerUrl).toBeNull() + }) + + it('does not abort the transaction when a lifecycle hook throws', async () => { + const throwingOnSubmit = vi.fn(() => { + throw new Error('lifecycle error') + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { result } = renderHook( + () => useTransaction({ lifecycle: { onSubmit: throwingOnSubmit } }), + { wrapper: makeWrapper() }, + ) + + await act(async () => { + await result.current.execute(testParams) + }) + consoleError.mockRestore() + + // Transaction should complete successfully despite the lifecycle hook throwing + expect(result.current.result?.status).toBe('success') + expect(result.current.error).toBeNull() + }) + + it('fires onReplace lifecycle hook when confirm returns a replaced transaction', async () => { + const originalRef = { chainType: 'evm', id: '0xoriginal', chainId: 1 } + const replacedRef = { chainType: 'evm', id: '0xreplaced', chainId: 1 } + const globalOnReplace = vi.fn() + + const txAdapter = makeMockTxAdapter({ + execute: vi.fn(async () => originalRef), + confirm: vi.fn(async () => ({ + status: 'success' as const, + ref: replacedRef, + receipt: {}, + })), + }) + + const { result } = renderHook(() => useTransaction(), { + wrapper: makeWrapper({ txAdapter, lifecycle: { onReplace: globalOnReplace } }), + }) + + await act(async () => { + await result.current.execute(testParams) + }) + + expect(globalOnReplace).toHaveBeenCalledOnce() + expect(globalOnReplace).toHaveBeenCalledWith(originalRef, replacedRef, 'replaced') + }) + + it('fires onError lifecycle hook when an error occurs', async () => { + const globalOnError = vi.fn() + const txAdapter = makeMockTxAdapter({ + prepare: vi.fn(async () => { + throw new Error('tx failed') + }), + }) + + const { result } = renderHook(() => useTransaction(), { + wrapper: makeWrapper({ txAdapter, lifecycle: { onError: globalOnError } }), + }) + + await act(async () => { + try { + await result.current.execute(testParams) + } catch { + // expected + } + }) + + expect(globalOnError).toHaveBeenCalledWith('prepare', expect.any(Error)) + }) +}) diff --git a/src/sdk/react/hooks/useTransaction.ts b/src/sdk/react/hooks/useTransaction.ts new file mode 100644 index 00000000..c2746313 --- /dev/null +++ b/src/sdk/react/hooks/useTransaction.ts @@ -0,0 +1,207 @@ +import { useCallback, useMemo, useState } from 'react' +import type { TransactionLifecycle, TransactionPhase } from '../../core/adapters/lifecycle' +import type { + ConfirmOptions, + PrepareResult, + TransactionParams, + TransactionRef, + TransactionResult, +} from '../../core/adapters/transaction' +import { getExplorerUrl } from '../../core/chain/explorer' +import { + AdapterNotFoundError, + PreStepsNotExecutedError, + TransactionNotReadyError, + WalletNotConnectedError, +} from '../../core/errors' +import { useProviderContext } from '../provider/context' + +export type TransactionExecutionPhase = 'idle' | 'prepare' | 'preStep' | 'submit' | 'confirm' + +export interface UseTransactionOptions { + /** Per-operation lifecycle hooks — merged with global lifecycle. Global fires first. */ + lifecycle?: TransactionLifecycle + /** Auto-execute preSteps before the main transaction. Default: true. */ + autoPreSteps?: boolean + /** Options forwarded to confirm(). */ + confirmOptions?: ConfirmOptions +} + +export interface UseTransactionReturn { + phase: TransactionExecutionPhase + prepareResult: PrepareResult | null + ref: TransactionRef | null + result: TransactionResult | null + preStepResults: TransactionResult[] + explorerUrl: string | null + error: Error | null + execute: (params: TransactionParams) => Promise + reset: () => void +} + +function fireLifecycle( + key: K, + global: TransactionLifecycle | undefined, + local: TransactionLifecycle | undefined, + ...args: Parameters> +): void { + for (const hooks of [global, local]) { + const fn = hooks?.[key] as ((...a: unknown[]) => void) | undefined + if (!fn) { + continue + } + try { + fn(...(args as unknown[])) + } catch (err) { + console.error(`useTransaction lifecycle hook "${key}" threw:`, err) + } + } +} + +/** + * Executes a chain transaction through the registered TransactionAdapter, + * managing phase transitions, preSteps, lifecycle hooks, and error state. + */ +export function useTransaction(options: UseTransactionOptions = {}): UseTransactionReturn { + const { + transactionAdapters, + walletAdapters, + registry, + lifecycle: globalLifecycle, + } = useProviderContext() + + const [phase, setPhase] = useState('idle') + const [prepareResult, setPrepareResult] = useState(null) + const [ref, setRef] = useState(null) + const [result, setResult] = useState(null) + const [preStepResults, setPreStepResults] = useState([]) + const [error, setError] = useState(null) + + const explorerUrl = useMemo( + () => (ref ? getExplorerUrl(registry, { chainId: ref.chainId, tx: ref.id }) : null), + [ref, registry], + ) + + const reset = useCallback(() => { + setPhase('idle') + setPrepareResult(null) + setRef(null) + setResult(null) + setPreStepResults([]) + setError(null) + }, []) + + const { lifecycle: localLifecycle, autoPreSteps = true, confirmOptions } = options + + const execute = useCallback( + async (params: TransactionParams): Promise => { + const chainIdStr = String(params.chainId) + let currentPhase: TransactionPhase = 'prepare' + + try { + const transactionAdapter = Object.values(transactionAdapters).find((adapter) => + adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr), + ) + const walletAdapter = Object.values(walletAdapters).find((adapter) => + adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr), + ) + + if (!transactionAdapter) { + throw new AdapterNotFoundError(params.chainId, 'transaction') + } + if (!walletAdapter) { + throw new AdapterNotFoundError(params.chainId, 'wallet') + } + + const signer = await walletAdapter.getSigner() + if (signer === null) { + throw new WalletNotConnectedError() + } + + setPhase('prepare') + const prepared = await transactionAdapter.prepare(params) + setPrepareResult(prepared) + fireLifecycle('onPrepare', globalLifecycle, localLifecycle, prepared) + + if (!prepared.ready) { + throw new TransactionNotReadyError(prepared.reason ?? 'Transaction preparation failed.') + } + + if (params.preSteps && params.preSteps.length > 0) { + currentPhase = 'preStep' + if (!autoPreSteps) { + throw new PreStepsNotExecutedError(params.preSteps.length) + } + setPhase('preStep') + for (const [index, preStep] of params.preSteps.entries()) { + fireLifecycle('onPreStep', globalLifecycle, localLifecycle, preStep, index) + const preStepRef = await transactionAdapter.execute(preStep.params, signer) + const preStepResult = await transactionAdapter.confirm(preStepRef, confirmOptions) + fireLifecycle( + 'onPreStepComplete', + globalLifecycle, + localLifecycle, + preStep, + index, + preStepResult, + ) + setPreStepResults((previous) => [...previous, preStepResult]) + } + } + + currentPhase = 'submit' + setPhase('submit') + const txRef = await transactionAdapter.execute(params, signer) + setRef(txRef) + fireLifecycle('onSubmit', globalLifecycle, localLifecycle, txRef) + + currentPhase = 'confirm' + setPhase('confirm') + const txResult = await transactionAdapter.confirm(txRef, confirmOptions) + setResult(txResult) + + if (txResult.ref.id !== txRef.id) { + fireLifecycle( + 'onReplace', + globalLifecycle, + localLifecycle, + txRef, + txResult.ref, + 'replaced', + ) + } + + fireLifecycle('onConfirm', globalLifecycle, localLifecycle, txResult) + + setPhase('idle') + return txResult + } catch (err) { + const errorObj = err instanceof Error ? err : new Error(String(err)) + setError(errorObj) + setPhase('idle') + fireLifecycle('onError', globalLifecycle, localLifecycle, currentPhase, errorObj) + throw errorObj + } + }, + [ + transactionAdapters, + walletAdapters, + localLifecycle, + autoPreSteps, + confirmOptions, + globalLifecycle, + ], + ) + + return { + phase, + prepareResult, + ref, + result, + preStepResults, + explorerUrl, + error, + execute, + reset, + } +} diff --git a/src/sdk/react/hooks/useWallet.test.tsx b/src/sdk/react/hooks/useWallet.test.tsx new file mode 100644 index 00000000..f4dee778 --- /dev/null +++ b/src/sdk/react/hooks/useWallet.test.tsx @@ -0,0 +1,212 @@ +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { WalletAdapter, WalletStatus } from '../../core/adapters/wallet' +import { AdapterNotFoundError, AmbiguousAdapterError } from '../../core/errors' +import { DAppBoosterProvider } from '../provider/DAppBoosterProvider' +import { useWallet } from './useWallet' + +vi.mock('@/src/wallet/providers', () => ({ + Web3Provider: ({ children }: { children: ReactNode }) => children, +})) + +const mockChain = { + caip2Id: 'eip155:1', + chainId: 1, + name: 'Ethereum', + chainType: 'evm', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + addressConfig: { format: 'hex', patterns: [], example: '0x...' }, +} + +const mockStatus: WalletStatus = { + connected: false, + activeAccount: null, + connectedChainIds: [], + connecting: false, +} + +const makeMockAdapter = (overrides?: Partial): WalletAdapter => { + const unsubscribe = vi.fn() + return { + chainType: 'evm', + supportedChains: [mockChain], + metadata: { + chainType: 'evm', + capabilities: { signTypedData: false, switchChain: false }, + formatAddress: (addr: string) => addr, + availableWallets: () => [], + }, + connect: vi.fn(), + reconnect: vi.fn(), + disconnect: vi.fn(), + getStatus: vi.fn(() => mockStatus), + onStatusChange: vi.fn(() => unsubscribe), + signMessage: vi.fn(), + getSigner: vi.fn(), + switchChain: vi.fn(), + ...overrides, + } as unknown as WalletAdapter +} + +const makeWrapper = + ( + wallets: Record< + string, + { adapter: WalletAdapter; useConnectModal?: () => { open: () => void } } + >, + ) => + ({ children }: { children: ReactNode }) => + createElement(DAppBoosterProvider, { config: { wallets } }, children) + +describe('useWallet', () => { + it('resolves the single adapter when no options given', () => { + const adapter = makeMockAdapter() + const wrapper = makeWrapper({ evm: { adapter } }) + const { result } = renderHook(() => useWallet(), { wrapper }) + expect(result.current.adapter).toBe(adapter) + }) + + it('throws AmbiguousAdapterError when multiple adapters exist and no options given', () => { + const evmAdapter = makeMockAdapter({ chainType: 'evm' }) + const solAdapter = makeMockAdapter({ + chainType: 'solana', + supportedChains: [ + { + caip2Id: 'solana:mainnet', + chainId: 'solana:mainnet', + name: 'Solana', + chainType: 'solana', + nativeCurrency: { symbol: 'SOL', decimals: 9 }, + addressConfig: { format: 'base58', patterns: [], example: '...' }, + }, + ], + }) + const wrapper = makeWrapper({ evm: { adapter: evmAdapter }, solana: { adapter: solAdapter } }) + expect(() => renderHook(() => useWallet(), { wrapper })).toThrow(AmbiguousAdapterError) + }) + + it('resolves by chainType', () => { + const evmAdapter = makeMockAdapter({ chainType: 'evm' }) + const wrapper = makeWrapper({ evm: { adapter: evmAdapter } }) + const { result } = renderHook(() => useWallet({ chainType: 'evm' }), { wrapper }) + expect(result.current.adapter).toBe(evmAdapter) + }) + + it('resolves by chainId', () => { + const adapter = makeMockAdapter() + const wrapper = makeWrapper({ evm: { adapter } }) + const { result } = renderHook(() => useWallet({ chainId: 1 }), { wrapper }) + expect(result.current.adapter).toBe(adapter) + }) + + it('throws AdapterNotFoundError for unknown chainType', () => { + const adapter = makeMockAdapter() + const wrapper = makeWrapper({ evm: { adapter } }) + expect(() => renderHook(() => useWallet({ chainType: 'solana' }), { wrapper })).toThrow( + AdapterNotFoundError, + ) + }) + + it('uses the explicit adapter option directly without resolution', () => { + const evmAdapter = makeMockAdapter({ chainType: 'evm' }) + const explicitAdapter = makeMockAdapter({ chainType: 'solana' }) + const wrapper = makeWrapper({ evm: { adapter: evmAdapter } }) + const { result } = renderHook(() => useWallet({ adapter: explicitAdapter }), { wrapper }) + expect(result.current.adapter).toBe(explicitAdapter) + }) + + it('subscribes to status changes and updates status in hook result', () => { + let capturedListener: ((status: WalletStatus) => void) | null = null + const adapter = makeMockAdapter({ + onStatusChange: vi.fn((listener) => { + capturedListener = listener + return vi.fn() + }), + }) + const wrapper = makeWrapper({ evm: { adapter } }) + const { result } = renderHook(() => useWallet(), { wrapper }) + + const updatedStatus: WalletStatus = { + connected: true, + activeAccount: '0xabc', + connectedChainIds: [1], + connecting: false, + } + + act(() => { + capturedListener?.(updatedStatus) + }) + + expect(result.current.status).toEqual(updatedStatus) + }) + + it('isReady is true when connected with no chainId constraint', () => { + const connectedStatus: WalletStatus = { + connected: true, + activeAccount: '0xabc', + connectedChainIds: [1], + connecting: false, + } + const adapter = makeMockAdapter({ getStatus: vi.fn(() => connectedStatus) }) + const wrapper = makeWrapper({ evm: { adapter } }) + const { result } = renderHook(() => useWallet(), { wrapper }) + expect(result.current.isReady).toBe(true) + }) + + it('needsConnect is true when not connected', () => { + const adapter = makeMockAdapter() + const wrapper = makeWrapper({ evm: { adapter } }) + const { result } = renderHook(() => useWallet(), { wrapper }) + expect(result.current.needsConnect).toBe(true) + }) + + it('needsChainSwitch is true when connected but requested chainId not in connectedChainIds', () => { + const connectedStatus: WalletStatus = { + connected: true, + activeAccount: '0xabc', + connectedChainIds: [137], + connecting: false, + } + const adapter = makeMockAdapter({ getStatus: vi.fn(() => connectedStatus) }) + const wrapper = makeWrapper({ evm: { adapter } }) + const { result } = renderHook(() => useWallet({ chainId: 1 }), { wrapper }) + expect(result.current.needsChainSwitch).toBe(true) + }) + + it('calls unsubscribe returned by onStatusChange on unmount', () => { + const unsubscribe = vi.fn() + const adapter = makeMockAdapter({ + onStatusChange: vi.fn(() => unsubscribe), + }) + const wrapper = makeWrapper({ evm: { adapter } }) + const { unmount } = renderHook(() => useWallet(), { wrapper }) + unmount() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('openConnectModal calls the registered modal opener for the resolved adapter', () => { + const openSpy = vi.fn() + const adapter = makeMockAdapter() + const wrapper = makeWrapper({ + evm: { adapter, useConnectModal: () => ({ open: openSpy }) }, + }) + const { result } = renderHook(() => useWallet(), { wrapper }) + act(() => { + result.current.openConnectModal() + }) + expect(openSpy).toHaveBeenCalledOnce() + }) + + it('openConnectModal is a no-op when no modal is registered for the adapter', () => { + const adapter = makeMockAdapter() + const wrapper = makeWrapper({ evm: { adapter } }) + const { result } = renderHook(() => useWallet(), { wrapper }) + expect(() => { + act(() => { + result.current.openConnectModal() + }) + }).not.toThrow() + }) +}) diff --git a/src/sdk/react/hooks/useWallet.ts b/src/sdk/react/hooks/useWallet.ts new file mode 100644 index 00000000..a12e3f64 --- /dev/null +++ b/src/sdk/react/hooks/useWallet.ts @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useState } from 'react' +import type { WalletLifecycle } from '../../core/adapters/lifecycle' +import type { + ChainSigner, + ConnectOptions, + SignatureResult, + SignMessageInput, + SignTypedDataInput, + WalletAdapter, + WalletConnection, + WalletStatus, +} from '../../core/adapters/wallet' +import { + AdapterNotFoundError, + AmbiguousAdapterError, + CapabilityNotSupportedError, +} from '../../core/errors' +import { useProviderContext } from '../provider/context' + +function fireWalletLifecycle( + key: K, + lifecycle: WalletLifecycle | undefined, + ...args: Parameters> +): void { + const fn = lifecycle?.[key] as ((...a: unknown[]) => void) | undefined + if (!fn) { + return + } + try { + fn(...(args as unknown[])) + } catch (err) { + console.error(`useWallet lifecycle hook "${key}" threw:`, err) + } +} + +export interface UseWalletOptions { + /** Resolve by chainId — finds the adapter whose supportedChains includes this chainId. */ + chainId?: string | number + /** Resolve by chainType — finds the adapter with matching chainType. */ + chainType?: string + /** Explicit adapter — bypasses resolution entirely. */ + adapter?: WalletAdapter +} + +export interface UseWalletReturn { + adapter: WalletAdapter + /** The key under which this adapter was registered in DAppBoosterConfig.wallets. */ + adapterKey: string | null + status: WalletStatus + /** true when connected AND (if chainId option given) connectedChainIds includes that chainId. */ + isReady: boolean + /** true when not connected and not connecting. */ + needsConnect: boolean + /** true when connected but the requested chainId is not in connectedChainIds. */ + needsChainSwitch: boolean + connect: (options?: ConnectOptions) => Promise + disconnect: () => Promise + signMessage: (input: SignMessageInput) => Promise + signTypedData?: (input: SignTypedDataInput) => Promise + getSigner: () => Promise + switchChain: (chainId: string | number) => Promise + /** Opens the connect modal for this adapter's connector. No-op if no modal is registered. */ + openConnectModal: () => void + /** Opens the account/disconnect modal. Falls back to openConnectModal if not available. */ + openAccountModal: () => void +} + +function chainIdMatch(a: string | number, b: string | number): boolean { + return String(a) === String(b) +} + +interface ResolvedAdapter { + adapter: WalletAdapter + /** The key from DAppBoosterConfig.wallets, or null when an explicit adapter was passed. */ + key: string | null +} + +function resolveAdapter( + walletAdapters: Record, + options: UseWalletOptions, +): ResolvedAdapter { + if (options.adapter) { + return { adapter: options.adapter, key: null } + } + + const entries = Object.entries(walletAdapters) + + if (options.chainType !== undefined) { + const found = entries.find(([, adapter]) => adapter.chainType === options.chainType) + if (!found) { + throw new AdapterNotFoundError(options.chainType, 'wallet') + } + return { adapter: found[1], key: found[0] } + } + + if (options.chainId !== undefined) { + const chainId = options.chainId + const found = entries.find(([, adapter]) => + adapter.supportedChains.some((chain) => chainIdMatch(chain.chainId, chainId)), + ) + if (!found) { + throw new AdapterNotFoundError(chainId, 'wallet') + } + return { adapter: found[1], key: found[0] } + } + + if (entries.length === 1) { + return { adapter: entries[0][1], key: entries[0][0] } + } + + throw new AmbiguousAdapterError(entries.map(([, adapter]) => adapter.chainType)) +} + +/** + * Resolves a WalletAdapter from the DAppBoosterProvider and subscribes to its status changes. + * + * Pass `chainType`, `chainId`, or `adapter` in options to disambiguate when multiple adapters + * are registered. With a single adapter and no options, it resolves automatically. + */ +export function useWallet(options: UseWalletOptions = {}): UseWalletReturn { + const { walletAdapters, walletLifecycle, connectModalsRef } = useProviderContext() + const { adapter, key: adapterKey } = resolveAdapter(walletAdapters, options) + + const [status, setStatus] = useState(() => adapter.getStatus()) + + useEffect(() => { + setStatus(adapter.getStatus()) + const unsubscribe = adapter.onStatusChange(setStatus) + return unsubscribe + }, [adapter]) + + const chainId = options.chainId + + const isReady = + status.connected && + (chainId === undefined || status.connectedChainIds.some((id) => chainIdMatch(id, chainId))) + + const needsConnect = !status.connected && !status.connecting + + const needsChainSwitch = + status.connected && + chainId !== undefined && + !status.connectedChainIds.some((id) => chainIdMatch(id, chainId)) + + const signMessage = useCallback( + async (input: SignMessageInput): Promise => { + fireWalletLifecycle('onSign', walletLifecycle, 'message', input) + try { + const result = await adapter.signMessage(input) + fireWalletLifecycle('onSignComplete', walletLifecycle, result) + return result + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + fireWalletLifecycle('onSignError', walletLifecycle, error) + throw err + } + }, + [adapter, walletLifecycle], + ) + + const signTypedDataImpl = useCallback( + async (input: SignTypedDataInput): Promise => { + if (!adapter.signTypedData) { + throw new CapabilityNotSupportedError('signTypedData') + } + fireWalletLifecycle('onSign', walletLifecycle, 'typedData', input) + try { + const result = await adapter.signTypedData(input) + fireWalletLifecycle('onSignComplete', walletLifecycle, result) + return result + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + fireWalletLifecycle('onSignError', walletLifecycle, error) + throw err + } + }, + [adapter, walletLifecycle], + ) + + const signTypedData = adapter.signTypedData ? signTypedDataImpl : undefined + + const openConnectModal = useCallback(() => { + if (adapterKey) { + connectModalsRef.current[adapterKey]?.open() + } + }, [adapterKey, connectModalsRef]) + + const openAccountModal = useCallback(() => { + if (adapterKey) { + const modals = connectModalsRef.current[adapterKey] + if (modals?.openAccount) { + modals.openAccount() + } else { + modals?.open() + } + } + }, [adapterKey, connectModalsRef]) + + return { + adapter, + adapterKey, + status, + isReady, + needsConnect, + needsChainSwitch, + connect: adapter.connect, + disconnect: adapter.disconnect, + signMessage, + signTypedData, + getSigner: adapter.getSigner, + switchChain: adapter.switchChain, + openConnectModal, + openAccountModal, + } +} diff --git a/src/sdk/react/index.ts b/src/sdk/react/index.ts new file mode 100644 index 00000000..075b4a08 --- /dev/null +++ b/src/sdk/react/index.ts @@ -0,0 +1,4 @@ +export * from './components' +export * from './hooks' +export * from './lifecycle' +export * from './provider' From 7d3e19aaad4eef22fe2b28e17763e3300940affd Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:10:32 +0200 Subject: [PATCH 10/16] feat: add WalletGuard, ConnectWalletButton, and createNotificationLifecycle --- .../react/components/ConnectWalletButton.tsx | 30 +++ src/sdk/react/components/WalletGuard.test.tsx | 180 ++++++++++++++++++ src/sdk/react/components/WalletGuard.tsx | 48 +++++ src/sdk/react/components/index.ts | 3 + .../createNotificationLifecycle.test.ts | 153 +++++++++++++++ .../lifecycle/createNotificationLifecycle.ts | 67 +++++++ src/sdk/react/lifecycle/index.ts | 6 + 7 files changed, 487 insertions(+) create mode 100644 src/sdk/react/components/ConnectWalletButton.tsx create mode 100644 src/sdk/react/components/WalletGuard.test.tsx create mode 100644 src/sdk/react/components/WalletGuard.tsx create mode 100644 src/sdk/react/components/index.ts create mode 100644 src/sdk/react/lifecycle/createNotificationLifecycle.test.ts create mode 100644 src/sdk/react/lifecycle/createNotificationLifecycle.ts create mode 100644 src/sdk/react/lifecycle/index.ts diff --git a/src/sdk/react/components/ConnectWalletButton.tsx b/src/sdk/react/components/ConnectWalletButton.tsx new file mode 100644 index 00000000..b14444b6 --- /dev/null +++ b/src/sdk/react/components/ConnectWalletButton.tsx @@ -0,0 +1,30 @@ +import type { FC } from 'react' +import ConnectButton from '@/src/wallet/components/ConnectButton' +import type { UseWalletOptions } from '../hooks/useWallet' +import { useWallet } from '../hooks/useWallet' + +/** + * Styled connect/account button that works with any connector. + * + * Resolves the wallet adapter via `useWallet(options)` and opens the + * adapter-specific connect modal. In a multi-wallet setup, pass `chainType` + * or `chainId` to target a specific adapter's modal. + */ +export const ConnectWalletButton: FC = ({ + label = 'Connect', + ...walletOptions +}) => { + const { status, openConnectModal, openAccountModal } = useWallet(walletOptions) + + const address = status.activeAccount + const truncatedAddress = address ? `${address.slice(0, 6)}\u2026${address.slice(-4)}` : undefined + + return ( + + {status.connected && truncatedAddress ? truncatedAddress : label} + + ) +} diff --git a/src/sdk/react/components/WalletGuard.test.tsx b/src/sdk/react/components/WalletGuard.test.tsx new file mode 100644 index 00000000..5755efc6 --- /dev/null +++ b/src/sdk/react/components/WalletGuard.test.tsx @@ -0,0 +1,180 @@ +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 { WalletGuard } from './WalletGuard' + +const mockSwitchChain = vi.fn() + +vi.mock('../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: vi.fn(), + 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/wallet/components/SwitchChainButton', () => ({ + default: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => + createElement( + 'button', + { type: 'button', 'data-testid': 'switch-chain-button', onClick }, + children, + ), +})) + +const { useWallet, useChainRegistry } = await import('../hooks') +const mockedUseWallet = vi.mocked(useWallet) +const mockedUseChainRegistry = vi.mocked(useChainRegistry) + +const system = createSystem(defaultConfig) + +const renderWithChakra = (ui: ReactNode) => + render(createElement(ChakraProvider, { value: system } as never, ui)) + +const makeWalletReady = () => ({ + 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: vi.fn(), + getSigner: vi.fn(), + adapterKey: 'evm', + openConnectModal: vi.fn(), + openAccountModal: vi.fn(), +}) + +describe('WalletGuard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders fallback when wallet needsConnect', () => { + renderWithChakra( + createElement( + WalletGuard, + 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 needsConnect', () => { + renderWithChakra( + createElement( + WalletGuard, + { 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 needsChainSwitch', () => { + mockedUseWallet.mockReturnValue({ + ...makeWalletReady(), + needsConnect: false, + needsChainSwitch: true, + isReady: false, + }) + + mockedUseChainRegistry.mockReturnValue({ + getChain: vi.fn(() => ({ + name: 'OP Mainnet', + chainId: 10, + caip2Id: 'eip155:10', + chainType: 'evm', + nativeCurrency: { 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( + createElement( + WalletGuard, + { chainId: 10 }, + createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), + ), + ) + + expect(screen.getByTestId('switch-chain-button')).toBeInTheDocument() + 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', () => { + mockedUseWallet.mockReturnValue(makeWalletReady()) + + renderWithChakra( + createElement( + WalletGuard, + null, + createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), + ), + ) + + expect(screen.getByTestId('protected-content')).toBeInTheDocument() + }) + + it('renders children with custom chainId when wallet is ready', () => { + mockedUseWallet.mockReturnValue({ + ...makeWalletReady(), + status: { + connected: true, + connecting: false, + activeAccount: '0xabc', + connectedChainIds: [10], + }, + }) + + renderWithChakra( + createElement( + WalletGuard, + { chainId: 10 }, + createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), + ), + ) + + expect(screen.getByTestId('protected-content')).toBeInTheDocument() + }) +}) diff --git a/src/sdk/react/components/WalletGuard.tsx b/src/sdk/react/components/WalletGuard.tsx new file mode 100644 index 00000000..58e7a7e2 --- /dev/null +++ b/src/sdk/react/components/WalletGuard.tsx @@ -0,0 +1,48 @@ +import type { FC, ReactElement, ReactNode } from 'react' +import SwitchChainButton from '@/src/wallet/components/SwitchChainButton' +import { ConnectWalletButton } from '@/src/wallet/providers' +import { useChainRegistry, useWallet } from '../hooks' + +export interface WalletGuardProps { + chainId?: string | number + chainType?: string + children?: ReactNode + fallback?: ReactElement + switchChainLabel?: string +} + +/** + * Gates content on wallet connection and correct chain. + * Shows ConnectWalletButton when disconnected, SwitchChainButton when on wrong chain, + * or renders children when ready. + */ +export const WalletGuard: FC = ({ + chainId, + chainType, + children, + fallback = ( + + ), + switchChainLabel = 'Switch to', +}) => { + const wallet = useWallet({ chainId, chainType }) + const registry = useChainRegistry() + + if (wallet.needsConnect) { + return fallback + } + + if (wallet.needsChainSwitch && chainId !== undefined) { + const targetChain = registry.getChain(chainId) + return ( + wallet.switchChain(chainId)}> + {switchChainLabel} {targetChain?.name ?? String(chainId)} + + ) + } + + return children +} diff --git a/src/sdk/react/components/index.ts b/src/sdk/react/components/index.ts new file mode 100644 index 00000000..058d8c98 --- /dev/null +++ b/src/sdk/react/components/index.ts @@ -0,0 +1,3 @@ +export { ConnectWalletButton } from './ConnectWalletButton' +export type { WalletGuardProps } from './WalletGuard' +export { WalletGuard } from './WalletGuard' diff --git a/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts b/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts new file mode 100644 index 00000000..51ec5c89 --- /dev/null +++ b/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createNotificationLifecycle } from './createNotificationLifecycle' + +const mockCreate = vi.fn() +const mockToaster = { create: mockCreate } + +const makeResult = (status: 'success' | 'reverted' | 'timeout') => ({ + status, + ref: { chainType: 'evm', id: '0xhash', chainId: 1 }, + receipt: {}, +}) + +describe('createNotificationLifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockReturnValue('toast-1') + }) + + it('onSubmit creates a loading toast', () => { + const lifecycle = createNotificationLifecycle({ toaster: mockToaster }) + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 }) + + expect(mockCreate).toHaveBeenCalledOnce() + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ type: 'loading', description: 'Transaction submitted' }), + ) + }) + + it('onConfirm creates a success toast when status is success, passing the toastId as id', () => { + const lifecycle = createNotificationLifecycle({ toaster: mockToaster }) + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 }) + lifecycle.onConfirm?.(makeResult('success')) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockCreate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: 'success', + description: 'Transaction confirmed!', + id: 'toast-1', + }), + ) + }) + + it('onConfirm creates an error toast when status is reverted, passing the toastId as id', () => { + const lifecycle = createNotificationLifecycle({ toaster: mockToaster }) + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 }) + lifecycle.onConfirm?.(makeResult('reverted')) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockCreate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: 'error', + description: 'Transaction was reverted', + id: 'toast-1', + }), + ) + }) + + it('onError creates an error toast, passing the toastId as id', () => { + const lifecycle = createNotificationLifecycle({ toaster: mockToaster }) + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 }) + lifecycle.onError?.('submit', new Error('Something went wrong')) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockCreate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: 'error', + description: 'Something went wrong', + id: 'toast-1', + }), + ) + }) + + it('uses custom messages.submitted when provided', () => { + const lifecycle = createNotificationLifecycle({ + toaster: mockToaster, + messages: { submitted: 'Sending tx...' }, + }) + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 }) + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ description: 'Sending tx...' }), + ) + }) + + it('uses custom messages.confirmed when provided', () => { + const lifecycle = createNotificationLifecycle({ + toaster: mockToaster, + messages: { confirmed: 'Done!' }, + }) + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 }) + lifecycle.onConfirm?.(makeResult('success')) + + expect(mockCreate).toHaveBeenNthCalledWith(2, expect.objectContaining({ description: 'Done!' })) + }) + + it('uses custom messages.reverted when provided', () => { + const lifecycle = createNotificationLifecycle({ + toaster: mockToaster, + messages: { reverted: 'Tx failed!' }, + }) + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 }) + lifecycle.onConfirm?.(makeResult('reverted')) + + expect(mockCreate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ description: 'Tx failed!' }), + ) + }) + + it('uses custom messages.error when provided', () => { + const lifecycle = createNotificationLifecycle({ + toaster: mockToaster, + messages: { error: 'Custom error message' }, + }) + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 }) + lifecycle.onError?.('submit', new Error('raw error')) + + expect(mockCreate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ description: 'Custom error message' }), + ) + }) + + it('clears toastId after onConfirm so subsequent onSubmit creates a fresh toast', () => { + const lifecycle = createNotificationLifecycle({ toaster: mockToaster }) + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 }) + lifecycle.onConfirm?.(makeResult('success')) + + // Second submit — toastId should be undefined, so no id field in the new loading toast + mockCreate.mockReturnValue('toast-2') + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x2', chainId: 1 }) + + expect(mockCreate).toHaveBeenNthCalledWith(3, expect.objectContaining({ type: 'loading' })) + // The third call should NOT have id set (toastId was cleared) + const thirdCall = mockCreate.mock.calls[2][0] + expect(thirdCall.id).toBeUndefined() + }) + + it('threads toastId from onSubmit to onConfirm', () => { + const lifecycle = createNotificationLifecycle({ toaster: mockToaster }) + lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 }) + expect(mockCreate).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: 'loading' })) + lifecycle.onConfirm?.(makeResult('success')) + expect(mockCreate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: 'toast-1', type: 'success' }), + ) + }) +}) diff --git a/src/sdk/react/lifecycle/createNotificationLifecycle.ts b/src/sdk/react/lifecycle/createNotificationLifecycle.ts new file mode 100644 index 00000000..ef9e57ca --- /dev/null +++ b/src/sdk/react/lifecycle/createNotificationLifecycle.ts @@ -0,0 +1,67 @@ +import type { TransactionLifecycle } from '../../core/adapters/lifecycle' +import type { TransactionResult } from '../../core/adapters/transaction' + +/** Minimal interface for the toast notification API. */ +export interface ToasterAPI { + create(options: { + description: string + type: 'loading' | 'success' | 'error' + id?: string + }): string +} + +export interface NotificationLifecycleMessages { + /** Shown when transaction is submitted. Defaults to 'Transaction submitted'. */ + submitted?: string + /** Shown when transaction is confirmed with success status. Defaults to 'Transaction confirmed!'. */ + confirmed?: string + /** Shown when transaction is reverted. Defaults to 'Transaction was reverted'. */ + reverted?: string + /** Shown when an error occurs. Defaults to the error message. */ + error?: string +} + +export interface NotificationLifecycleOptions { + toaster: ToasterAPI + messages?: NotificationLifecycleMessages +} + +/** + * Creates a TransactionLifecycle that fires toast notifications for submit, confirm, and error events. + * + * Pass the result to useTransaction({ lifecycle }) or TransactionButton lifecycle prop. + */ +export function createNotificationLifecycle({ + toaster, + messages = {}, +}: NotificationLifecycleOptions): TransactionLifecycle { + let toastId: string | undefined + + return { + onSubmit() { + toastId = toaster.create({ + description: messages.submitted ?? 'Transaction submitted', + type: 'loading', + }) + }, + onConfirm(result: TransactionResult) { + const isSuccess = result.status === 'success' + toaster.create({ + description: isSuccess + ? (messages.confirmed ?? 'Transaction confirmed!') + : (messages.reverted ?? 'Transaction was reverted'), + type: isSuccess ? 'success' : 'error', + id: toastId, + }) + toastId = undefined + }, + onError(_phase, error) { + toaster.create({ + description: messages.error ?? error.message, + type: 'error', + id: toastId, + }) + toastId = undefined + }, + } +} diff --git a/src/sdk/react/lifecycle/index.ts b/src/sdk/react/lifecycle/index.ts new file mode 100644 index 00000000..ed1120a3 --- /dev/null +++ b/src/sdk/react/lifecycle/index.ts @@ -0,0 +1,6 @@ +export type { + NotificationLifecycleMessages, + NotificationLifecycleOptions, + ToasterAPI, +} from './createNotificationLifecycle' +export { createNotificationLifecycle } from './createNotificationLifecycle' From 04c450f141e526a0e48b919d03187a56e4ab1ce5 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:10:52 +0200 Subject: [PATCH 11/16] refactor: migrate TransactionButton and SignButton to adapter-backed hooks --- .../sharedComponents/SignButton.test.tsx | 223 ++++++++++----- .../sharedComponents/SignButton.tsx | 56 ++-- .../TransactionButton.test.tsx | 270 ++++++++++++++---- .../sharedComponents/TransactionButton.tsx | 94 ++---- 4 files changed, 418 insertions(+), 225 deletions(-) diff --git a/src/components/sharedComponents/SignButton.test.tsx b/src/components/sharedComponents/SignButton.test.tsx index f4e07cae..30a8f99b 100644 --- a/src/components/sharedComponents/SignButton.test.tsx +++ b/src/components/sharedComponents/SignButton.test.tsx @@ -1,25 +1,39 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' import { render, screen } from '@testing-library/react' -import type { ReactNode } from 'react' -import { createElement } from '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 mockSignMessageAsync = vi.fn() -const mockWatchSignature = vi.fn() +const mockSignMessage = vi.fn() -vi.mock('@/src/hooks/useWalletStatus', () => ({ - useWalletStatus: vi.fn(() => ({ - isReady: false, +vi.mock('@/src/sdk/react/hooks', () => ({ + useWallet: vi.fn(() => ({ + adapter: {} as never, needsConnect: true, needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' }, - targetChainId: 1, + 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/providers/Web3Provider', () => ({ +vi.mock('@/src/wallet/providers', () => ({ ConnectWalletButton: () => createElement( 'button', @@ -28,108 +42,175 @@ vi.mock('@/src/providers/Web3Provider', () => ({ ), })) -vi.mock('@/src/providers/TransactionNotificationProvider', () => ({ - useTransactionNotification: vi.fn(() => ({ - watchSignature: mockWatchSignature, - })), -})) - -vi.mock('wagmi', () => ({ - useSignMessage: vi.fn(() => ({ - isPending: false, - signMessageAsync: mockSignMessageAsync, - })), +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 { useWalletStatus } = await import('@/src/hooks/useWalletStatus') -const mockedUseWalletStatus = vi.mocked(useWalletStatus) +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 needs connect', async () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: false, - needsConnect: true, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, - switchChain: mockSwitchChain, - }) - - const { default: SignButton } = await import('./SignButton') - + 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 needs connect', async () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: false, - needsConnect: true, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, - switchChain: mockSwitchChain, - }) - - const { default: SignButton } = await import('./SignButton') - + 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 needs chain switch', async () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: false, + it('renders switch chain button when wallet needsChainSwitch', () => { + mockedUseWallet.mockReturnValue({ + adapter: {} as never, needsConnect: false, needsChainSwitch: true, - targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType< - typeof useWalletStatus - >['targetChain'], - targetChainId: 10, + 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(), }) - - const { default: SignButton } = await import('./SignButton') - - renderWithChakra() - + 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 sign button when wallet is ready', async () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: true, + it('renders custom switchChainLabel when wallet needsChainSwitch', () => { + mockedUseWallet.mockReturnValue({ + adapter: {} as never, needsConnect: false, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, + 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() + }) - const { default: SignButton } = await import('./SignButton') - + 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 index da5d6f2c..e209d8d9 100644 --- a/src/components/sharedComponents/SignButton.tsx +++ b/src/components/sharedComponents/SignButton.tsx @@ -1,15 +1,13 @@ import { type ButtonProps, chakra } from '@chakra-ui/react' import type { FC, ReactElement } from 'react' -import { useSignMessage } from 'wagmi' +import { useState } from 'react' import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton' -import { useWalletStatus } from '@/src/hooks/useWalletStatus' -import type { ChainsIds } from '@/src/lib/networks.config' -import { useTransactionNotification } from '@/src/providers/TransactionNotificationProvider' -import { ConnectWalletButton } from '@/src/providers/Web3Provider' +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?: ChainsIds + chainId?: string | number /** Custom fallback when wallet needs connection. Defaults to ConnectWalletButton. */ fallback?: ReactElement /** Button label while signing. Defaults to 'Signing...'. */ @@ -51,42 +49,40 @@ const SignButton: FC = ({ switchChainLabel = 'Switch to', ...restProps }) => { - const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } = - useWalletStatus({ chainId }) - const { watchSignature } = useTransactionNotification() + const wallet = useWallet({ chainId }) + const registry = useChainRegistry() + const [isPending, setIsPending] = useState(false) - const { isPending, signMessageAsync } = useSignMessage({ - mutation: { - onSuccess(data) { - onSign?.(data) - }, - onError(error) { - onError?.(error) - }, - }, - }) - - if (needsConnect) { + if (wallet.needsConnect) { return fallback } - if (needsChainSwitch) { + if (wallet.needsChainSwitch && chainId !== undefined) { + const targetChain = registry.getChain(chainId) return ( - switchChain(targetChainId)}> - {switchChainLabel} {targetChain.name} + 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 ( { - watchSignature({ - message: 'Signing message...', - signaturePromise: signMessageAsync({ message }), - }) - }} + onClick={handleSign} {...restProps} > {isPending ? labelSigning : children} diff --git a/src/components/sharedComponents/TransactionButton.test.tsx b/src/components/sharedComponents/TransactionButton.test.tsx index 032dc575..bebe37f3 100644 --- a/src/components/sharedComponents/TransactionButton.test.tsx +++ b/src/components/sharedComponents/TransactionButton.test.tsx @@ -5,21 +5,49 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import TransactionButton from './TransactionButton' const mockSwitchChain = vi.fn() -const mockWatchTx = vi.fn() -const mockTransaction = vi.fn(() => Promise.resolve('0xabc' as `0x${string}`)) +const mockExecute = vi.fn(async () => ({ + status: 'success' as const, + ref: { chainType: 'evm', id: '0xabc', chainId: 1 }, + receipt: {}, +})) -vi.mock('@/src/hooks/useWalletStatus', () => ({ - useWalletStatus: vi.fn(() => ({ - isReady: false, +vi.mock('@/src/sdk/react/hooks', () => ({ + useWallet: vi.fn(() => ({ needsConnect: true, needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' }, - targetChainId: 1, + 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/providers/Web3Provider', () => ({ +vi.mock('@/src/wallet/providers', () => ({ ConnectWalletButton: () => createElement( 'button', @@ -28,60 +56,96 @@ vi.mock('@/src/providers/Web3Provider', () => ({ ), })) -vi.mock('@/src/providers/TransactionNotificationProvider', () => ({ - useTransactionNotification: vi.fn(() => ({ - watchTx: mockWatchTx, - })), -})) - -vi.mock('wagmi', () => ({ - useWaitForTransactionReceipt: vi.fn(() => ({ - data: undefined, - })), +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 { useWalletStatus } = await import('@/src/hooks/useWalletStatus') -const mockedUseWalletStatus = vi.mocked(useWalletStatus) +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 needs connect', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: false, + it('renders connect button when wallet needsConnect', () => { + mockedUseWallet.mockReturnValue({ needsConnect: true, needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, + 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) + renderWithChakra(Send) expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument() expect(screen.queryByText('Send')).toBeNull() }) - it('renders custom fallback when provided and wallet needs connect', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: false, + it('renders custom fallback when provided and wallet needsConnect', () => { + mockedUseWallet.mockReturnValue({ needsConnect: true, needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, + 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 @@ -92,40 +156,89 @@ describe('TransactionButton', () => { expect(screen.queryByText('Send')).toBeNull() }) - it('renders switch chain button when wallet needs chain switch', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: false, + it('renders switch chain button when wallet needsChainSwitch', () => { + mockedUseWallet.mockReturnValue({ needsConnect: false, needsChainSwitch: true, - targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType< - typeof useWalletStatus - >['targetChain'], - targetChainId: 10, + 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) + 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 custom switch chain label when provided', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: false, + it('renders switch chain label with chain name', () => { + mockedUseWallet.mockReturnValue({ needsConnect: false, needsChainSwitch: true, - targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType< - typeof useWalletStatus - >['targetChain'], - targetChainId: 10, + 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 @@ -137,18 +250,59 @@ describe('TransactionButton', () => { }) it('renders transaction button when wallet is ready', () => { - mockedUseWalletStatus.mockReturnValue({ - isReady: true, - needsConnect: false, - needsChainSwitch: false, - targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], - targetChainId: 1, - switchChain: mockSwitchChain, - }) + makeWalletReady() - renderWithChakra(Send ETH) + 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 index 620b0cbe..15d05361 100644 --- a/src/components/sharedComponents/TransactionButton.tsx +++ b/src/components/sharedComponents/TransactionButton.tsx @@ -1,118 +1,80 @@ import type { ButtonProps } from '@chakra-ui/react' import type { ReactElement } from 'react' -import { useEffect, useState } from 'react' -import type { Hash, TransactionReceipt } from 'viem' -import { useWaitForTransactionReceipt } from 'wagmi' import PrimaryButton from '@/src/components/sharedComponents/ui/PrimaryButton' import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton' -import { useWalletStatus } from '@/src/hooks/useWalletStatus' -import type { ChainsIds } from '@/src/lib/networks.config' -import { useTransactionNotification } from '@/src/providers/TransactionNotificationProvider' -import { ConnectWalletButton } from '@/src/providers/Web3Provider' +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 { - /** Target chain ID for wallet status verification. */ - chainId?: ChainsIds - /** Number of confirmations to wait for. Defaults to 1. */ - confirmations?: number + /** 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 - /** Callback function called when transaction is mined. */ - onMined?: (receipt: TransactionReceipt) => void /** Label for the switch chain button. Defaults to 'Switch to'. */ switchChainLabel?: string - /** Function that initiates the transaction and returns a hash. */ - transaction: { - (): Promise - methodId?: string - } } /** - * Self-contained transaction button with wallet verification, submission, and confirmation tracking. + * Self-contained transaction button with wallet verification and submission. * - * Handles wallet connection status internally — shows a connect button if not connected, - * a switch chain button if on the wrong chain, or the transaction button when ready. + * 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("Transaction confirmed:", receipt)} - * labelSending="Processing..." - * confirmations={3} + * params={{ chainId: 1, payload: { to: '0x...', value: '0' } }} + * lifecycle={{ onConfirm: (result) => console.log('confirmed', result) }} * > * Send ETH * * ``` */ function TransactionButton({ - chainId, + params, + lifecycle, children = 'Send Transaction', - confirmations = 1, disabled, fallback = , labelSending = 'Sending...', - onMined, switchChainLabel = 'Switch to', - transaction, ...restProps }: TransactionButtonProps) { - const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } = - useWalletStatus({ chainId }) - - const [hash, setHash] = useState() - const [isPending, setIsPending] = useState(false) - - const { watchTx } = useTransactionNotification() - const { data: receipt } = useWaitForTransactionReceipt({ - hash: hash, - confirmations, - }) - - useEffect(() => { - const handleMined = async () => { - if (receipt && isPending) { - await onMined?.(receipt) - setIsPending(false) - setHash(undefined) - } - } - - handleMined() - }, [isPending, onMined, receipt]) + const wallet = useWallet({ chainId: params.chainId }) + const { execute, phase } = useTransaction({ lifecycle }) + const registry = useChainRegistry() + const isPending = phase !== 'idle' - if (needsConnect) { + if (wallet.needsConnect) { return fallback } - if (needsChainSwitch) { + if (wallet.needsChainSwitch) { + const targetChain = registry.getChain(params.chainId) return ( - switchChain(targetChainId)}> - {switchChainLabel} {targetChain.name} + wallet.switchChain(params.chainId)}> + {switchChainLabel} {targetChain?.name ?? String(params.chainId)} ) } - const handleSendTransaction = async () => { - setIsPending(true) + const handleClick = async () => { try { - const txPromise = transaction() - watchTx({ txPromise, methodId: transaction.methodId }) - const hash = await txPromise - setHash(hash) - } catch (error: unknown) { - console.error('Error sending transaction', error instanceof Error ? error.message : error) - setIsPending(false) + await execute(params) + } catch { + // useTransaction sets error state internally } } return ( {isPending ? labelSending : children} From 5bc9926a1f80f7093311bb72e62cddd71d511f18 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:11:21 +0200 Subject: [PATCH 12/16] chore: mark legacy APIs as deprecated and fix notification bugs --- .../WalletStatusVerifier.test.tsx | 2 +- .../sharedComponents/WalletStatusVerifier.tsx | 6 +- .../components/LegacyTransactionButton.tsx | 113 +++++++ .../components/SignButton.test.tsx | 216 ++++++++++++ src/transactions/components/SignButton.tsx | 101 ++++++ .../components/TransactionButton.test.tsx | 308 ++++++++++++++++++ .../components/TransactionButton.tsx | 85 +++++ .../TransactionNotificationProvider.test.tsx | 82 +++++ .../TransactionNotificationProvider.tsx | 242 ++++++++++++++ 9 files changed, 1153 insertions(+), 2 deletions(-) create mode 100644 src/transactions/components/LegacyTransactionButton.tsx create mode 100644 src/transactions/components/SignButton.test.tsx create mode 100644 src/transactions/components/SignButton.tsx create mode 100644 src/transactions/components/TransactionButton.test.tsx create mode 100644 src/transactions/components/TransactionButton.tsx create mode 100644 src/transactions/providers/TransactionNotificationProvider.test.tsx create mode 100644 src/transactions/providers/TransactionNotificationProvider.tsx diff --git a/src/components/sharedComponents/WalletStatusVerifier.test.tsx b/src/components/sharedComponents/WalletStatusVerifier.test.tsx index 8487e9b3..389e2525 100644 --- a/src/components/sharedComponents/WalletStatusVerifier.test.tsx +++ b/src/components/sharedComponents/WalletStatusVerifier.test.tsx @@ -35,7 +35,7 @@ vi.mock('@/src/hooks/useWeb3Status', () => ({ })), })) -vi.mock('@/src/providers/Web3Provider', () => ({ +vi.mock('@/src/wallet/providers', () => ({ ConnectWalletButton: () => createElement( 'button', diff --git a/src/components/sharedComponents/WalletStatusVerifier.tsx b/src/components/sharedComponents/WalletStatusVerifier.tsx index 9d39d13b..ab88e5b2 100644 --- a/src/components/sharedComponents/WalletStatusVerifier.tsx +++ b/src/components/sharedComponents/WalletStatusVerifier.tsx @@ -3,9 +3,9 @@ import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainB import { useWalletStatus } from '@/src/hooks/useWalletStatus' import { useWeb3Status, type Web3Status } from '@/src/hooks/useWeb3Status' import type { ChainsIds } from '@/src/lib/networks.config' -import { ConnectWalletButton } from '@/src/providers/Web3Provider' import type { RequiredNonNull } from '@/src/types/utils' import { DeveloperError } from '@/src/utils/DeveloperError' +import { ConnectWalletButton } from '@/src/wallet/providers' const WalletStatusVerifierContext = createContext | null>(null) @@ -14,6 +14,8 @@ const WalletStatusVerifierContext = createContext | * * 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) @@ -38,6 +40,8 @@ interface WalletStatusVerifierProps { * 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 * diff --git a/src/transactions/components/LegacyTransactionButton.tsx b/src/transactions/components/LegacyTransactionButton.tsx new file mode 100644 index 00000000..7e44488c --- /dev/null +++ b/src/transactions/components/LegacyTransactionButton.tsx @@ -0,0 +1,113 @@ +/** + * @deprecated Legacy TransactionButton that uses wagmi hooks directly. + * Migrate to the new adapter-based TransactionButton with `params` + `lifecycle` props. + */ + +import type { ButtonProps } from '@chakra-ui/react' +import type { ReactElement } from 'react' +import { useEffect, useState } from 'react' +import type { Hash, TransactionReceipt } from 'viem' +import { useWaitForTransactionReceipt } from 'wagmi' +import { PrimaryButton } from '@/src/core/components' +import type { ChainsIds } from '@/src/core/types' +import { useTransactionNotification } from '@/src/transactions/providers' +import SwitchChainButton from '@/src/wallet/components/SwitchChainButton' +import { useWalletStatus } from '@/src/wallet/hooks' +import { ConnectWalletButton } from '@/src/wallet/providers' + +interface LegacyTransactionButtonProps extends ButtonProps { + /** Target chain ID for wallet status verification. */ + chainId?: ChainsIds + /** Number of confirmations to wait for. Defaults to 1. */ + confirmations?: number + /** Custom fallback when wallet needs connection. Defaults to ConnectWalletButton. */ + fallback?: ReactElement + /** Button label during pending transaction. Defaults to 'Sending...'. */ + labelSending?: string + /** Callback function called when transaction is mined. */ + onMined?: (receipt: TransactionReceipt) => void + /** Label for the switch chain button. Defaults to 'Switch to'. */ + switchChainLabel?: string + /** Function that initiates the transaction and returns a hash. */ + transaction: { + (): Promise + methodId?: string + } +} + +/** + * @deprecated Use the adapter-based TransactionButton instead. + */ +function LegacyTransactionButton({ + chainId, + children = 'Send Transaction', + confirmations = 1, + disabled, + fallback = , + labelSending = 'Sending...', + onMined, + switchChainLabel = 'Switch to', + transaction, + ...restProps +}: LegacyTransactionButtonProps) { + const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } = + useWalletStatus({ chainId }) + + const [hash, setHash] = useState() + const [isPending, setIsPending] = useState(false) + + const { watchTx } = useTransactionNotification() + const { data: receipt } = useWaitForTransactionReceipt({ + hash: hash, + confirmations, + }) + + useEffect(() => { + const handleMined = async () => { + if (receipt && isPending) { + await onMined?.(receipt) + setIsPending(false) + setHash(undefined) + } + } + + handleMined() + }, [isPending, onMined, receipt]) + + if (needsConnect) { + return fallback + } + + if (needsChainSwitch) { + return ( + switchChain(targetChainId)}> + {switchChainLabel} {targetChain.name} + + ) + } + + const handleSendTransaction = async () => { + setIsPending(true) + try { + const txPromise = transaction() + watchTx({ txPromise, methodId: transaction.methodId }) + const hash = await txPromise + setHash(hash) + } catch (error: unknown) { + console.error('Error sending transaction', error instanceof Error ? error.message : error) + setIsPending(false) + } + } + + return ( + + {isPending ? labelSending : children} + + ) +} + +export default LegacyTransactionButton diff --git a/src/transactions/components/SignButton.test.tsx b/src/transactions/components/SignButton.test.tsx new file mode 100644 index 00000000..138de4ba --- /dev/null +++ b/src/transactions/components/SignButton.test.tsx @@ -0,0 +1,216 @@ +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/wallet/components', () => ({ + SwitchChainButton: ({ 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/transactions/components/SignButton.tsx b/src/transactions/components/SignButton.tsx new file mode 100644 index 00000000..b9cbc0a8 --- /dev/null +++ b/src/transactions/components/SignButton.tsx @@ -0,0 +1,101 @@ +import { type ButtonProps, chakra } from '@chakra-ui/react' +import type { FC, ReactElement } from 'react' +import { useState } from 'react' +import { useChainRegistry, useWallet } from '@/src/sdk/react/hooks' +import { SwitchChainButton } from '@/src/wallet/components' +import { ConnectWalletButton } from '@/src/wallet/providers' + +interface SignButtonProps extends Omit { + /** Target chain ID for wallet and adapter resolution. */ + chainId?: string | number + /** Target chain type for wallet and adapter resolution (e.g. 'evm', 'svm'). */ + chainType?: string + /** 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, + chainType, + children = 'Sign Message', + disabled, + fallback = ( + + ), + labelSigning = 'Signing...', + message, + onError, + onSign, + switchChainLabel = 'Switch to', + ...restProps +}) => { + const wallet = useWallet({ chainId, chainType }) + 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/transactions/components/TransactionButton.test.tsx b/src/transactions/components/TransactionButton.test.tsx new file mode 100644 index 00000000..38073c8e --- /dev/null +++ b/src/transactions/components/TransactionButton.test.tsx @@ -0,0 +1,308 @@ +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/wallet/components', () => ({ + SwitchChainButton: ({ 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/transactions/components/TransactionButton.tsx b/src/transactions/components/TransactionButton.tsx new file mode 100644 index 00000000..ea61bbbe --- /dev/null +++ b/src/transactions/components/TransactionButton.tsx @@ -0,0 +1,85 @@ +import type { ButtonProps } from '@chakra-ui/react' +import type { ReactElement } from 'react' +import { PrimaryButton } from '@/src/core/components' +import type { TransactionLifecycle, TransactionParams } from '@/src/sdk/core' +import { useChainRegistry, useTransaction, useWallet } from '@/src/sdk/react/hooks' +import { SwitchChainButton } from '@/src/wallet/components' +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/transactions/providers/TransactionNotificationProvider.test.tsx b/src/transactions/providers/TransactionNotificationProvider.test.tsx new file mode 100644 index 00000000..46a0e538 --- /dev/null +++ b/src/transactions/providers/TransactionNotificationProvider.test.tsx @@ -0,0 +1,82 @@ +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { + TransactionNotificationProvider, + useTransactionNotification, +} from './TransactionNotificationProvider' + +const mockCreate = vi.fn((_options: Record) => 'toast-1') + +vi.mock('@/src/core/components', () => ({ + notificationToaster: { + create: (options: Record) => mockCreate(options), + }, + ExplorerLink: () => null, + NotificationToast: () => null, +})) + +vi.mock('@/src/wallet/hooks', () => ({ + useWeb3Status: vi.fn(() => ({ + readOnlyClient: { + chain: { id: 1, name: 'Ethereum' }, + waitForTransactionReceipt: vi.fn(async () => ({ status: 'success' })), + }, + })), +})) + +const wrapper = ({ children }: { children: ReactNode }) => + createElement(TransactionNotificationProvider, null, children) + +describe('TransactionNotificationProvider', () => { + describe('watchSignature', () => { + it('shows error toast (not success) when signature is rejected', async () => { + const { result } = renderHook(() => useTransactionNotification(), { wrapper }) + + const rejectedPromise = Promise.reject( + Object.assign(new Error('User rejected'), { shortMessage: 'User rejected the request' }), + ) + + await act(async () => { + result.current.watchSignature({ + message: 'Sign this', + signaturePromise: rejectedPromise, + }) + // Let microtasks settle + await rejectedPromise.catch(() => {}) + }) + + const allArgs = mockCreate.mock.calls.map((call) => call[0]) + const errorToast = allArgs.find((arg) => arg.description === 'User rejected the request') + expect(errorToast).toBeDefined() + expect(errorToast?.type).toBe('error') + }) + }) + + describe('watchTx', () => { + it('does not cause unhandled rejection when txPromise rejects', async () => { + const { result } = renderHook(() => useTransactionNotification(), { wrapper }) + + const rejectedPromise = Promise.reject( + Object.assign(new Error('User rejected'), { shortMessage: 'User rejected the request' }), + ) + + // If watchTx awaits the same promise twice, the second await + // would throw an unhandled rejection. This test verifies that + // watchTx returns cleanly after the first rejection. + await act(async () => { + await result.current.watchTx({ txPromise: rejectedPromise }) + }) + + // If we reach here without an unhandled rejection, the fix is correct. + // Also verify the error toast was shown, not a success toast. + const allArgs = mockCreate.mock.calls.map((call) => call[0]) + const errorToast = allArgs.find( + (arg) => arg.type === 'error' && arg.description === 'User rejected the request', + ) + expect(errorToast).toBeDefined() + }) + }) +}) diff --git a/src/transactions/providers/TransactionNotificationProvider.tsx b/src/transactions/providers/TransactionNotificationProvider.tsx new file mode 100644 index 00000000..6f83c2ce --- /dev/null +++ b/src/transactions/providers/TransactionNotificationProvider.tsx @@ -0,0 +1,242 @@ +import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext } from 'react' +import type { + Hash, + ReplacementReturnType, + SignMessageErrorType, + TransactionExecutionError, +} from 'viem' +import { ExplorerLink, NotificationToast, notificationToaster } from '@/src/core/components' +import { useWeb3Status } from '@/src/wallet/hooks' + +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: 'error', + 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, + // biome-ignore lint/suspicious/noAssignInExpressions: + 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 = '' + let hash: Hash | undefined + + try { + toastId = notificationToaster.create({ + description: `Signature requested: ${transactionMessage}`, + type: 'loading', + }) + hash = await txPromise + } 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: 'error', + id: toastId, + }) + return + } + + 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 +} From 1c3795d3a507b2b785fb73f220edd6602c474109 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:11:35 +0200 Subject: [PATCH 13/16] feat: mount DAppBoosterProvider with ConnectKit connector and shared wagmi config --- src/routes/__root.tsx | 64 ++++++++++++++++----------- src/wallet/connectors/wagmi.config.ts | 14 ++++++ 2 files changed, 52 insertions(+), 26 deletions(-) create mode 100644 src/wallet/connectors/wagmi.config.ts diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index f299c7d5..bce42a4e 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,14 +1,41 @@ -import { chakra, Flex } from '@chakra-ui/react' +import { + Footer, + Header, + 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 { connector, config as wagmiConfig } from '@/src/wallet/connectors/wagmi.config' +import '@/src/wallet/connectors/portoInit' +import { Flex } from '@chakra-ui/react' import { createRootRoute, Outlet } from '@tanstack/react-router' import { Analytics } from '@vercel/analytics/react' -import { TanStackReactQueryDevtools } from '@/src/components/sharedComponents/dev/TanStackReactQueryDevtools' -import { TanStackRouterDevtools } from '@/src/components/sharedComponents/dev/TanStackRouterDevtools' -import { Footer } from '@/src/components/sharedComponents/ui/Footer' -import { Header } from '@/src/components/sharedComponents/ui/Header' -import { Provider } from '@/src/components/ui/provider' -import { Toaster } from '@/src/components/ui/toaster' -import { TransactionNotificationProvider } from '@/src/providers/TransactionNotificationProvider' -import { Web3Provider } from '@/src/providers/Web3Provider' +import type { Chain } from 'viem' + +const evmChains: Chain[] = [...chains] + +const evmWalletBundle = createEvmWalletAdapter({ + connector, + chains: evmChains, + transports, + wagmiConfig, +}) + +const evmTransactionAdapter = createEvmTransactionAdapter({ + chains: evmChains, + transports, +}) + +const dappboosterConfig = { + wallets: { evm: evmWalletBundle }, + transactions: { evm: evmTransactionAdapter }, +} + export const Route = createRootRoute({ component: Root, }) @@ -16,33 +43,18 @@ export const Route = createRootRoute({ function Root() { return ( - + - - Skip to main content -
@@ -52,7 +64,7 @@ function Root() { - + ) diff --git a/src/wallet/connectors/wagmi.config.ts b/src/wallet/connectors/wagmi.config.ts new file mode 100644 index 00000000..221d3775 --- /dev/null +++ b/src/wallet/connectors/wagmi.config.ts @@ -0,0 +1,14 @@ +/** + * Shared wagmi Config and connector — the single place to choose which EVM connector to use. + * Both the SDK adapter (in __root.tsx) and generated contract hooks reference this file. + * + * To switch connectors, change the import below: + * import { connectkitConnector as connector } from '@/src/sdk/core/evm' + * import { rainbowkitConnector as connector } from '@/src/sdk/core/evm' + * import { reownConnector as connector } from '@/src/sdk/core/evm' + */ +import { chains, transports } from '@/src/core/types' +import { connectkitConnector as connector } from '@/src/sdk/core/evm' + +export { connector } +export const config = connector.createConfig([...chains], transports) From 09bc3d5011aef8d5d66194e927b51e1f5468e5cd Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:11:58 +0200 Subject: [PATCH 14/16] chore: merge upstream develop changes (tooling, CI, config) --- .../components/WalletStatusVerifier.test.tsx | 205 ++++++++++++++++++ .../components/WalletStatusVerifier.tsx | 73 +++++++ 2 files changed, 278 insertions(+) create mode 100644 src/wallet/components/WalletStatusVerifier.test.tsx create mode 100644 src/wallet/components/WalletStatusVerifier.tsx diff --git a/src/wallet/components/WalletStatusVerifier.test.tsx b/src/wallet/components/WalletStatusVerifier.test.tsx new file mode 100644 index 00000000..bafd6720 --- /dev/null +++ b/src/wallet/components/WalletStatusVerifier.test.tsx @@ -0,0 +1,205 @@ +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 { DeveloperError } from '@/src/core/utils/DeveloperError' +import { useWeb3StatusConnected, WalletStatusVerifier } from './WalletStatusVerifier' + +const mockSwitchChain = vi.fn() + +vi.mock('@/src/wallet/hooks/useWalletStatus', () => ({ + useWalletStatus: vi.fn(() => ({ + isReady: false, + needsConnect: true, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' }, + targetChainId: 1, + switchChain: mockSwitchChain, + })), +})) + +vi.mock('@/src/wallet/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + address: '0x1234567890abcdef1234567890abcdef12345678', + appChainId: 1, + balance: undefined, + connectingWallet: false, + disconnect: vi.fn(), + isWalletConnected: true, + isWalletSynced: true, + readOnlyClient: {}, + switchChain: vi.fn(), + switchingChain: false, + walletChainId: 1, + walletClient: {}, + })), +})) + +vi.mock('@/src/wallet/providers', () => ({ + ConnectWalletButton: () => + createElement( + 'button', + { type: 'button', 'data-testid': 'connect-wallet-button' }, + 'Connect Wallet', + ), +})) + +const { useWalletStatus } = await import('@/src/wallet/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 via context 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 Consumer = () => { + const { address } = useWeb3StatusConnected() + return createElement('div', { 'data-testid': 'address' }, address) + } + + renderWithChakra(createElement(WalletStatusVerifier, null, createElement(Consumer))) + + expect(screen.getByTestId('address')).toHaveTextContent( + '0x1234567890abcdef1234567890abcdef12345678', + ) + }) + + it('throws DeveloperError when useWeb3StatusConnected is used outside WalletStatusVerifier', () => { + const Consumer = () => { + const { address } = useWeb3StatusConnected() + return createElement('div', null, address) + } + + expect(() => renderWithChakra(createElement(Consumer))).toThrow(DeveloperError) + }) +}) diff --git a/src/wallet/components/WalletStatusVerifier.tsx b/src/wallet/components/WalletStatusVerifier.tsx new file mode 100644 index 00000000..838efb11 --- /dev/null +++ b/src/wallet/components/WalletStatusVerifier.tsx @@ -0,0 +1,73 @@ +import type { FC, ReactElement } from 'react' +import { createContext, useContext } from 'react' +import type { ChainsIds, RequiredNonNull } from '@/src/core/types' +import { DeveloperError } from '@/src/core/utils/DeveloperError' +import { useWalletStatus } from '../hooks/useWalletStatus' +import { useWeb3Status, type Web3Status } from '../hooks/useWeb3Status' +import { ConnectWalletButton } from '../providers' +import SwitchChainButton from './SwitchChainButton' + +type ConnectedWeb3Status = RequiredNonNull + +const WalletStatusVerifierContext = createContext(null) + +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. + * + * @deprecated Use {@link WalletGuard} from `@/src/sdk/react` instead. + * + * @example + * ```tsx + * + * + * + * ``` + */ +const WalletStatusVerifier: FC = ({ + chainId, + children, + fallback = , + switchChainLabel = 'Switch to', +}: WalletStatusVerifierProps) => { + const web3Status = useWeb3Status() + const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } = + useWalletStatus({ chainId }) + + if (needsConnect) { + return fallback + } + + if (needsChainSwitch) { + return ( + switchChain(targetChainId)}> + {switchChainLabel} {targetChain.name} + + ) + } + + return ( + + {children} + + ) +} + +/** Reads the connected Web3 status from WalletStatusVerifier context. */ +const useWeb3StatusConnected = (): ConnectedWeb3Status => { + const context = useContext(WalletStatusVerifierContext) + if (context === null) { + throw new DeveloperError('useWeb3StatusConnected must be used inside ') + } + return context +} + +export { useWeb3StatusConnected, WalletStatusVerifier } From b23fbe86ee7195c8e33ad8270448edbbf75343cb Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 09:12:15 +0200 Subject: [PATCH 15/16] docs: add adapter architecture spec aligned with Phase 2 implementation --- .../adapter-architecture-overview.md | 575 +++++ .../architecture/adapter-architecture-spec.md | 2161 +++++++++++++++++ docs/architecture/domain-folder-structure.md | 121 + resume.txt | 42 + src/sdk/react/provider/index.ts | 1 - 5 files changed, 2899 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adapter-architecture-overview.md create mode 100644 docs/architecture/adapter-architecture-spec.md create mode 100644 docs/architecture/domain-folder-structure.md create mode 100644 resume.txt diff --git a/docs/architecture/adapter-architecture-overview.md b/docs/architecture/adapter-architecture-overview.md new file mode 100644 index 00000000..bc3c9827 --- /dev/null +++ b/docs/architecture/adapter-architecture-overview.md @@ -0,0 +1,575 @@ +# dAppBooster Adapter Architecture + +> **Follow-up to:** [Domain Folder Architecture](https://hackmd.io/@feper/ryUoesKj-l) +> **Status:** Design complete, implementation planning next +> **Date:** 2026-04-01 + +## Where we are + +The domain folder reorganization (Task 1) restructured `src/` into 6 domain folders with sub-barrel entry points. That was the foundation. This document describes what we're building on top of it: a chain-agnostic, headless-first adapter architecture that transforms dAppBooster from an EVM starter template into a multi-chain SDK. + +### What changed since the domain folder doc + +- **Task 2 (#wallet alias)** — absorbed into the adapter architecture. Connector swapping is now runtime config, not a Vite alias. +- **Task 3 (package extraction)** — informed by this architecture. The domain folders map to packages, but the package boundaries and interfaces are now fully designed. +- **Monorepo** — we're going monorepo (Turborepo + pnpm workspaces + Changesets), not polyrepo. +- **npm scope** — `@dappbooster/*` is reserved on npm. + +--- + +## The big picture + +dAppBooster becomes three packages: + +``` +@dappbooster/core Adapters, interfaces, types, chain registry. + Framework-agnostic — works in Node.js, CLI, agents, Vue, anything. + +@dappbooster/react Hooks and provider. + React 19+, no styling dependency. + +@dappbooster/chakra Styled components. + One of many possible style packages. Thin wrappers (~30 lines each) + around the hooks. +``` + +A Tailwind user imports `core` + `react` and writes their own 30-line components. A CLI tool imports only `core`. An agent script imports only `core`. The hooks do the heavy lifting — components are just UI wrappers. + +### Escape hatch progression + +Every layer is independently replaceable: + +``` +Level 1: ← zero boilerplate (style package) +Level 2: useTransaction() ← control the UI (react hooks) +Level 3: useTransaction().adapter ← raw adapter access +Level 4: adapter prop ← bypass provider entirely +Level 5: @dappbooster/core directly ← no React, no provider, no hooks +``` + +Agents default to Level 1. Experienced devs go to Level 2. Edge cases go deeper. + +--- + +## Two adapters, one provider + +The architecture separates wallet and transaction concerns into two independent adapter interfaces. You can use one without the other. + +### WalletAdapter + +Owns connection and signing for a chain type. + +```typescript +interface WalletAdapter { + connect() → WalletConnection { address, chainId } + disconnect() + getStatus() → { connected, address, chainId, connecting } + signMessage() → SignatureResult + signTypedData() → SignatureResult // EIP-712, permits, Safe approvals + getSigner() → chain-native signer (opaque to the SDK) +} +``` + +### TransactionAdapter + +Owns the four-phase transaction lifecycle. **Optional** — auth-only apps skip this entirely. + +```typescript +interface TransactionAdapter { + prepare() → { ready, reason, estimatedFee, preSteps[] } + execute() → TransactionRef { id, chainType, chainId } + confirm() → TransactionResult { status, receipt } +} +``` + +The four phases — **prepare → execute → confirm → report** — are universal across every blockchain. The implementation differs per chain. That's what adapters are for. + +### Why two, not one? + +- Swap wallet providers without touching transaction logic. +- Customize gas strategies without touching wallet connection. +- Auth-only apps (like wh-portal-earn) register wallet adapters only — no transaction adapter needed. +- Read-only apps (portfolio trackers) skip both. + +--- + +## Lifecycle hooks + +Two sets of hooks for cross-cutting concerns (notifications, analytics, logging): + +**TransactionLifecycle** — fires during the transaction four-phase cycle: + +``` +onPrepare → onPreStep → onSubmit → onConfirm + → onError (any phase) + → onReplace (tx speedup/cancel) +``` + +**WalletLifecycle** — fires during signing: + +``` +onSign → onSignComplete + → onSignError +``` + +### Two scopes + +- **Global** (provider config) — every transaction/signing fires these. The notification system lives here. +- **Per-operation** (passed to a hook/component) — fires for one specific transaction. + +Both always fire. Global first, then per-operation. Hooks are observers — they never abort the transaction. + +### What this replaces + +`TransactionNotificationProvider` with its `watchTx`/`watchHash`/`watchSignature` methods becomes a set of global lifecycle hooks. Same behavior, formalized interface, no special provider. + +--- + +## ChainDescriptor + +Chain metadata is independent of adapters. Every chain — whether you connect a wallet to it or not — has an identity: name, explorer, endpoints, address format, currency. + +```typescript +interface ChainDescriptor { + caip2Id: string // Universal ID: 'eip155:1', 'solana:5eykt4U...', 'cosmos:cosmoshub-4' + chainId: string | number // Native ID: 1, 'cosmoshub-4', -239 (TON) + name: string // 'Ethereum', 'Solana', 'Osmosis' + chainType: string // 'evm', 'svm', 'cosmos', 'movevm-sui', ... + nativeCurrency: CurrencyInfo // { symbol: 'ETH', decimals: 18 } + feeCurrency?: CurrencyInfo // Only if different from native (StarkNet: STRK, Berachain: BERA) + explorer?: ExplorerConfig // URLs with generic {id} placeholder (not {hash}) + endpoints?: EndpointConfig[] // Typed by protocol: json-rpc, rest, graphql, grpc, websocket + addressConfig: AddressConfig // Format, prefix, validation patterns + testnet?: boolean +} +``` + +### Why CAIP-2? + +`chainId` means different things on different chains — a number on EVM, a genesis hash on Solana, a string on Cosmos, a negative integer on TON. [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) is the industry standard that normalizes this: `namespace:reference`. The SDK uses `caip2Id` for cross-chain lookups and `chainId` for native chain interactions. + +### Why typed endpoints? + +Not every chain uses JSON-RPC. Aptos uses REST. Fuel uses GraphQL. Cosmos uses JSON-RPC + gRPC + REST simultaneously. The `EndpointConfig` declares what protocol each URL speaks, so adapters can pick the right one. + +### Explorer URLs use `{id}`, not `{hash}` + +Solana calls them "signatures." Sui calls them "digests." Aptos uses version numbers. The `{id}` placeholder is chain-agnostic. + +### Chain support tiers + +We designed the descriptor by researching 23+ blockchain ecosystems: + +| Tier | Chains | Status | +|---|---|---| +| **Must support** | EVM, Solana, Sui, Aptos, Cosmos | Descriptor handles all of these | +| **Later** | StarkNet, Polkadot, NEAR, TON | Descriptor handles all of these | +| **Excluded** | Bitcoin, Cardano, ICP, Radix, Fuel, Tezos, Algorand, XRP, Stellar... | UTXO/alien models — not dApp platforms in the dAppBooster sense | + +Dual-VM chains like Sei (EVM + Cosmos) register as two separate descriptors. From the SDK's perspective, they're different chains. + +--- + +## DAppBoosterProvider + +The single provider that replaces the current `Web3Provider` + `TransactionNotificationProvider` stack. + +```tsx + + + +``` + +The provider: + +1. Builds a chain registry from all adapters' `supportedChains` +2. Validates no chainId conflicts +3. Syncs wallet state to React context +4. Exposes adapter resolution to hooks + +### Different app types, same provider + +```tsx +// Auth-only app (portal-earn pattern) — no transaction adapter +{ wallets: { evm, svm, sui, aptos }, walletLifecycle: signingNotifications } + +// Read-only portfolio tracker — no adapters at all +{ chains: [...evmChains, solanaMainnet, cosmosHub] } + +// Multi-chain bridge +{ wallets: { evm, svm }, transactions: { evm, svm }, lifecycle: notificationLifecycle } +``` + +--- + +## React hooks + +The primary API for React apps. Each hook resolves adapters from provider context. + +| Hook | Replaces | Purpose | +|---|---|---| +| `useWallet({ chainId })` | `useWeb3Status` + `useWalletStatus` | Wallet state + actions for a chain | +| `useTransaction({ chainId, params })` | Inline wagmi calls in TransactionButton | Transaction lifecycle | +| `useMultiWallet()` | — (new) | All registered wallets and their states | +| `useReadOnly({ chainId, address })` | — (new) | Public client for arbitrary addresses, no wallet needed | +| `useChainRegistry()` | — (new) | Chain metadata access | + +### useWallet example + +```typescript +const wallet = useWallet({ chainId: 1 }) + +wallet.status // { connected, address, chainId, connecting } +wallet.isReady // connected && on correct chain +wallet.needsConnect // not connected +wallet.needsChainSwitch +wallet.connect() +wallet.signMessage({ message: 'Hello' }) +wallet.switchChain(10) +wallet.adapter // escape hatch — raw adapter +``` + +### useTransaction example + +```typescript +const tx = useTransaction({ + chainId: 1, + params: { chainId: 1, payload: { contract: { address, abi, functionName, args } } }, + lifecycle: { onConfirm: () => invalidateQueries() }, +}) + +tx.phase // 'idle' | 'prepare' | 'submit' | 'confirm' +tx.execute() // runs the full cycle: prepare → submit → confirm +tx.result // TransactionResult after confirmation +tx.explorerUrl // from chain registry +``` + +--- + +## Styled components (style packages) + +Components live in `@dappbooster/chakra` (or future `@dappbooster/tailwind`, etc.). They're thin wrappers around hooks: + +```tsx +// This is roughly what TransactionButton looks like — ~30 lines +function TransactionButton({ chainId, params, lifecycle, label, ...chakraProps }) { + const wallet = useWallet({ chainId }) + const tx = useTransaction({ chainId, params, lifecycle }) + + if (wallet.needsConnect) return + if (wallet.needsChainSwitch) return + + return ( + + ) +} +``` + +A Tailwind version is the same logic, different markup. The hook does the work. + +| Component | Purpose | +|---|---| +| `TransactionButton` | One-click transaction with wallet gating | +| `SignButton` | Message signing with wallet gating | +| `WalletGuard` | Gate children on wallet requirements (single or multi-chain) | +| `ConnectWalletButton` | Trigger wallet connection | +| `ExplorerLink` | Chain-agnostic explorer links | +| `SwitchChain` | Chain selector dropdown | + +--- + +## EVM adapter (what ships at launch) + +The only adapter we ship initially. It wraps the existing wagmi/viem code — no new EVM logic, just formalization behind the interfaces. + +| Adapter method | Wraps | +|---|---| +| `connect()` | ConnectKit/RainbowKit modal | +| `getSigner()` | wagmi `WalletClient` | +| `signMessage()` | wagmi `signMessage` | +| `prepare()` | `estimateGas`, balance check, allowance check | +| `execute()` | `sendTransaction` or `writeContract` | +| `confirm()` | `waitForTransactionReceipt` with replacement detection | + +### Connectors are subpath exports + +ConnectKit, RainbowKit, and Reown are EVM-specific connector adapters. They live in `@dappbooster/core` as subpath exports with optional peer dependencies: + +```typescript +import { connectkitConnector } from '@dappbooster/core/evm/connectors' +``` + +If you use ConnectKit, install `connectkit`. If you use RainbowKit, install `@rainbow-me/rainbowkit`. A CLI tool installs neither. + +### Generated hooks still work + +`pnpm codegen` (renamed from `pnpm wagmi-generate`) still produces typed hooks for specific contracts. These coexist with the adapter — they're an EVM-specific convenience, not a replacement. Non-React apps get framework-agnostic typed actions from the same codegen. + +--- + +## Beyond the browser + +`@dappbooster/core` is framework-agnostic. Same adapters, same lifecycle, different consumers: + +### Agent script (Node.js) + +```typescript +import { createEvmTransactionAdapter, createEvmServerWallet } from '@dappbooster/core' + +const wallet = createEvmServerWallet({ privateKey: process.env.AGENT_PK }) +const evm = createEvmTransactionAdapter() + +const ref = await evm.execute(params, wallet.getSigner()) +const result = await evm.confirm(ref) +``` + +### CLI tool + +```typescript +import { createChainRegistry, getExplorerUrl } from '@dappbooster/core' +const registry = createChainRegistry([...evmChains, solanaMainnet]) + +for (const chain of registry.getAllChains()) { + console.log(`${chain.name}: ${getExplorerUrl(registry, { chainId: chain.chainId, address })}`) +} +``` + +### Relayer (backend service) + +Same adapters with server-side signers. Lifecycle hooks plug into monitoring. The SDK handles execute/confirm; the relayer adds nonce management and retry logic. + +--- + +## 14 validated use cases + +Every use case below works with the same adapter interfaces: + +| # | Use case | What it validates | +|---|---|---| +| 1 | EVM dApp (Aave-like) | Single-chain, generated hooks, TransactionButton | +| 2 | Auth-only (portal-earn) | Wallet adapters only, multi-platform signing | +| 3 | Portfolio tracker (Rotki-like) | Zero adapters, read-only, arbitrary addresses | +| 4 | Cross-chain bridge | Multi-adapter, flow orchestration (consumer-land) | +| 5 | Tailwind-styled dApp | Hooks only, no Chakra, headless pattern | +| 6 | Agent script | Node.js, server signer, no React | +| 7 | CLI tool | Terminal, multi-chain commands | +| 8 | Relayer | Backend, lifecycle hooks for monitoring | +| 9 | Smart wallet (ERC-4337) | UserOperations, bundler, paymaster | +| 10 | Gasless app | Client signs, server submits | +| 11 | Multi-sig (Safe-like) | Multi-party lifecycle, EIP-712 typed data | +| 12 | Token-gated app | Wallet for identity only, no transactions | +| 13 | ZK identity/voting | Proof generation as PreStep | +| 14 | FHE private DeFi | Encrypt/decrypt middleware on adapters | + +--- + +## Monorepo structure + +``` +dAppBooster/ ← monorepo root +├── packages/ +│ ├── core/ ← @dappbooster/core +│ │ ├── package.json +│ │ └── src/ +│ │ ├── adapters/ # WalletAdapter, TransactionAdapter interfaces +│ │ ├── chain/ # ChainDescriptor, ChainRegistry, getExplorerUrl +│ │ ├── evm/ # EVM adapter implementations +│ │ │ ├── connectors/ # connectkit, rainbowkit, reown (subpath exports) +│ │ │ ├── wallet.ts # EvmWalletAdapter (wraps wagmi internally) +│ │ │ ├── transaction.ts # EvmTransactionAdapter (wraps viem) +│ │ │ └── server-wallet.ts # EvmServerWallet (private key signer) +│ │ ├── tokens/ # Token types, token list config, cache utils +│ │ ├── data/ # Data adapter interfaces +│ │ ├── types/ # Shared types +│ │ └── utils/ # String utils, address utils +│ │ +│ ├── react/ ← @dappbooster/react +│ │ ├── package.json # depends on @dappbooster/core +│ │ └── src/ +│ │ ├── provider/ # DAppBoosterProvider +│ │ ├── hooks/ +│ │ │ ├── useWallet.ts +│ │ │ ├── useTransaction.ts +│ │ │ ├── useMultiWallet.ts +│ │ │ ├── useReadOnly.ts +│ │ │ ├── useChainRegistry.ts +│ │ │ ├── useTokenLists.ts +│ │ │ ├── useTokens.ts +│ │ │ ├── useErc20Balance.ts +│ │ │ └── useTokenSearch.ts +│ │ └── types/ +│ │ +│ ├── chakra/ ← @dappbooster/chakra +│ │ ├── package.json # depends on @dappbooster/react +│ │ └── src/ +│ │ ├── components/ +│ │ │ ├── TransactionButton.tsx +│ │ │ ├── SignButton.tsx +│ │ │ ├── WalletGuard.tsx +│ │ │ ├── ConnectWalletButton.tsx +│ │ │ ├── SwitchChain.tsx +│ │ │ ├── ExplorerLink.tsx +│ │ │ ├── Hash.tsx +│ │ │ ├── BigNumberInput.tsx +│ │ │ ├── NotificationToaster.tsx +│ │ │ └── tokens/ # TokenSelect, TokenInput, TokenLogo +│ │ └── styles/ +│ │ +│ └── create-dappbooster/ ← CLI scaffolding tool +│ ├── package.json +│ └── src/ +│ +├── templates/ ← what create-dappbooster scaffolds +│ ├── evm-defi/ # Full dApp starter +│ │ ├── src/ +│ │ │ ├── components/ # Header, Footer, page components +│ │ │ ├── contracts/ # ABIs, definitions, generated.ts +│ │ │ ├── routes/ # TanStack Router pages +│ │ │ ├── theme/ # Chakra provider, color-mode, fonts +│ │ │ ├── env.ts +│ │ │ └── main.tsx +│ │ ├── package.json # depends on @dappbooster/core + react + chakra +│ │ └── vite.config.ts +│ ├── bridge/ +│ ├── portfolio-tracker/ +│ ├── agent-script/ +│ └── ... +│ +├── apps/ ← living examples / dev playground +│ └── demo/ # Current demo app (home, examples) +│ ├── src/ +│ │ ├── components/pageComponents/ +│ │ ├── routes/ +│ │ └── ... +│ └── package.json # depends on @dappbooster/core + react + chakra +│ +├── turbo.json ← Turborepo config +├── pnpm-workspace.yaml ← pnpm workspaces +├── .changeset/ ← Changesets config +└── package.json ← root (scripts, devDeps) +``` + +### How packages reference each other + +```json +// packages/react/package.json +{ "dependencies": { "@dappbooster/core": "workspace:*" } } + +// packages/chakra/package.json +{ "dependencies": { "@dappbooster/react": "workspace:*" } } + +// apps/demo/package.json +{ "dependencies": { + "@dappbooster/core": "workspace:*", + "@dappbooster/react": "workspace:*", + "@dappbooster/chakra": "workspace:*" + } +} +``` + +`workspace:*` means "use the local version from this monorepo." pnpm symlinks them — edit a file in `packages/core/` and the react and chakra packages see the change immediately, no publishing step. + +### Daily workflow + +```bash +pnpm build # Turborepo builds core → react → chakra (dependency order, cached) +pnpm test # Tests across all packages in parallel +pnpm test --filter=@dappbooster/core # Tests only core +pnpm dev # Starts apps/demo with HMR, watching all packages +``` + +### Where current domain folders land + +The current `src/` domain folder structure dissolves — its contents scatter across packages: + +| Current location | Package | Notes | +|---|---|---| +| `src/core/config/`, `src/core/types/`, `src/core/utils/` | `packages/core/` | SDK infrastructure | +| `src/core/ui/ExplorerLink`, `Hash`, `BigNumberInput`, etc. | `packages/chakra/` | Styled components | +| `src/core/ui/Header/`, `Footer/`, `Modal/`, buttons, `chakra/` setup | `templates/evm-defi/` | App-level layout & design system | +| `src/wallet/connectors/` | `packages/core/src/evm/connectors/` | Subpath exports | +| `src/wallet/hooks/`, `providers/` | `packages/core/src/evm/` | Internals of EvmWalletAdapter | +| `src/wallet/components/` | `packages/chakra/` | WalletGuard, SwitchChain, ConnectButton | +| `src/transactions/providers/` | **Removed** — becomes lifecycle hooks | No provider, just hook callbacks | +| `src/transactions/components/` | `packages/chakra/` | TransactionButton, SignButton | +| `src/tokens/hooks/` | `packages/react/` | Token data hooks | +| `src/tokens/types/`, `config/`, `utils/` | `packages/core/` | Token infrastructure | +| `src/tokens/components/` | `packages/chakra/` | TokenSelect, TokenInput, etc. | +| `src/contracts/wagmi/` | `packages/core/src/evm/` | wagmi config + plugins | +| `src/contracts/abis/`, `definitions.ts`, `generated.ts` | `templates/evm-defi/` | App-specific contracts | +| `src/data/adapters/` infrastructure | `packages/core/` | Adapter pattern | +| `src/data/adapters/subgraph/queries/`, `gql/` | `templates/evm-defi/` | App-specific queries | +| `src/components/pageComponents/`, `src/routes/` | `apps/demo/` or `templates/` | App-level code | + +### Tooling + +- **pnpm workspaces** — package management (already using pnpm) +- **Turborepo** — build orchestration with dependency-aware caching +- **Changesets** — versioning + changelogs for multi-package releases + +--- + +## Migration path + +The migration is incremental. Old and new code coexist at each phase. + +### Phase 1: Introduce adapters alongside existing code + +- Add adapter interfaces and EVM implementations +- `DAppBoosterProvider` wraps the existing provider stack internally +- New hooks (`useWallet`, `useTransaction`) work alongside existing ones +- **No breaking changes** + +### Phase 2: Migrate internals to adapter-backed hooks + +- TransactionButton/SignButton use `useTransaction`/`useWallet` internally +- `WalletStatusVerifier` → `WalletGuard` +- `TransactionNotificationProvider` → global lifecycle hooks +- Old hooks deprecated with `@deprecated` pointing to replacements + +### Phase 3: Extract packages + +- Styled components move to `@dappbooster/chakra` +- Hooks and provider become `@dappbooster/react` +- Interfaces, adapters, types become `@dappbooster/core` +- Remove deprecated code + +### Phase 4: Multi-chain + +- Community or official SVM, Cosmos, Sui, Aptos adapters +- `pnpm codegen` dispatches per chain type +- Reference apps published as templates + +--- + +## What stays the same + +- wagmi/viem under the hood for EVM +- TanStack Router for file-based routing +- Chakra UI for the default style package +- Biome for linting +- Vitest for testing +- The domain folder structure from Task 1 maps directly to packages + +## What changes + +| Before | After | +|---|---| +| EVM-only | Chain-agnostic via adapters | +| Hardcoded ConnectKit import | Connector as config: `createEvmWalletAdapter({ connector })` | +| `TransactionNotificationProvider` | Global lifecycle hooks | +| `useWeb3Status` / `useWalletStatus` | `useWallet({ chainId })` | +| Components coupled to Chakra | Headless hooks + optional style packages | +| Starter template | SDK (installable packages) + templates (scaffolding) | +| Single repo | Monorepo with Turborepo + Changesets | +| `pnpm wagmi-generate` | `pnpm codegen` (multi-chain, dual output) | diff --git a/docs/architecture/adapter-architecture-spec.md b/docs/architecture/adapter-architecture-spec.md new file mode 100644 index 00000000..8e7bc4f4 --- /dev/null +++ b/docs/architecture/adapter-architecture-spec.md @@ -0,0 +1,2161 @@ +# dAppBooster Adapter Architecture Spec + +> **Status:** Phase 2 implementation complete — spec aligned with `feat/huge-auto-refactor` +> **Date:** 2026-04-06 (updated) +> **Branch:** `feat/huge-auto-refactor` +> **Depends on:** Domain folder reorganization (Task 1, commit `30e00e46b`) + +## Overview + +This spec defines a chain-agnostic adapter architecture for dAppBooster, transforming it from an EVM-only starter template into a multi-chain, headless-first blockchain interaction SDK. + +The architecture enables dAppBooster to serve as the go-to SDK for **any** blockchain UI — from single-chain DeFi apps to cross-chain bridges, portfolio trackers, agent scripts, CLI tools, and backend relayers — using the same core primitives. + +### Design principles + +- **Headless-first.** Logic has zero UI dependencies. Styling is opt-in. +- **Adapter-based.** Each chain type implements standard interfaces. The SDK is chain-agnostic. +- **Layered escape hatches.** Components → hooks → adapters → raw. Each layer peels back one level of abstraction. +- **Agent-deterministic.** One way to do each thing. No ambiguity, no alternatives for the same operation. +- **Composable, not monolithic.** Pick the layers you need. Skip what you don't. + +### Package structure + +``` +@dappbooster/core → adapters, interfaces, types, chain registry, utilities +@dappbooster/react → hooks, provider (React 19+, no styling dependency) +@dappbooster/chakra → styled components (one of many possible style packages) +``` + +`@dappbooster/core` is framework-agnostic — usable in Node.js, CLI tools, agent scripts, Vue, Svelte, or any other runtime. `@dappbooster/react` adds React hooks. `@dappbooster/chakra` adds one opinionated styled component set. Other style packages (Tailwind, Shadcn, etc.) wrap the same hooks with different markup. + +--- + +## 1. Chain Descriptor and Registry + +Chain metadata is a foundational concern — independent of wallets, transactions, or any adapter. Explorer URLs, native currency, chain names, address formats, and RPC endpoints are properties of the chain itself. + +The descriptor was designed after analyzing 23+ blockchain ecosystems to ensure no structural refactoring is needed when adding support for new chain types. See Appendix A for the chain tier analysis. + +### ChainDescriptor + +```typescript +interface ChainDescriptor { + // Universal cross-chain identifier (CAIP-2 standard) + // Examples: 'eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d', + // 'cosmos:cosmoshub-4', 'polkadot:91b171bb158e2d38' + caip2Id: string + + // Native chain identifier (as the chain knows itself) + // EVM: number (1, 137, 42161). Cosmos: string ('cosmoshub-4'). + // Solana: string (genesis hash). TON: number (-239). StarkNet: string ('SN_MAIN'). + chainId: string | number + + // Human-readable chain name + name: string + + // VM/ecosystem family + chainType: string // 'evm' | 'svm' | 'movevm-sui' | 'movevm-aptos' | 'cosmos' | 'starknet' | 'substrate' | 'near' | 'ton' + + // Primary currency (staking, transfers, display) + nativeCurrency: CurrencyInfo + + // Gas/fee currency — only when different from nativeCurrency + // StarkNet: STRK, Berachain: BERA, Cosmos: may differ from staking token + feeCurrency?: CurrencyInfo + + // Block explorer configuration + explorer?: ExplorerConfig + + // Network endpoints — typed by protocol + endpoints?: EndpointConfig[] + + // Address format metadata + addressConfig: AddressConfig + + // Chain icon (URL or data URI — for SwitchChain selector, chain badges) + icon?: string + + // Testnet flag + testnet?: boolean +} + +interface CurrencyInfo { + symbol: string + decimals: number + name?: string +} +``` + +#### ExplorerConfig + +```typescript +interface ExplorerConfig { + name?: string // 'Etherscan', 'Solscan', 'Mintscan' + url: string // 'https://etherscan.io' + txPath: string // '/tx/{id}' — generic {id} placeholder, NOT {hash} + addressPath: string // '/address/{id}' + blockPath?: string // '/block/{id}' + queryParams?: Record // Solana: { cluster: 'mainnet-beta' } +} +``` + +The `{id}` placeholder is chain-agnostic — it works for EVM tx hashes, Solana signatures, Sui digests, Aptos version numbers, and any future identifier format. `queryParams` are appended to all explorer URLs for the chain (handles Solana's `?cluster=mainnet-beta` pattern). + +#### EndpointConfig + +```typescript +interface EndpointConfig { + url: string + protocol: 'json-rpc' | 'rest' | 'graphql' | 'grpc' | 'websocket' + purpose?: 'default' | 'indexer' | 'archive' | 'streaming' +} +``` + +Typed by protocol because chain ecosystems vary: +- **EVM**: `json-rpc` (default) + `websocket` (streaming) +- **Cosmos**: `json-rpc` (CometBFT) + `grpc` + `rest` (LCD) +- **Aptos**: `rest` (default) + `graphql` (indexer) +- **Sui**: `json-rpc` (default) + `graphql` (indexer) +- **Solana**: `json-rpc` (default) + `websocket` (PubSub) + +Adapters pick the endpoint type they need. The `purpose` field disambiguates when multiple endpoints of the same protocol exist. + +#### AddressConfig + +```typescript +interface AddressConfig { + // Address encoding format + format: 'hex' | 'base58' | 'bech32' | 'bech32m' | 'ss58' | 'named' | 'other' + // Chain-specific prefix (Cosmos HRP: 'cosmos', 'osmo'. Polkadot SS58 prefix. MultiversX: 'erd1') + prefix?: string + // Validation patterns (array — some chains support multiple valid address formats) + patterns: RegExp[] + // Example address for documentation/testing + example?: string +} +``` + +`patterns` is an array because some chains accept multiple address formats (e.g., Sui accepts both `0x` hex and Bech32m in the future). Most chains have one pattern — the array doesn't add DX cost but keeps the door open. + +#### Design decisions + +- **`caip2Id` is required, `chainId` is also required.** CAIP-2 is for cross-chain lookups and interoperability. `chainId` is the native value the chain uses internally (passed to wallets, RPC calls, etc.). Both are needed. +- **`feeCurrency` is optional, separate from `nativeCurrency`.** Most chains use the same token for both. When they differ (StarkNet: ETH native + STRK for fees, Berachain: tri-token, Cosmos: staking ≠ fee denom), `feeCurrency` captures the gas/fee token. +- **`addressConfig` is required** (not optional). Every chain has an address format. Making it required prevents runtime errors from missing metadata. +- **`testnet` flag** distinguishes test networks without encoding it in the chain name. +- **Dual-VM chains (Sei)** are registered as two separate ChainDescriptors — `{ caip2Id: 'eip155:1329', chainType: 'evm' }` and `{ caip2Id: 'cosmos:pacific-1', chainType: 'cosmos' }`. From the SDK's perspective, these are different chains with different adapters. The shared infrastructure is transparent. +- **No `stateModel`, `parentChain`, or `accountModel` fields.** These are adapter concerns, not descriptor concerns. The ChainDescriptor describes identity and metadata, not execution model. +- **`supportedChains` must be consistent with the adapter's `chainType`.** If a wallet adapter declares `chainType: 'evm'`, every entry in its `supportedChains` must have `chainType: 'evm'`. The provider validates this at initialization. + +### Chain registry + +The registry is a lookup structure built from `ChainDescriptor` arrays. It resolves chains by `chainId`, `caip2Id`, or `chainType`. + +```typescript +interface ChainRegistry { + getChain(chainId: string | number): ChainDescriptor | null + getChainByCaip2(caip2Id: string): ChainDescriptor | null + getChainType(chainId: string | number): string | null + getChainsByType(chainType: string): ChainDescriptor[] + getAllChains(): ChainDescriptor[] +} + +function createChainRegistry(chains: ChainDescriptor[]): ChainRegistry +``` + +If two descriptors declare the same `chainId` or the same `caip2Id`, `createChainRegistry` throws at construction time — fail fast, fail loud. + +### Explorer URL utility + +Works anywhere — React, Node, CLI. No adapter or provider required. + +```typescript +function getExplorerUrl( + registry: ChainRegistry, + params: + | { chainId: string | number; tx: string } + | { chainId: string | number; address: string } + | { chainId: string | number; block: string | number } +): string | null +``` + +The `tx` parameter (not `txHash`) is chain-agnostic — it accepts whatever identifier the chain uses for transactions (hash, signature, digest, version number). + +The SDK ships default `ChainDescriptor` sets for EVM chains via a factory function: + +```typescript +import { mainnet, optimism, arbitrum } from 'viem/chains' +import { fromViemChain } from '@dappbooster/core/evm' + +const chains = [mainnet, optimism, arbitrum].map(fromViemChain) +// Each gets caip2Id, chainId, explorer, endpoints, addressConfig auto-populated from viem +``` + +Other ecosystem descriptors ship with their respective adapter packages (`@dappbooster/solana`, `@dappbooster/cosmos`, etc.). + +--- + +## 2. Adapter Interfaces + +Two adapter interfaces, independently implementable. A wallet-only app (auth, token-gating) skips the transaction adapter. A read-only app (portfolio tracker) skips both. A full dApp uses both. + +All interfaces follow **Design by Contract**: preconditions, postconditions, and invariants are explicitly stated. Adapters that violate contracts throw typed errors. Consumers that violate preconditions receive clear error messages at the call site. + +### WalletAdapter + +Owns connection lifecycle and signing for a chain type. + +```typescript +interface WalletAdapter { + // --- Invariants --- + // chainType never changes after construction + // supportedChains never changes after construction + // Every entry in supportedChains has chainType matching this.chainType + readonly chainType: TChainType + readonly supportedChains: ChainDescriptor[] + + // --- Connection lifecycle --- + + // connect() + // Precondition: none (can be called when connected — reconnects) + // Postcondition: getStatus().connected === true + // Postcondition: result.accounts.length >= 1 + // Postcondition: result.activeAccount is included in result.accounts + // Throws: WalletConnectionRejectedError if user cancels + // Throws: WalletNotInstalledError if wallet extension is not available + connect(options?: ConnectOptions): Promise + + // reconnect() + // Precondition: none + // Postcondition: if session exists → returns WalletConnection, getStatus().connected === true + // Postcondition: if no session → returns null, getStatus() unchanged + // Note: for session persistence on page reload. Never throws — returns null on failure. + reconnect(): Promise + + // disconnect() + // Precondition: none (no-op if already disconnected) + // Postcondition: getStatus().connected === false + // Postcondition: getSigner() === null + disconnect(): Promise + + // --- State --- + + // getStatus() + // Precondition: none (callable at any time) + // Postcondition: returns current snapshot — not reactive + // Invariant: if connected === false → activeAccount === null, connectedChainIds === [] + // Invariant: if connected === true → activeAccount !== null, connectedChainIds.length >= 1 + getStatus(): WalletStatus + + // onStatusChange() + // Precondition: none + // Postcondition: listener fires on every status change + // Returns: unsubscribe function — calling it stops notifications + onStatusChange(listener: (status: WalletStatus) => void): () => void + + // --- Signing --- + + // signMessage() + // Precondition: getStatus().connected === true + // Postcondition: result.address matches the signing account + // Throws: WalletNotConnectedError if precondition violated + // Throws: SigningRejectedError if user cancels + signMessage(input: SignMessageInput): Promise + + // signTypedData() — OPTIONAL capability (EIP-712 on EVM, ADR-036 on Cosmos) + // Precondition: getStatus().connected === true + // Precondition: metadata.capabilities.signTypedData === true + // Throws: CapabilityNotSupportedError if capability is false + // Throws: WalletNotConnectedError if not connected + // Throws: SigningRejectedError if user cancels + signTypedData?(input: SignTypedDataInput): Promise + + // getSigner() + // Precondition: none + // Postcondition: if connected → returns chain-native signer (never null) + // Postcondition: if not connected → returns null + // Note: the returned signer is opaque to the SDK. TransactionAdapter validates it. + // Note: async because underlying client retrieval (e.g. wagmi getWalletClient) is async. + getSigner(): Promise + + // switchChain() + // Precondition: getStatus().connected === true + // Precondition: chainId is in supportedChains + // Postcondition: chainId is included in getStatus().connectedChainIds + // Throws: WalletNotConnectedError if not connected + // Throws: ChainNotSupportedError if chainId not in supportedChains + // Throws: CapabilityNotSupportedError for wallets that can't switch (Keplr — already multi-chain) + // Note: for multi-chain wallets (Keplr), this may be a no-op if already connected to the chain + switchChain(chainId: string | number): Promise + + // --- Metadata --- + readonly metadata: WalletAdapterMetadata +} + +interface ConnectOptions { + chainId?: string | number // preferred chain to connect to +} + +interface WalletConnection { + accounts: string[] // all accounts returned by wallet (MetaMask can have multiple) + activeAccount: string // the primary/selected account + chainId?: string | number +} + +interface WalletStatus { + connected: boolean + activeAccount: string | null // the primary account, null if disconnected + connectedChainIds: (string | number)[] // EVM: single element. Keplr: multiple Cosmos chains. Empty if disconnected. + connecting: boolean +} + +interface SignMessageInput { + message: string | Uint8Array +} + +interface SignTypedDataInput { + // EIP-712 structure — EVM-specific. Other chains define their own typed data format. + domain: Record + types: Record + primaryType: string + message: Record +} + +interface SignatureResult { + signature: string + address: string // the account that signed + meta?: Record // chain-specific extras (publicKey for Sui/Aptos, chainId for EVM) +} + +// Opaque — produced by WalletAdapter, consumed by TransactionAdapter. +// For EVM: wagmi WalletClient. For SVM: @solana/wallet-adapter signer. Etc. +// TransactionAdapter MUST validate the signer type at execute() boundary with a type guard. +type ChainSigner = unknown + +interface WalletAdapterMetadata { + chainType: string + + // Capabilities — declare what this adapter supports. + // Consumers MUST check capabilities before calling optional methods. + capabilities: { + signTypedData: boolean // EIP-712 (EVM), ADR-036 (Cosmos). False for SVM, Sui, etc. + switchChain: boolean // true for EVM. false for multi-chain wallets (Keplr). + // Future: sessionKeys, batchTransactions, etc. + } + + // formatAddress() — display formatting (truncation, checksum, prefix display) + // Called by: hooks (useWallet returns formatted address), components, or consumers directly + // Uses: ChainDescriptor.addressConfig for format type, but applies chain-specific logic + // (e.g., EVM checksumming, Cosmos prefix display) + formatAddress(address: string): string + + availableWallets(): WalletInfo[] +} + +interface WalletInfo { + id: string // 'metamask', 'phantom', 'keplr', etc. + name: string // display name + icon?: string // URL or data URI + installed: boolean + installUrl?: string +} +``` + +#### Design decisions + +- **`WalletStatus` tracks multiple chains.** `connectedChainIds` is an array. EVM wallets (MetaMask) report one chain. Multi-chain wallets (Keplr) report all connected chains simultaneously. The `useWallet` hook uses `connectedChainIds.includes(targetChainId)` to determine `needsChainSwitch`. +- **`WalletConnection` returns multiple accounts.** wagmi's `connect()` returns `accounts[]`. MetaMask can have multiple selected. `activeAccount` is the primary; `accounts` is the full list. +- **`reconnect()` returns null instead of throwing.** Session restoration is best-effort — a missing session is not an error. Called on page load, fails silently if no session exists. +- **`signTypedData` is optional via capability.** EIP-712 is EVM-specific. Solana, Sui, and other chains have no equivalent. Adapters declare `capabilities.signTypedData: boolean`. Calling `signTypedData()` on an adapter that doesn't support it throws `CapabilityNotSupportedError`. +- **`switchChain` is on the adapter**, not just the hook. For EVM it wraps wagmi's `switchChain`. For multi-chain wallets (Keplr), it's a no-op if already connected to the target chain. Adapters that can't switch declare `capabilities.switchChain: false`. +- **`ChainSigner` is opaque, but validated.** The wallet adapter produces it, the transaction adapter consumes it. The transaction adapter MUST validate the signer with a type guard at the `execute()` boundary — a mismatched signer throws `InvalidSignerError` with a clear message, not a cryptic `writeContract is not a function`. +- **`WalletStatus` is intentionally minimal.** No balance, no ENS, no avatar. Those are data/presentation concerns that belong in the data layer or style package. +- **`formatAddress()` caller is explicit.** Hooks call it for display; components call it for rendering. Consumers can call it directly. It uses `ChainDescriptor.addressConfig` to determine the format type but applies chain-specific logic (EVM checksumming, Cosmos prefix, truncation). + +### TransactionAdapter + +Owns the four-phase transaction lifecycle for a chain type. Optional — not needed for auth-only or read-only apps. + +```typescript +interface TransactionAdapter { + // --- Invariants --- + // chainType never changes after construction + // supportedChains never changes after construction + // Every entry in supportedChains has chainType matching this.chainType + readonly chainType: TChainType + readonly supportedChains: ChainDescriptor[] + + // prepare() + // Precondition: params.chainId is in supportedChains + // Postcondition: if ready === true → execute() can be called with these params + // Postcondition: if ready === false → reason explains why (human-readable) + // Postcondition: preSteps (if any) are CONSUMER-PROVIDED, not auto-detected + // Note: prepare() does NOT auto-detect ERC-20 approvals. Consumers provide + // preSteps explicitly, or use the optional approval hint on EVM payloads. + // Throws: ChainNotSupportedError if chainId not in supportedChains + prepare(params: TransactionParams): Promise + + // execute() + // Precondition: signer is a valid ChainSigner for this adapter's chainType + // Precondition: params.chainId is in supportedChains + // Postcondition: returns TransactionRef with a unique id + // Postcondition: the transaction has been submitted to the network (not yet confirmed) + // Throws: InvalidSignerError if signer type doesn't match (validated via type guard) + // Throws: SigningRejectedError if user cancels + // Throws: InsufficientFundsError if balance too low + execute(params: TransactionParams, signer: ChainSigner): Promise + + // confirm() + // Precondition: ref was returned by a previous execute() call on this adapter + // Postcondition: result.status is 'success', 'reverted', or 'timeout' + // Postcondition: if 'success' → result.receipt contains chain-specific receipt data + // Note: blocks until finality or timeout. Timeout is configurable. + // Throws: never (timeout returns TransactionResult with status: 'timeout') + confirm(ref: TransactionRef, options?: ConfirmOptions): Promise + + // --- Metadata --- + readonly metadata: TransactionAdapterMetadata +} + +interface TransactionParams { + chainId: string | number + payload: unknown // chain-specific — typed by the concrete adapter implementation + // Consumer-provided pre-steps (e.g., token approval before swap) + preSteps?: PreStep[] +} + +interface PrepareResult { + ready: boolean + reason?: string // 'Insufficient balance', 'Wrong network', etc. + estimatedFee?: { + amount: string + symbol: string + decimals: number + } +} + +interface PreStep { + label: string + params: TransactionParams +} + +interface TransactionRef { + chainType: string + id: string // tx hash on EVM, signature on SVM — opaque to consumers + chainId: string | number +} + +interface ConfirmOptions { + confirmations?: number + timeout?: number // ms +} + +interface TransactionResult { + status: 'success' | 'reverted' | 'timeout' + ref: TransactionRef + receipt: unknown // chain-specific — typed by the concrete adapter implementation +} + +interface TransactionAdapterMetadata { + chainType: string + feeModel: string // 'eip1559' | 'legacy' | 'priority-fee' | 'compute-units' | ... + confirmationModel: string // 'block-confirmations' | 'slot-finality' | ... +} +``` + +#### Design decisions + +- **PreSteps are consumer-provided, not auto-detected.** The adapter cannot generically detect that a contract call needs a token approval — that requires business-domain knowledge (which token, which spender, what amount). Consumers provide `preSteps` in `TransactionParams`. For EVM, an optional `approval` hint on the payload enables consumer-guided detection (see Section 7). +- **`preSteps` moved from `PrepareResult` to `TransactionParams`.** Consumers declare prerequisites up front. `prepare()` validates them (e.g., checks if the approval is already sufficient) and reports readiness. This makes the contract explicit: the consumer knows what steps are needed, the adapter validates and executes them. +- **`payload` is opaque (`unknown`).** Type safety comes from the concrete adapter, not the interface. +- **`receipt` is opaque.** EVM returns a `TransactionReceipt` (viem), SVM returns slot data, Cosmos returns `DeliverTxResponse`. +- **`confirm()` never throws.** Timeouts and reverts are expected outcomes, not exceptions. They return as `TransactionResult.status`. +- **`execute()` validates its signer.** Each adapter uses a type guard at the `execute()` boundary. Passing an SVM signer to an EVM adapter throws `InvalidSignerError` with a clear message. +- **`supportedChains` on both adapters** allows independent chain support. A wallet adapter might support 10 EVM chains while the transaction adapter supports only 3 (the ones with deployed contracts). +- **`supportedChains` must be consistent with the adapter's `chainType`.** If a wallet adapter declares `chainType: 'evm'`, every entry in its `supportedChains` must have `chainType: 'evm'`. The provider validates this at initialization. + +### ReadClientFactory + +Creates public (read-only) clients for chains without wallet or transaction adapters. Used by `useReadOnly` for the zero-adapter, read-only use case (portfolio trackers, data dashboards). + +```typescript +interface ReadClientFactory { + // Invariant: chainType never changes after construction + readonly chainType: string + + // createClient() + // Precondition: endpoint URL is reachable + // Postcondition: returns a public client capable of read-only chain queries + // Note: client type is chain-specific (viem PublicClient for EVM, Connection for SVM) + createClient(endpoint: EndpointConfig, chainId: string | number): unknown +} +``` + +The SDK ships `evmReadClientFactory` (wraps viem's `createPublicClient`). Other factories ship with their adapter packages. When a wallet or transaction adapter is registered, its read-client factory is included automatically. + +--- + +## 3. Lifecycle Hooks + +Intervention points for cross-cutting concerns: notifications, analytics, logging, monitoring. Two scopes, two interfaces. + +### TransactionLifecycle + +Hooks into the transaction four-phase cycle. + +```typescript +interface TransactionLifecycle { + onPrepare?: (result: PrepareResult) => void + onPreStep?: (step: PreStep, index: number) => void + onPreStepComplete?: (step: PreStep, index: number, result: TransactionResult) => void + onSubmit?: (ref: TransactionRef) => void + onConfirm?: (result: TransactionResult) => void + onError?: (phase: TransactionPhase, error: Error) => void + onReplace?: (oldRef: TransactionRef, newRef: TransactionRef, reason: string) => void +} + +type TransactionPhase = 'prepare' | 'preStep' | 'submit' | 'confirm' +``` + +### WalletLifecycle + +Hooks into wallet signing activity. + +```typescript +interface WalletLifecycle { + onSign?: (type: 'message' | 'typedData', input: SignMessageInput | SignTypedDataInput) => void + onSignComplete?: (result: SignatureResult) => void + onSignError?: (error: Error) => void +} +``` + +### Two scopes of lifecycle hooks + +**Global hooks** — registered in the provider config. Applied to every transaction and every signing operation. This is where the notification system, analytics, and logging live: + +```typescript +// Notification system as a global lifecycle hook — created via factory with injected toaster +const notificationLifecycle = createNotificationLifecycle({ toaster }) + +// Factory implementation (shipped by @dappbooster/react): +interface ToasterAPI { + create(options: { id?: string; title: string; description?: string; type?: string }): void + update(id: string, options: { title: string; description?: string; type?: string }): void + dismiss(id: string): void +} + +function createNotificationLifecycle(options: { + toaster: ToasterAPI + messages?: { + submitted?: string + confirmed?: string + failed?: string + error?: (phase: string, error: Error) => string + } +}): TransactionLifecycle { + return { + onSubmit: (ref) => + toaster.create({ id: ref.id, title: messages.submitted ?? 'Transaction submitted' }), + onConfirm: (result) => + toaster.update(result.ref.id, { + title: result.status === 'success' + ? (messages.confirmed ?? 'Transaction confirmed') + : (messages.failed ?? 'Transaction failed'), + }), + onError: (phase, error) => + toaster.create({ title: messages.error?.(phase, error) ?? error.message, type: 'error' }), + // Phase 3: onReplace — EVM-specific: fires when tx is sped up or cancelled (same nonce, higher gas). + // Not yet dispatched by useTransaction. When implemented, non-EVM adapters never fire this. + } +} + +const walletNotifications: WalletLifecycle = { + onSign: (type) => showToast('Signature requested...'), + onSignComplete: () => dismissToast(), + onSignError: (error) => showToast(formatError(error)), +} +``` + +**Per-operation hooks** — passed by the consumer to `useTransaction()`. Applied to a single transaction: + +```typescript +const tx = useTransaction({ + params: swapParams, + lifecycle: { + onConfirm: (result) => invalidateBalanceQueries(), + onPreStep: (step) => showApprovalModal(step), + }, +}) +``` + +Note: `WalletLifecycle` hooks are global-only (registered in provider config). There is no per-operation wallet lifecycle on `useWallet()` — signing operations fire the global wallet lifecycle hooks. This is intentional: signing is atomic (no multi-phase flow), so per-operation hooks add no value beyond what the `signMessage()`/`signTypedData()` Promise resolution already provides. + +### Merge behavior + +Both scopes always fire for `TransactionLifecycle`. Global first, then per-operation. Neither swallows the other. An error thrown in a lifecycle hook is caught and logged — it never aborts the transaction. Lifecycle hooks are observers, not interceptors. + +### Naming: `onSubmit` vs `onSign` + +`TransactionLifecycle.onSubmit` fires when a transaction is being submitted to the chain. `WalletLifecycle.onSign` fires when a message or typed data is being signed. These are distinct operations: + +- Submitting a transaction involves signing, but also broadcasting to the network and waiting for inclusion. +- Signing a message is a local operation — no network submission, no confirmation. + +The naming makes the distinction clear. The notification system hooks into both: `onSubmit` for "Transaction sent...", `onSign` for "Signature requested...". + +### How lifecycle hooks map to the current codebase + +| Current code | Becomes | +|---|---| +| `TransactionNotificationProvider.watchTx()` | Global `onSign` (wallet lifecycle) + `onSubmit` (transaction lifecycle) | +| `TransactionNotificationProvider.watchHash()` | Global `onSubmit` + `onConfirm` + `onReplace` | +| `TransactionNotificationProvider.watchSignature()` | `WalletAdapter.signMessage()` + global `onSign` (wallet lifecycle) | +| `TransactionButton.onMined(receipt)` | Per-operation `onConfirm` | +| `useWaitForTransactionReceipt` | Inside `TransactionAdapter.confirm()` | + +--- + +## 4. Provider Architecture + +The provider holds the adapter registry, builds the chain resolution map, and exposes context to hooks. It's the single configuration point — the architect sets it up, developers and agents consume it through hooks. + +### DAppBoosterConfig + +```typescript +interface DAppBoosterConfig { + // Wallet adapter bundles, keyed by chain type. + // Each bundle includes the adapter AND any required React providers (e.g., WagmiProvider). + wallets?: Record + + // Transaction adapters, keyed by chain type (optional) + transactions?: Record + + // Additional chain descriptors (for read-only use cases with no adapters) + chains?: ChainDescriptor[] + + // Read client factories (only needed for read-only use cases with no adapters) + readClientFactories?: ReadClientFactory[] + + // Global lifecycle hooks + lifecycle?: TransactionLifecycle + walletLifecycle?: WalletLifecycle +} + +// Adapter factories return bundles — the adapter plus any React infrastructure it needs. +// DAppBoosterProvider composes the Provider components from all bundles internally. +interface WalletAdapterBundle { + adapter: WalletAdapter + // React provider required by this adapter (e.g., WagmiProvider + QueryClientProvider + ConnectKitProvider). + // Omit for non-React adapters (server wallets, CLI). + Provider?: FC<{ children: ReactNode }> + // Hook to open the connector's connect/account modal. + // Called via a bridge component inside the bundle's Provider tree. + // The resulting `open` function is stored per adapter key in the context ref. + useConnectModal?: () => { open: () => void } +} +``` + +The `WalletAdapterBundle` solves the React provider wrapping problem: EVM adapters need WagmiProvider, QueryClientProvider, and ConnectKitProvider in the React tree. The adapter factory returns these as a composed `Provider` component. `DAppBoosterProvider` nests all bundle Providers internally — the consumer sees one provider. + +```typescript +// createEvmWalletAdapter returns a bundle +const evmBundle = createEvmWalletAdapter({ + chains: [mainnet, optimism], + connector: connectkitConnector, +}) +// evmBundle.adapter → WalletAdapter methods +// evmBundle.Provider → WagmiProvider + QueryClientProvider + ConnectKitProvider (composed) + +// Server wallets have no Provider +const serverBundle = createEvmServerWallet({ privateKey }) +// serverBundle.adapter → WalletAdapter methods +// serverBundle.Provider → undefined +``` +``` + +### Chain resolution + +The provider builds a `ChainRegistry` automatically from three sources, merged in this order: + +1. `config.chains` — explicit chain descriptors (read-only use cases) +2. `config.wallets[*].supportedChains` — chains from wallet adapters +3. `config.transactions[*].supportedChains` — chains from transaction adapters + +Duplicate chainIds across sources are allowed **only if they resolve to the same chainType**. If two adapters claim the same chainId with different chainTypes, the provider throws at initialization. + +### Registration examples + +**Minimal EVM dApp:** + +```tsx +import { createEvmWalletAdapter, createEvmTransactionAdapter } from '@dappbooster/core' +import { connectkitConnector } from '@dappbooster/core/evm/connectors' +import { DAppBoosterProvider } from '@dappbooster/react' +import { mainnet, optimism } from 'viem/chains' + + + + +``` + +**Multi-chain bridge:** + +```tsx + +``` + +**Auth-only (portal-earn pattern):** + +```tsx + +``` + +**Read-only portfolio tracker (no adapters):** + +```tsx +import { evmChains } from '@dappbooster/core/chains' + + +``` + +### What the provider does internally + +1. Merges chain descriptors from all sources into a `ChainRegistry` +2. Validates no chainId conflicts across different chain types +3. Stores adapter references in React context +4. Subscribes to `onStatusChange` for each wallet adapter, syncs to React state +5. Exposes resolution functions to hooks: `getWalletAdapter(chainType)`, `getTransactionAdapter(chainType)`, `getChainRegistry()` + +### What the provider replaces + +| Current | Becomes | +|---|---| +| `Web3Provider` (WagmiProvider + QueryClient + WalletProvider) | `DAppBoosterProvider` — wallet adapter wraps wagmi internally | +| `TransactionNotificationProvider` | Global lifecycle hooks in provider config | +| `ConnectWalletButton` re-export from Web3Provider | Wallet adapter's connect method + style package component | +| Hardcoded connectkit import in Web3Provider | Connector config passed to adapter factory | + +Current provider stack in `__root.tsx`: + +``` +ChakraProvider → Web3Provider → TransactionNotificationProvider → App +``` + +Becomes: + +``` +ChakraProvider → DAppBoosterProvider → App +``` + +Chakra stays outside — theming is a template concern. `DAppBoosterProvider` is pure context, renders no UI. + +--- + +## 5. Hook Layer + +Hooks are the primary consumer API in React apps. They resolve adapters from provider context, manage React state, and expose escape hatches for full control. + +### useWallet + +Replaces `useWeb3Status` + `useWalletStatus` with chain-type-aware resolution. + +```typescript +function useWallet(options?: UseWalletOptions): UseWalletReturn + +type UseWalletOptions = + | { chainId: string | number } + | { chainType: string } + | { adapter: WalletAdapter } // explicit — bypass provider +``` + +**Default behavior when no options provided:** +- If exactly one wallet adapter is registered → uses that adapter (single-chain app convenience) +- If multiple wallet adapters are registered → **throws `AmbiguousAdapterError`** with a message listing the available chain types and instructing the consumer to specify one + +This is deterministic: single-adapter apps work without options, multi-adapter apps must be explicit. An agent always knows which code path it's on. + +```typescript +interface UseWalletReturn { + // State (reactive — triggers re-render on change) + status: WalletStatus + isReady: boolean // connected && targetChainId in connectedChainIds + needsConnect: boolean // !connected && !connecting + needsChainSwitch: boolean // connected but targetChainId not in connectedChainIds + + // Actions (signMessage/signTypedData fire walletLifecycle hooks from provider) + connect(options?: ConnectOptions): Promise + disconnect(): Promise + signMessage(input: SignMessageInput): Promise + signTypedData?(input: SignTypedDataInput): Promise // undefined if capability not supported; throws CapabilityNotSupportedError if called via signTypedDataImpl + getSigner(): Promise + switchChain(chainId: string | number): Promise + + // Modal + openConnectModal(): void // opens the correct connector's modal for this adapter (no-op if none registered) + + // Resolution info + adapterKey: string | null // the key under which this adapter was registered in DAppBoosterConfig.wallets + + // Escape hatch — raw adapter (access reconnect(), metadata, etc.) + adapter: WalletAdapter +} +``` + +Resolution: `{ chainId }` → provider resolves chainType via `ChainRegistry` → looks up wallet adapter. `{ chainType }` → direct lookup. `{ adapter }` → uses adapter directly, no provider. When no adapter matches the requested chain, throws `AdapterNotFoundError` (not `AmbiguousAdapterError`). + +### useTransaction + +Replaces the inline wagmi calls inside TransactionButton. + +```typescript +function useTransaction(options?: UseTransactionOptions): UseTransactionReturn + +interface UseTransactionOptions { + lifecycle?: TransactionLifecycle // per-transaction hooks + autoPreSteps?: boolean // default: true — auto-execute preSteps before main tx + confirmOptions?: ConfirmOptions // forwarded to adapter.confirm() +} +``` + +**`params` are passed to `execute()` at call time, not at hook init.** This means one `useTransaction()` instance can be reused for different transactions. The hook manages phase/state/lifecycle; the params drive each execution. + +**`chainId` lives in `params` only — no duplication.** The `execute(params)` call resolves the adapter from `params.chainId`. One source of truth, zero ambiguity. If no adapter supports `params.chainId`, throws `AdapterNotFoundError`. + +```typescript +type TransactionExecutionPhase = 'idle' | 'prepare' | 'preStep' | 'submit' | 'confirm' + +interface UseTransactionReturn { + // State (reactive) + phase: TransactionExecutionPhase + prepareResult: PrepareResult | null + ref: TransactionRef | null + result: TransactionResult | null + preStepResults: TransactionResult[] + error: Error | null + + // Metadata + explorerUrl: string | null // resolved from ChainRegistry using ref.id + + // Main execution — runs the full cycle: prepare → preSteps → submit → confirm + // Precondition: if autoPreSteps === false and preSteps exist → throws PreStepsNotExecutedError + execute(params: TransactionParams): Promise + + // Reset all state back to idle + reset(): void +} +``` + +**PreStep execution.** When `autoPreSteps: true` (default), `execute()` runs all preSteps sequentially before the main transaction. When `autoPreSteps: false`, `execute()` throws `PreStepsNotExecutedError` if preSteps exist. + +> **Phase 3:** Manual pre-step control — `executePreStep(index)`, `executeAllPreSteps()`, and standalone `prepare()` — to support per-step approval UX (show each approval, let user confirm). Not yet implemented. + +Internal flow of `execute(params)`: + +1. Resolves `TransactionAdapter` from provider via `params.chainId` — throws `AdapterNotFoundError` if not found +2. Resolves `WalletAdapter` for the same chain — throws `AdapterNotFoundError` if not found +3. Gets signer via `walletAdapter.getSigner()` — throws `WalletNotConnectedError` if null +4. Calls `adapter.prepare(params)` → fires `lifecycle.onPrepare` +5. If `autoPreSteps === true` and preSteps exist → executes each through full cycle, fires `lifecycle.onPreStep` / `lifecycle.onPreStepComplete` +6. If `autoPreSteps === false` and preSteps exist → throws `PreStepsNotExecutedError` +7. Calls `adapter.execute(params, signer)` → fires `lifecycle.onSubmit` +8. Calls `adapter.confirm(ref, confirmOptions)` → fires `lifecycle.onConfirm` +9. On error at any phase → fires `lifecycle.onError` with phase identifier +10. All lifecycle hooks: global (from provider) fires first, per-transaction (from options) fires second. Hook errors are logged but never abort the transaction. + +### useMultiWallet + +For apps needing multiple simultaneous connections (bridge, portfolio). + +```typescript +function useMultiWallet(): UseMultiWalletReturn + +// Returns a Record keyed by adapter name from DAppBoosterConfig.wallets. +// Each entry includes wallet lifecycle hook dispatch (signMessage/signTypedData fire walletLifecycle) +// and openConnectModal resolved to the correct adapter. +type UseMultiWalletReturn = Record +``` + +> **Phase 3:** Convenience methods — `getWallet(chainType)`, `getWalletByChainId(chainId)`, and aggregated `connectedAddresses` summary. Currently consumers iterate the record directly. + +### useReadOnly + +For data fetching without wallet connection — arbitrary addresses, no signing. + +```typescript +function useReadOnly(options: UseReadOnlyOptions): UseReadOnlyReturn + +interface UseReadOnlyOptions { + chainId: string | number +} + +interface UseReadOnlyReturn { + chain: ChainDescriptor | null + client: unknown // chain-specific read client (e.g. viem PublicClient for EVM) +} +``` + +Creating a public client requires knowing the chain type (EVM uses viem's `createPublicClient`, SVM uses `@solana/web3.js Connection`). The hook resolves this through a `ReadClientFactory` — a lightweight registry of "given a chain type + endpoint, create a read client." + +```typescript +interface ReadClientFactory { + readonly chainType: string + createClient(endpoint: EndpointConfig, chainId: string | number): unknown +} +``` + +The SDK ships `evmReadClientFactory` (wraps viem). Other factories ship with their adapter packages. Factories are registered in the provider config: + +```typescript + +``` + +> **Phase 3:** Auto-contribute read factories from registered adapters (zero-config for apps that already have wallet/transaction adapters). Add optional `address` param and `explorerAddressUrl` to the return. Currently `readClientFactories` must be explicitly provided. + +### useChainRegistry + +Access to chain metadata without any adapter. + +```typescript +function useChainRegistry(): ChainRegistry +``` + +Returns the registry built by the provider. Useful for components that need chain metadata (explorer links, chain selectors) without wallet or transaction context. + +### Hook mapping from current codebase + +| Current hook | Replacement | +|---|---| +| `useWeb3Status()` | `useWallet({ chainType: 'evm' })` | +| `useWalletStatus({ chainId })` | `useWallet({ chainId })` — same `isReady`/`needsConnect`/`needsChainSwitch` | +| `useWeb3StatusConnected()` | `useWallet()` inside a `WalletGuard` — guard guarantees `status.connected === true`, no special hook needed | +| `useWaitForTransactionReceipt` | Inside `useTransaction()` — consumers never call directly | +| `useSignMessage` | `useWallet().signMessage()` | +| `useTransactionNotification` | Global lifecycle hooks in provider — no explicit hook needed | + +### Escape hatch progression + +1. Use `` (style package) — zero boilerplate +2. Use `useTransaction()` (react) — control UI, SDK handles lifecycle +3. Use `useTransaction().adapter` — raw adapter for one-off customization +4. Pass explicit `adapter` prop — bypass provider entirely +5. Use `@dappbooster/core` directly — no React, no provider, no hooks + +Each level peels back one layer. Agents default to level 1. Experienced devs go to level 2. Edge cases go deeper. + +--- + +## 6. Component Layer (Style Packages) + +Components live in style packages (`@dappbooster/chakra`, future `@dappbooster/tailwind`, etc.). They are thin wrappers around hooks — typically 20-40 lines each. The hook does the work, the component does the rendering. + +### Why style packages are separate + +The SDK's logic has zero UI dependencies. A consumer using Tailwind doesn't install Chakra. A consumer using Vue doesn't install React. The headless core stands alone. + +A Chakra `TransactionButton` and a Tailwind `TransactionButton` call the same `useTransaction()` hook. The only difference is the markup. Building a new style package means writing thin wrappers, not reimplementing logic. + +### TransactionButton + +```typescript +interface TransactionButtonProps { + // Transaction configuration — chainId is inside params (single source of truth) + params: TransactionParams + lifecycle?: TransactionLifecycle + autoPreSteps?: boolean // default: false + confirmations?: number + connectFallback?: ReactElement + switchChainFallback?: ReactElement + label?: string + labelSigning?: string + labelConfirming?: string + children?: ReactNode + // + style library props (Chakra ButtonProps, etc.) +} +``` + +This is a **new API** — there is no backwards-compatible `transaction: () => Promise` prop. The adapter architecture is a new major version. Consumers migrating from the current codebase adopt the new props; see Section 12 (Migration Path) for the phased approach. + +Internal structure (Chakra example, ~30 lines): + +```tsx +function TransactionButton({ params, lifecycle, label, ...chakraProps }) { + const wallet = useWallet({ chainId: params.chainId }) + const tx = useTransaction({ params, lifecycle }) + + if (wallet.needsConnect) { + return + } + if (wallet.needsChainSwitch) { + return ( + + ) + } + + return ( + + ) +} +``` + +### SignButton + +```typescript +interface SignButtonProps { + chainId?: string | number + message: string | Uint8Array + lifecycle?: WalletLifecycle + connectFallback?: ReactElement + switchChainFallback?: ReactElement + label?: string + labelSigning?: string + children?: ReactNode +} +``` + +Same wallet gating logic as TransactionButton. Calls `useWallet().signMessage()` on click. No transaction adapter needed. + +### WalletGuard + +Gates children on wallet connection requirements. Lives in the style package because the fallback rendering is a styling concern. + +```typescript +interface WalletGuardProps { + chainId?: string | number + chainType?: string + fallback?: ReactElement // defaults to + switchChainLabel?: string // defaults to 'Switch to' + children?: ReactNode +} +``` + +Usage: + +```tsx +// Gate on specific chain + + + + +// Gate on chain type (auth-only / multi-platform signing) + + + +``` + +The guard's job is binary: wallet connected (and on correct chain if `chainId` provided)? Yes → render children. No → render fallback. The default fallback renders a `ConnectWalletButton` scoped to the same chain, or a `SwitchChainButton` when connected but on the wrong chain. + +> **Phase 3:** Multi-chain gating — `require: WalletRequirement[]` for bridge-style UX requiring multiple simultaneous wallet connections. Currently single-chain only; bridges compose two `useWallet` calls in consumer-land. + +A consumer not using a style package builds the same guard in ~5 lines with hooks: + +```tsx +function MyGuard({ children }) { + const { needsConnect, connect } = useWallet({ chainId: 1 }) + if (needsConnect) return + return children +} +``` + +### ConnectWalletButton + +```typescript +// Accepts all UseWalletOptions (chainId, chainType, adapter) for adapter resolution +interface ConnectWalletButtonProps extends UseWalletOptions { + label?: string // defaults to 'Connect' +} +``` + +Resolves the wallet adapter via `useWallet(options)` and calls `openConnectModal()` to open the adapter-specific connector modal. Displays the truncated address when connected. In multi-wallet setups, pass `chainType` or `chainId` to target a specific adapter's modal. + +The component does NOT depend on wagmi or any chain-specific import — it uses `useWallet().status` for connection state and `useWallet().openConnectModal` for the modal trigger, making it fully adapter-agnostic. + +### ExplorerLink + +Chain-agnostic — resolved from the chain registry, not from any adapter. + +```typescript +interface ExplorerLinkProps { + chainId: string | number + tx?: string // chain-agnostic: hash, signature, digest, version number + address?: string + block?: string | number + truncate?: boolean + children?: ReactNode +} +``` + +Internally calls `getExplorerUrl()` from the chain registry. Works for any chain with an `ExplorerConfig` in its descriptor — whether it came from an adapter's `supportedChains` or from explicit `chains` config. + +### SwitchChain + +Chain selector dropdown. Shows chains from all registered adapters. + +```typescript +interface SwitchChainProps { + chainType?: string // filter to one chain type, or show all + onChange?: (chainId: string | number) => void +} +``` + +### Style package component summary + +| Component | Hook(s) used | Purpose | +|---|---|---| +| `TransactionButton` | `useWallet` + `useTransaction` | One-click transaction with wallet gating | +| `SignButton` | `useWallet` | Message signing with wallet gating | +| `WalletGuard` | `useWallet` / `useMultiWallet` | Gate children on wallet requirements | +| `ConnectWalletButton` | `useWallet` | Trigger wallet connection | +| `ExplorerLink` | `useChainRegistry` | Chain-aware explorer links | +| `SwitchChain` | `useWallet` + `useChainRegistry` | Chain selector | +| `NotificationToaster` | (lifecycle hooks) | Global transaction/signing notifications | + +--- + +## 7. EVM Adapter Implementation + +The only adapter the SDK ships at launch. It wraps the existing dAppBooster code — no new EVM logic, just formalization behind the adapter interfaces. + +### EvmWalletAdapter + +```typescript +function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBundle + +interface EvmWalletConfig { + chains: Chain[] // from viem/chains + transports?: Record + connector: EvmConnectorConfig +} +``` + +The factory returns a `WalletAdapterBundle` (adapter + Provider). The Provider composes WagmiProvider + QueryClientProvider + the connector's WalletProvider. This is how DAppBoosterProvider gets wagmi into the React tree without the consumer seeing the nesting. + +**EvmConnectorConfig is split between core and react:** + +```typescript +// In @dappbooster/core/evm — framework-agnostic part +interface EvmCoreConnectorConfig { + createConfig: (chains: Chain[], transports: Record) => WagmiConfig +} + +// In @dappbooster/react/evm — React-specific part (returned by connector subpath exports) +interface EvmConnectorConfig extends EvmCoreConnectorConfig { + WalletProvider: FC<{ children: ReactNode }> // ConnectKitProvider, RainbowKitProvider, etc. + useConnectModal: () => { open: () => void } // hook to open the connector's connect/account modal +} +``` + +Non-React consumers (CLI, agents) use `createEvmServerWallet()` which needs only `EvmCoreConnectorConfig` — no React types, no Provider component. + +Internal mapping (uses `@wagmi/core` actions, NOT React hooks — framework-agnostic): + +| WalletAdapter method | EVM implementation | +|---|---| +| `connect()` | `@wagmi/core` `connect()` action + connector modal subscription | +| `reconnect()` | `@wagmi/core` `reconnect()` action | +| `disconnect()` | `@wagmi/core` `disconnect()` action | +| `getStatus()` | `@wagmi/core` `getAccount()` → maps to `WalletStatus` | +| `onStatusChange()` | `@wagmi/core` `watchAccount()` + `watchChainId()` | +| `signMessage()` | `@wagmi/core` `signMessage()` action | +| `signTypedData()` | `@wagmi/core` `signTypedData()` action | +| `getSigner()` | Returns wagmi `WalletClient` via `getWalletClient()` | +| `switchChain()` | `@wagmi/core` `switchChain()` action | +| `supportedChains` | Built from `config.chains` via `fromViemChain()` | +| `metadata.capabilities` | `{ signTypedData: true, switchChain: true }` | +| `metadata.availableWallets()` | From connector's wallet discovery | + +The three existing connector configs (`connectkit.config.tsx`, `rainbowkit.config.tsx`, `reown.config.tsx`) become `EvmConnectorConfig` implementations. + +Connector adapters live as subpath exports of `@dappbooster/core/evm/connectors` (core config) and `@dappbooster/react/evm/connectors` (React Provider/Button). They are EVM wallet connection logic, not styling concerns — a Tailwind app uses the same ConnectKit connector as a Chakra app. + +### EvmTransactionAdapter + +```typescript +function createEvmTransactionAdapter(config?: EvmTransactionConfig): TransactionAdapter<'evm'> + +interface EvmTransactionConfig { + defaultConfirmations?: number // default: 1 +} +``` + +EVM-specific transaction payload (what consumers pass as `TransactionParams.payload`). This is a discriminated union — you either send a raw transaction or call a contract, never both: + +```typescript +type EvmTransactionPayload = EvmRawTransaction | EvmContractCall + +interface EvmRawTransaction { + to: Address + data?: Hex + value?: bigint + // Gas overrides (optional — adapter estimates if omitted) + gas?: bigint + maxFeePerGas?: bigint + maxPriorityFeePerGas?: bigint +} + +interface EvmContractCall { + contract: { + address: Address + abi: Abi + functionName: string + args?: unknown[] + } + value?: bigint + // Gas overrides (optional — adapter estimates if omitted) + gas?: bigint + maxFeePerGas?: bigint + maxPriorityFeePerGas?: bigint +} +``` + +The adapter detects which variant by checking for the presence of `contract` vs `to`. + +Four-phase mapping: + +| Phase | EVM implementation | +|---|---| +| `prepare()` | Validates signer exists. Estimates gas via `publicClient.estimateGas()`. Checks balance sufficiency. Validates consumer-provided preSteps (e.g., checks if approval allowance is already sufficient and removes unnecessary preSteps). | +| `execute()` | Calls `walletClient.sendTransaction()` for raw transactions or `walletClient.writeContract()` for contract calls. Returns `TransactionRef` with tx hash. | +| `confirm()` | Wraps viem's `publicClient.waitForTransactionReceipt()` with replacement detection (`onReplaced` callback). Fires `onReplace` lifecycle hook if tx is sped up or cancelled. | +| Return | `TransactionResult` with viem `TransactionReceipt` as `receipt`. | + +### Generated hooks coexistence + +`pnpm wagmi-generate` (to be renamed `pnpm codegen`) still produces typed hooks for specific contracts (`useReadWethAllowance`, `useWriteWethApprove`, etc.). These are EVM-specific convenience hooks with full type safety. + +They coexist with the adapter. A developer building an EVM-only app may prefer generated hooks for their type safety and skip the adapter for common contract interactions. The adapter is for the generic, chain-agnostic path. + +### EVM PreStep helpers + +The SDK ships convenience functions for common PreStep patterns. These are the **only way** to build PreSteps for EVM transactions — agents and developers use these, not manual PreStep construction. + +```typescript +import { + createApprovalPreStep, + createPermitPreStep, +} from '@dappbooster/core/evm' + +// ERC-20 approval before a swap/transfer/deposit +const approvalStep = createApprovalPreStep({ + token: usdcAddress, // ERC-20 token to approve + spender: routerAddress, // contract that will spend the token + amount: parseUnits('1000', 6), +}) +// Returns: PreStep { label: 'Approve USDC', params: { chainId, payload: { contract: approve call } } } +// The EVM adapter's prepare() checks current allowance — if already sufficient, marks preStep as skippable. + +// EIP-2612 permit (gasless approval via signature) +const permitStep = createPermitPreStep({ + token: usdcAddress, + spender: routerAddress, + amount: parseUnits('1000', 6), + deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), +}) +// Returns: PreStep that uses signTypedData instead of a transaction + +// Usage with useTransaction: +const tx = useTransaction({ lifecycle: { onConfirm: () => invalidateQueries() } }) + +// autoPreSteps defaults to true — approval runs automatically before the swap: +await tx.execute({ + chainId: 1, + payload: swapPayload, + preSteps: [approvalStep], +}) +``` + +> **Phase 3:** Manual pre-step control via `executePreStep(index)` for per-step approval UX. + +Additional helpers to be added as common patterns emerge: +- `createWrapEthPreStep()` — wrap ETH → WETH before operations requiring ERC-20 +- `createUnwrapEthPreStep()` — unwrap WETH → ETH after operations + +### Adapter wrapping utility + +The SDK provides `wrapAdapter()` for composing adapters — adding observation hooks around every method call without implementing a full adapter from scratch. This is the formal mechanism for patterns like logging, analytics, and error monitoring. + +```typescript +import { wrapAdapter } from '@dappbooster/core' + +// wrapAdapter() +// Precondition: adapter is any object with function methods +// Postcondition: returns a new object with identical interface that delegates to the original +// Contract: hooks are fire-and-forget observers — they cannot transform data or abort calls +// Contract: hook errors are caught and silently ignored to avoid aborting adapter calls +function wrapAdapter( + adapter: T, + hooks: { + onBefore?(method: string, args: unknown[]): void + onAfter?(method: string, result: unknown): void + onError?(method: string, error: Error): void + }, +): T + +// Example: logging middleware +const loggingAdapter = wrapAdapter(evmTransactionAdapter, { + onBefore: (method, args) => console.log(`[${method}] called with`, args), + onAfter: (method, result) => console.log(`[${method}] returned`, result), + onError: (method, error) => console.error(`[${method}] threw`, error), +}) + +// Example: analytics +const trackedAdapter = wrapAdapter(evmWalletAdapter, { + onAfter: (method, result) => { + if (method === 'connect') analytics.track('wallet_connected', result) + if (method === 'signMessage') analytics.track('message_signed') + }, +}) +``` + +`wrapAdapter` works on any object — wallet adapters, transaction adapters, or any other interface. It wraps inherited methods via prototype traversal and preserves synchronous vs asynchronous behavior. + +> **Phase 3:** Transforming hooks — `beforePrepare`, `afterExecute`, etc. — that can modify params and results for use cases like FHE encryption. Current hooks are observation-only. For transformation use cases today, consumers implement a custom adapter that delegates internally. + +### What doesn't change + +- wagmi is the EVM engine under the hood — the adapter wraps it, doesn't replace it +- viem types (`Address`, `Hash`, `Hex`, `Abi`) are used inside the EVM adapter +- The wagmi-cli codegen still works for EVM-specific type generation +- Existing EVM patterns (generated hooks, Suspense reads) continue to work + +### EVM library coupling (explicit constraint) + +The adapter interfaces (`WalletAdapter`, `TransactionAdapter`) are library-agnostic — they define contracts in terms of generic types (`ChainSigner`, `TransactionParams`, `TransactionRef`). However, the shipped EVM implementation is **structurally coupled to wagmi + viem** at three levels: + +1. **Connector system** — `EvmCoreConnectorConfig.createConfig()` returns wagmi's `Config` type. `EvmConnectorConfig.WalletProvider` wraps `WagmiProvider` + `QueryClientProvider`. All three shipped connectors (ConnectKit, RainbowKit, Reown) are wagmi-based. + +2. **Adapter internals** — `createEvmWalletAdapter` uses `@wagmi/core` actions (`connect`, `disconnect`, `signMessage`, `getWalletClient`, `watchAccount`, `switchChain`). `createEvmTransactionAdapter` uses viem's `PublicClient` and `WalletClient`. The `ChainSigner` opaque type is a viem `WalletClient` at runtime. + +3. **Generated contract hooks** — `pnpm wagmi-generate` produces hooks via `@wagmi/cli` that import a wagmi `Config` instance and use `wagmi/codegen` + `@tanstack/react-query`. These hooks are tightly bound to wagmi's query/cache infrastructure. + +**What this means for alternative libraries:** + +- **Replacing wagmi with ethers.js for EVM** is not a config swap. It requires a parallel `WalletAdapter<'evm'>` + `TransactionAdapter<'evm'>` implementation, a replacement for the connector system (no `WagmiProvider`), and either dropping generated hooks or building an ethers-based codegen. The practical investment is equivalent to writing a new chain adapter from scratch. + +- **Adding non-EVM chains** (SVM, Cosmos, etc.) is the designed extension point. Each chain type gets its own adapter implementations that use whatever library is native to that ecosystem (e.g., `@solana/web3.js` for SVM, `@cosmjs` for Cosmos). These coexist with the EVM adapter — the architecture supports multiple chain types, not multiple libraries for the same chain type. + +- **The adapter interface is the stable contract.** If a future EVM library emerges that's superior to wagmi/viem, the migration path is: implement new `WalletAdapter<'evm'>` + `TransactionAdapter<'evm'>`, register them in `DAppBoosterConfig`, and all hook/component consumers work unchanged. The interface boundary protects consumers from implementation churn. + +--- + +## 8. Codegen Generalization + +The current `pnpm wagmi-generate` is EVM-specific. As the SDK supports multiple chain types, codegen needs a generic entry point. + +### Generic script + +``` +pnpm codegen +``` + +Internally dispatches to the right generator based on configuration. + +### Output targets + +A critical distinction: **React hooks are only useful in React apps.** Agent scripts, CLI tools, relayers, and backend services need framework-agnostic typed clients. The codegen must support both: + +| Chain type | Generator | Input | React output | Core output (framework-agnostic) | +|---|---|---|---|---| +| EVM | wagmi-cli | Contract ABIs | Typed React hooks (`useReadWeth...`) | Typed actions (plain async functions via wagmi `actions` plugin) | +| SVM | Anchor / Codama / Kinobi | Program IDLs | — | Typed program clients (already framework-agnostic) | +| Sui | @mysten/sui + Move compiler | Move modules | — | Typed transaction builders (already framework-agnostic) | +| Aptos | @aptos-labs/ts-sdk + Move compiler | Move modules | — | Typed clients (already framework-agnostic) | +| Cosmos | Telescope | Protobuf definitions | — | TypeScript clients (already framework-agnostic) | + +Non-EVM generators already produce framework-agnostic output. The EVM case is the exception — wagmi-cli defaults to React hooks. The fix: EVM codegen generates **both** React hooks (for `@dappbooster/react` consumers) and typed actions (for `@dappbooster/core` consumers) via wagmi-cli's `actions` plugin. + +Additionally, viem provides typed contract interaction without any codegen: + +```typescript +// Framework-agnostic — works in CLI, agent, relayer, anywhere +import { getContract } from 'viem' +const contract = getContract({ address, abi, client: publicClient }) +await contract.read.balanceOf([address]) +await contract.write.transfer([to, amount]) +``` + +Codegen is a DX convenience for type safety, not a requirement. The adapter's `execute()` accepts raw payloads (ABI + function name + args) and works without generated code. + +### Configuration + +```typescript +// dappbooster.config.ts +export default { + codegen: { + evm: { + contracts: [...], + output: { + react: 'src/contracts/generated.hooks.ts', // React hooks (optional) + core: 'src/contracts/generated.ts', // Framework-agnostic actions + }, + }, + svm: { + programs: [...], + output: 'src/programs/generated.ts', + }, + }, +} +``` + +Only configured chain types trigger codegen. An EVM-only project runs `pnpm codegen` and only wagmi-cli executes. A Node.js agent project configures only `core` output — no React hooks generated. + +--- + +## 9. Agent Integration and Non-Browser Use Cases + +### @dappbooster/core as a blockchain runtime + +`@dappbooster/core` is framework-agnostic. It runs anywhere JavaScript runs — browser, Node.js, Deno, Bun, edge functions. This makes it the blockchain interaction layer for: + +**AI agent scripts:** + +```typescript +import { createEvmTransactionAdapter, createEvmServerWallet } from '@dappbooster/core' + +const wallet = createEvmServerWallet({ privateKey: process.env.AGENT_PK }) +const evm = createEvmTransactionAdapter() + +// Same four-phase cycle as frontend +const prepared = await evm.prepare({ + chainId: 1, + payload: { contract: { abi: usdcAbi, functionName: 'transfer', args: [to, amount] }, to: usdcAddress }, +}) + +const ref = await evm.execute(prepared.params, wallet.getSigner()) +const result = await evm.confirm(ref) +``` + +No React. No browser. No wagmi. Same typed adapters, same lifecycle hooks (for logging/monitoring), same interfaces an agent already knows from building frontends. + +**CLI tools:** + +```typescript +import { createChainRegistry, getExplorerUrl } from '@dappbooster/core' + +const registry = createChainRegistry([...evmChains, solanaMainnet]) + +// Portfolio check across chains +for (const chain of registry.getAllChains()) { + const balance = await fetchBalance(chain, address) + console.log(`${chain.name}: ${balance}`) + console.log(` Explorer: ${getExplorerUrl(registry, { chainId: chain.chainId, address })}`) +} +``` + +**Backend relayers:** + +Same adapters with server-side signers. Lifecycle hooks plug into monitoring/alerting. The SDK handles the execute/confirm cycle; the relayer adds nonce management, queuing, and retry logic on top. + +### Why this matters + +- **Agents learn one SDK.** Building a UI and executing transactions directly use the same interfaces. No context switch. +- **Multi-chain by default.** An agent managing assets across EVM + Solana uses the same adapter pattern everywhere. +- **Deterministic API.** One interface, one way to execute. Agents don't handle ambiguity. +- **Lifecycle hooks for observability.** Agent frameworks plug into `onSubmit`, `onConfirm`, `onError` for decision logging, cost tracking, and alerting. + +### Three consumers, one core + +``` +@dappbooster/core (adapters, interfaces, types) + ├── @dappbooster/react (hooks) → @dappbooster/chakra (styled components) + ├── Agent scripts (Node.js, direct adapter usage) + └── CLI tools (terminal, direct adapter usage) +``` + +### Documentation strategy for agents + +Three layers, each optimized for a different context window: + +**Layer 1: `llms.txt`** + +Machine-optimized context file at the package root. Agents load this first. Contains: + +- Package map: what each package provides +- Decision tree: intent → API mapping (no ambiguity, no alternatives) +- Canonical examples: one per use case, copy-paste ready +- Escape hatch progression: which level of abstraction to use when + +Structure: + +``` +# @dappbooster SDK — Agent Context + +## Package map +@dappbooster/core → adapters, types, chain registry (no framework dependency) +@dappbooster/react → hooks, provider (React 19+) +@dappbooster/chakra → styled components (Chakra UI 3) + +## Decision tree + +### Execute a transaction +- React + styled → +- React + custom UI → useTransaction({ chainId, params }) +- Node.js / CLI / agent → TransactionAdapter.execute(params, signer) + +### Connect a wallet +- React + styled → +- React + custom UI → useWallet({ chainId }).connect() +- Node.js (server wallet) → createEvmServerWallet({ privateKey }) + +### Read on-chain data without a wallet +- React → useReadOnly({ chainId, address }) +- Node.js → create public client from adapter + +### Get an explorer URL +- Any environment → getExplorerUrl(registry, { chainId, tx | address | block }) + +### Add a new chain type +- Implement WalletAdapter<'mychain'> + TransactionAdapter<'mychain'> +- Register in DAppBoosterProvider or use directly + +## Canonical examples +[One complete example per use case — no "you could also" alternatives] +``` + +**Layer 2: TypeDoc JSDoc on every export** + +One-line purpose, `@example` block, `@see` cross-references: + +```typescript +/** + * Execute a transaction on any supported chain. + * + * @example + * ```tsx + * const tx = useTransaction({ chainId: 1, params: { payload: { to, data, value } } }) + * await tx.execute() + * ``` + * + * @see useWallet - for wallet connection state + * @see TransactionAdapter - for the underlying adapter interface + */ +function useTransaction(options: UseTransactionOptions): UseTransactionReturn +``` + +**Layer 3: Reference use cases** + +Complete working examples (see Section 10). + +--- + +## 10. Reference Use Cases + +Each use case demonstrates a different composition of SDK primitives. These are runnable reference apps — not snippets, but complete starting points. + +### Use case matrix + +| # | Use case | Wallet adapters | Transaction adapters | Packages | Key pattern | Status | +|---|---|---|---|---|---|---| +| 1 | **EVM dApp** (Aave-like) | EVM | EVM | core + react + chakra | Single-chain, generated hooks, TransactionButton | **Ready** — use `createEvmWalletAdapter` + `createEvmTransactionAdapter`, `` | +| 2 | **Auth-only app** (portal-earn-like) | EVM + SVM + Sui + Aptos | None | core + react | Multi-platform wallet signing, no transactions | **EVM only** — use `useWallet({ chainType: 'evm' })` + `signMessage()`. SVM/Sui/Aptos adapters are Phase 4. | +| 3 | **Portfolio tracker** (Rotki-like) | None | None | core + react | Read-only, arbitrary addresses, data fetching | **Partial** — `useReadOnly({ chainId })` returns a client but lacks address param and explorer URL (Phase 3). Use `useChainRegistry()` for chain metadata. | +| 4 | **Cross-chain bridge** | EVM + SVM | EVM + SVM | core + react | Multi-adapter, consumer-land flow orchestrator | **EVM-side only** — compose two `useWallet()` calls + two `useTransaction()` calls in consumer code. SVM adapter is Phase 4. WalletGuard is single-chain; use two guards or compose with hooks. | +| 5 | **Custom-styled dApp** (Tailwind) | EVM | EVM | core + react (no chakra) | Hooks-only, custom components, headless pattern | **Ready** — import `useWallet`, `useTransaction` from `@dappbooster/react`. Build your own components with any CSS framework. | +| 6 | **Agent script** | Server wallet | EVM | core only | Node.js, server-side signer, lifecycle logging, no React | **Ready** — `createEvmServerWallet({ privateKey, chain })` + `createEvmTransactionAdapter(config)`. Call `adapter.execute(params, signer)` directly. | +| 7 | **CLI tool** | Server wallets | Multi-chain | core only | Terminal UX, multi-chain commands, batch operations | **EVM only** — use `createEvmServerWallet` + `createChainRegistry(chains)` + `getExplorerUrl()`. Multi-chain requires Phase 4 adapters. | +| 8 | **Relayer** | Server wallets | Multi-chain | core only | Backend service, lifecycle hooks for monitoring | **EVM only** — same as use case 6 with `wrapAdapter()` for logging/monitoring. Consumer adds nonce management. | +| 9 | **Smart wallet (AA)** | ERC-4337 adapter | Bundler-based adapter | core + react | UserOperations, paymaster, batched calls | **Not started** — requires custom `WalletAdapter` + `TransactionAdapter` implementations. Interfaces support it; no reference adapter ships. | +| 10 | **Gasless app** | EVM (sign only) | Server-side relay | core (client) + core (server) | Client signs permit, server submits and pays gas | **Primitives ready** — client: `useWallet().signTypedData()` for permit. Server: `createEvmServerWallet()` + `createEvmTransactionAdapter()`. Consumer composes the relay logic. | +| 11 | **Multi-sig** (Safe-like) | EVM | EVM (propose/approve) | core + react | Multi-party transaction lifecycle, EIP-712 typed data signing | **Primitives ready** — `useWallet().signTypedData()` for EIP-712. Consumer implements propose/approve/execute flow; SDK provides signing + lifecycle hooks. | +| 12 | **Token-gated app** | EVM (identity only) | None | core + react | Connect wallet, verify ownership, no transactions | **Ready** — `useWallet({ chainId })` for connection + address. `WalletGuard` gates UI. Consumer verifies token ownership off-chain or via `useReadOnly`. | +| 13 | **ZK identity / voting** | EVM | EVM (proof as calldata) | core + react | Proof generation as PreStep, lifecycle hooks for progress | **Partial** — `createApprovalPreStep` pattern works for proof-as-preStep. `autoPreSteps: true` runs it automatically. Manual per-step approval UI is Phase 3. | +| 14 | **FHE private DeFi** | EVM + encryption keys | EVM (encrypted payload) | core + react | Encrypt/decrypt middleware on adapters | **Partial** — current `wrapAdapter()` is observation-only (logging, analytics). Transforming hooks (encrypt/decrypt payloads) are Phase 3. Today: implement a custom `TransactionAdapter` that delegates to the EVM adapter internally. | + +### What each validates + +- **Use cases 1, 5**: Basic single-chain dApp works with minimal config +- **Use cases 2, 12**: Wallet-only apps (no transaction adapter) are first-class +- **Use case 3**: Zero-adapter apps (read-only) are first-class +- **Use cases 4, 10**: Multi-chain apps compose adapters independently +- **Use cases 6, 7, 8**: Non-browser use cases work with `@dappbooster/core` alone +- **Use case 9**: Adapter interfaces accommodate non-standard transaction models (UserOps, bundlers) +- **Use case 11**: Lifecycle hooks support multi-party flows +- **Use cases 13, 14**: Pre-processing (ZK proofs, FHE encryption) composes with standard adapters via PreStep and middleware + +### Agent implementation guide + +When an agent needs to implement one of these use cases, follow this decision tree: + +1. **Does the app need a wallet connection?** Yes → register wallet adapters in `DAppBoosterConfig.wallets`. No → skip wallets, use `chains` + `readClientFactories` for read-only. +2. **Does the app send transactions?** Yes → register transaction adapters in `DAppBoosterConfig.transactions`. No → skip transactions. +3. **Is this a React app?** Yes → use `DAppBoosterProvider` + hooks (`useWallet`, `useTransaction`). No → use `@dappbooster/core` directly (adapter factories, no provider). +4. **Which chain types?** EVM is the only shipped adapter. For other chains, implement `WalletAdapter` and/or `TransactionAdapter` against the interfaces. +5. **Which EVM connector?** Pass one of `connectkitConnector`, `rainbowkitConnector`, or `reownConnector` to `createEvmWalletAdapter({ connector })`. This is a one-line config choice. + +**Resolution rules (deterministic — no alternatives):** +- Always resolve adapters by `chainId` (preferred) or `chainType`. Never omit both in multi-adapter setups. +- Always pass `chainId` or `chainType` to ``, ``, ``. +- Always import SDK hooks from `@dappbooster/react` (or `src/sdk/react/hooks`). Never use deprecated hooks from `src/hooks/` or `src/wallet/hooks/`. +- Always use `createEvmWalletAdapter` to get a `WalletAdapterBundle`. Never construct wagmi config separately. +- For server-side / agent scripts, use `createEvmServerWallet` + `createEvmTransactionAdapter`. Never import React hooks. + +**wagmi config sharing rule (critical — single config instance):** + +The app must use exactly ONE wagmi `Config` instance at runtime. Generated contract hooks (from `pnpm wagmi-generate`) and the SDK wallet adapter must reference the same config, otherwise generated hooks read from a disconnected client and target the wrong chain. + +The config lives at `src/wallet/connectors/wagmi.config.ts`: +```typescript +import { chains, transports } from '@/src/core/types' +import { rainbowkitConnector } from '@/src/sdk/core/evm' // or connectkitConnector, reownConnector +export const config = rainbowkitConnector.createConfig([...chains], transports) +``` + +Two places reference this: +1. **`src/routes/__root.tsx`** — passes it as `wagmiConfig` to `createEvmWalletAdapter({ ..., wagmiConfig })` +2. **`src/contracts/wagmi/plugins/reactSuspenseRead.ts`** — the `walletConfigImport` string on line 9 controls what the generated hooks import + +**When switching connectors:** change the connector import in `wagmi.config.ts` and in `__root.tsx`. Both files must use the same connector. The codegen plugin path does NOT need to change — it always points to `wagmi.config.ts`. + +### Use case details + +Each reference app should include: + +1. **Provider configuration** — the exact `DAppBoosterConfig` for this use case +2. **Key component** — the primary UI component showing hook usage +3. **Adapter composition** — which adapters are registered and why +4. **What's SDK vs what's consumer code** — clear boundary + +These reference apps also serve as templates for `create-dappbooster`: + +```bash +npx create-dappbooster --template evm-defi +npx create-dappbooster --template bridge +npx create-dappbooster --template portfolio-tracker +npx create-dappbooster --template agent-script +``` + +--- + +## 11. Validation + +### TransactionFlow orchestrator (consumer-land) + +The SDK doesn't ship a flow orchestrator. This section proves the primitives support one. + +A bridge app sequences: approve → lock → wait for relay → claim across two chains: + +```typescript +// Consumer code — NOT part of the SDK + +interface FlowStep { + label: string + chainId: string | number + params: TransactionParams + resolveDependencies?: (previousResults: TransactionResult[]) => TransactionParams +} + +function useTransactionFlow(steps: FlowStep[]) { + const [currentIndex, setCurrentIndex] = useState(0) + const [results, setResults] = useState([]) + const [phase, setPhase] = useState<'idle' | 'running' | 'complete' | 'error'>('idle') + const registry = useChainRegistry() + + async function run() { + setPhase('running') + + for (let i = 0; i < steps.length; i++) { + setCurrentIndex(i) + const step = steps[i] + + const params = step.resolveDependencies + ? step.resolveDependencies(results) + : step.params + + const chainType = registry.getChainType(step.chainId) + const txAdapter = getTransactionAdapter(chainType) + const walletAdapter = getWalletAdapter(chainType) + + const prepared = await txAdapter.prepare(params) + + // Handle pre-steps (approvals) + if (prepared.preSteps) { + for (const preStep of prepared.preSteps) { + const preRef = await txAdapter.execute(preStep.params, walletAdapter.getSigner()) + await txAdapter.confirm(preRef) + } + } + + const ref = await txAdapter.execute(params, walletAdapter.getSigner()) + const result = await txAdapter.confirm(ref) + + if (result.status !== 'success') { + setPhase('error') + return + } + + setResults(prev => [...prev, result]) + } + + setPhase('complete') + } + + return { run, currentIndex, results, phase, totalSteps: steps.length } +} +``` + +Bridge usage: + +```tsx +const bridge = useTransactionFlow([ + { + label: 'Approve USDC', + chainId: 1, + params: { chainId: 1, payload: { contract: approveConfig } }, + }, + { + label: 'Lock tokens on Ethereum', + chainId: 1, + params: { chainId: 1, payload: { contract: lockConfig } }, + }, + { + label: 'Claim on Solana', + chainId: 'solana-mainnet', + resolveDependencies: (results) => ({ + chainId: 'solana-mainnet', + payload: { instruction: buildClaimInstruction(results[1].receipt) }, + }), + }, +]) +``` + +### What the SDK provides vs what the consumer builds + +| Concern | SDK | Consumer | +|---|---|---| +| Adapter resolution by chainId | `DAppBoosterProvider` + `ChainRegistry` | — | +| Transaction execution (4 phases) | `TransactionAdapter` | — | +| Approval detection | `PrepareResult.preSteps` | — | +| Lifecycle notifications | Global hooks in provider | — | +| Explorer URLs | `ChainRegistry` + `getExplorerUrl` | — | +| Step sequencing | — | Flow orchestrator | +| Cross-step dependencies | — | `resolveDependencies` | +| Relay/attestation waiting | — | Custom polling step | +| Multi-chain wallet prompts | — | Progressive connect UX | +| Flow progress UI | — | Progress indicators | + +### Hard use case validation + +**Account Abstraction (ERC-4337):** + +- `SmartWalletAdapter` implements `WalletAdapter<'evm-aa'>`. `connect()` initializes smart account. `getSigner()` returns session key or owner signer. `getStatus()` tracks smart account address. +- `BundlerTransactionAdapter` implements `TransactionAdapter<'evm-aa'>`. `execute()` builds UserOperation, sends to bundler. `confirm()` watches for UserOp inclusion. +- `prepare()` returns paymaster willingness (sponsored gas) in `estimatedFee`. +- `PreStep` for "deploy smart account" if it doesn't exist yet. +- `TransactionRef.id` is UserOp hash. `ChainSigner` is session key. Interfaces hold. + +**Gasless / Meta-transactions:** + +- Client: `WalletAdapter.signMessage()` or `signTypedData()` for permits. +- Server: `TransactionAdapter.execute()` with `ServerWalletAdapter`. Relayer submits tx with user's signature in calldata. +- Split: client uses `@dappbooster/react`, server uses `@dappbooster/core`. Same interfaces, different runtimes. +- Lifecycle hooks on server notify client via websocket (consumer-land). + +**FHE Private DeFi:** + +- `WalletAdapter` extended with `getEncryptionKey()` on an FHE-specific adapter. +- Encryption middleware wraps `TransactionAdapter`: encrypts payload before `execute()`, decrypts receipt after `confirm()`. +- `prepare()` validates contract supports encrypted inputs. +- `useReadOnly` with decryption middleware for reading encrypted state. +- Interfaces hold — encryption is middleware, not a new adapter type. + +### Architecture risks and mitigations + +| Risk | Mitigation | +|---|---| +| `ChainSigner` as `unknown` loses type safety | Concrete adapters expose typed signers (`EvmSigner`, `SvmSigner`). Generic interface stays stable. | +| `payload` as `unknown` allows wrong structure | Each adapter factory documents its payload type. `llms.txt` includes per-chain examples. TypeDoc `@example` shows exact shapes. | +| Lifecycle hook ordering confusion | Documented contract: global fires first, per-operation second. Both always fire. Errors in hooks never abort transactions. | +| Migration from current codebase is large | EVM adapter wraps existing wagmi code — internals don't change. Old and new components coexist during migration. | +| Adapter interface too rigid for unknown future chains | Four-phase cycle is universal. Irrelevant phases return no-ops. Metadata is extensible via `Record`. | +| Two adapters to register (wallet + transaction) feels like boilerplate | Factory functions compose both: `createEvmAdapters({ ... })` returns `{ wallet, transaction }`. Single-line setup for common cases. | + +--- + +## 12. Migration Path + +### From current codebase to adapter architecture + +The migration is incremental. Old and new code coexist during transition. + +**Phase 1: Introduce adapters alongside existing code** + +- Add `@dappbooster/core` interfaces and EVM adapter implementations +- `DAppBoosterProvider` wraps the existing provider stack internally (wagmi + ConnectKit stay underneath) +- New hooks (`useWallet`, `useTransaction`) work alongside existing hooks (`useWeb3Status`, `useWalletStatus`) +- No breaking changes — existing components continue to work + +**Phase 2: Migrate components to adapter-backed hooks** + +Done: + +- `TransactionButton` internals switch from direct wagmi calls to `useTransaction()` +- `SignButton` internals switch to `useWallet().signMessage()` +- `WalletStatusVerifier` becomes `WalletGuard` (old component marked `@deprecated`) +- Old hooks (`useWeb3Status`, `useWalletStatus`) marked `@deprecated` with JSDoc pointing to replacements +- `ConnectWalletButton` is adapter-agnostic (uses `useWallet` + `openConnectModal`, no wagmi imports) +- Connectors expose `useConnectModal` hook, resolved per-adapter via bridge components +- Default connect fallbacks in TransactionButton/SignButton/WalletGuard scoped to target chain + +Pending (tracked for Phase 2 completion before Phase 3): + +- `TransactionNotificationProvider` still active in `__root.tsx` — depends on `useWeb3Status` and wagmi's `readOnlyClient`. Replace with `createNotificationLifecycle` as global lifecycle hooks. +- `onReplace` lifecycle hook declared in interface but not dispatched by `useTransaction` — wire from EVM adapter's replacement detection. +- Active token/runtime code (`useTokens`, `TokenSelect`, `AddERC20TokenButton`) still consumes deprecated `useWeb3Status` — blocked on read-only adapter path (Phase 3). +- Demo pages use `LegacyTransactionButton` — migrate to adapter-based `TransactionButton` after notification provider migration. + +**Phase 3: Extract style package** + +- Move styled components to `@dappbooster/chakra` +- `@dappbooster/react` contains only hooks and provider +- `@dappbooster/core` contains only interfaces, types, adapters, and utilities +- Remove deprecated hooks +- `ConnectWalletButton` avatar/ENS display — build on `useReadOnly(address)` to fetch ENS name + avatar without wagmi hooks, making it adapter-agnostic +- Demo dialog z-index conflict — RainbowKit modal's close button is unresponsive when opened from inside a Chakra `Dialog` portal due to competing stacking contexts. Fix by adjusting z-index or closing the demo dialog when the connect modal opens + +**Phase 4: Multi-chain adapters** + +- Community or official SVM, Cosmos, Sui, Aptos adapters +- `pnpm codegen` script replaces `pnpm wagmi-generate` +- Reference use case apps built and published + +**Phase 4+ consideration: DataAdapter interface** + +Today the architecture has `WalletAdapter` (connection/signing) and `TransactionAdapter` (write operations). Data *reading* is ad-hoc — wagmi hooks for on-chain reads, LI.FI SDK for token prices, subgraph queries for indexed data. Each has its own fetching, caching, and error handling. + +A formal `DataAdapter` interface would standardize this: + +```typescript +interface DataAdapter { + chainType: string + query(params: TQuery): Promise + subscribe?(params: TQuery, listener: (result: TResult) => void): () => void +} +``` + +The existing `@bootnodedev/db-subgraph` codegen tool generates typed GraphQL queries from subgraph schemas. A `SubgraphDataAdapter` could wrap these queries behind the interface, adding SDK error types, lifecycle hooks (onQuery, onError for analytics/logging), and provider integration for a future `useData` hook. + +This only justifies itself when consumers need to swap data sources for the same query — e.g., "use The Graph on mainnet but a custom indexer on L2." Until there's a second data source to abstract over, the current direct-query pattern works and formalizing it would be premature. Track as a design consideration, not a committed deliverable. + +### What stays, what changes, what goes + +| Current | Phase 1 | Phase 2 | Phase 3 | +|---|---|---|---| +| `useWeb3Status` | Exists + new `useWallet` | Deprecated | Removed | +| `useWalletStatus` | Exists + new `useWallet` | Deprecated | Removed | +| `TransactionButton` | Unchanged | Adapter-backed internally | Moves to `@dappbooster/chakra` | +| `Web3Provider` | Exists + `DAppBoosterProvider` wraps it | `DAppBoosterProvider` replaces it | Removed | +| `TransactionNotificationProvider` | Exists + global lifecycle hooks | Lifecycle hooks replace it | Removed | +| `connectkit.config.tsx` | Unchanged | Becomes `EvmConnectorConfig` | Same | +| `wagmi-generate` script | Unchanged | Unchanged | Becomes `pnpm codegen` | + +--- + +## 13. Monorepo Directory Structure + +The monorepo is managed with **pnpm workspaces** (package management), **Turborepo** (build orchestration + caching), and **Changesets** (versioning + changelogs). npm scope: `@dappbooster/*`. + +### Directory layout + +``` +dAppBooster/ ← monorepo root +├── packages/ +│ ├── core/ ← @dappbooster/core +│ │ ├── package.json +│ │ └── src/ +│ │ ├── adapters/ # WalletAdapter, TransactionAdapter interfaces +│ │ │ ├── wallet.ts # WalletAdapter interface +│ │ │ ├── transaction.ts # TransactionAdapter interface +│ │ │ └── lifecycle.ts # TransactionLifecycle, WalletLifecycle +│ │ ├── chain/ # ChainDescriptor, ChainRegistry, getExplorerUrl +│ │ │ ├── descriptor.ts # ChainDescriptor, CurrencyInfo, ExplorerConfig, etc. +│ │ │ ├── registry.ts # createChainRegistry, ChainRegistry interface +│ │ │ └── explorer.ts # getExplorerUrl utility +│ │ ├── evm/ # EVM adapter implementations +│ │ │ ├── connectors/ # Subpath exports with optional peer deps +│ │ │ │ ├── connectkit.ts # connectkitConnector +│ │ │ │ ├── rainbowkit.ts # rainbowkitConnector +│ │ │ │ └── reown.ts # reownConnector +│ │ │ ├── wallet.ts # EvmWalletAdapter (wraps wagmi) +│ │ │ ├── transaction.ts # EvmTransactionAdapter (wraps viem) +│ │ │ ├── server-wallet.ts # EvmServerWallet (private key signer) +│ │ │ ├── chains.ts # fromViemChain factory, default EVM descriptors +│ │ │ └── types.ts # EvmTransactionPayload, EvmConnectorConfig +│ │ ├── tokens/ # Token types, token list config, cache utils +│ │ ├── data/ # Data adapter interfaces +│ │ ├── types/ # Shared types (ChainsIds, utility types) +│ │ └── utils/ # String utils, address utils +│ │ +│ ├── react/ ← @dappbooster/react +│ │ ├── package.json # depends on @dappbooster/core +│ │ └── src/ +│ │ ├── provider/ # DAppBoosterProvider, context +│ │ ├── hooks/ +│ │ │ ├── useWallet.ts +│ │ │ ├── useTransaction.ts +│ │ │ ├── useMultiWallet.ts +│ │ │ ├── useReadOnly.ts +│ │ │ ├── useChainRegistry.ts +│ │ │ ├── useTokenLists.ts +│ │ │ ├── useTokens.ts +│ │ │ ├── useErc20Balance.ts +│ │ │ └── useTokenSearch.ts +│ │ └── types/ +│ │ +│ ├── chakra/ ← @dappbooster/chakra +│ │ ├── package.json # depends on @dappbooster/react +│ │ └── src/ +│ │ ├── components/ +│ │ │ ├── TransactionButton.tsx +│ │ │ ├── SignButton.tsx +│ │ │ ├── WalletGuard.tsx +│ │ │ ├── ConnectWalletButton.tsx +│ │ │ ├── SwitchChain.tsx +│ │ │ ├── ExplorerLink.tsx +│ │ │ ├── Hash.tsx +│ │ │ ├── HashInput.tsx +│ │ │ ├── BigNumberInput.tsx +│ │ │ ├── NotificationToaster.tsx +│ │ │ └── tokens/ # TokenSelect, TokenInput, TokenLogo, TokenDropdown +│ │ └── styles/ +│ │ +│ └── create-dappbooster/ ← CLI scaffolding tool +│ ├── package.json +│ └── src/ +│ +├── templates/ ← what create-dappbooster scaffolds +│ ├── evm-defi/ # Full dApp starter (replaces .install-files) +│ │ ├── src/ +│ │ │ ├── components/ # Header, Footer, page components +│ │ │ ├── contracts/ # ABIs, definitions, generated.ts +│ │ │ ├── routes/ # TanStack Router pages +│ │ │ ├── theme/ # Chakra provider, color-mode, fonts +│ │ │ ├── env.ts +│ │ │ └── main.tsx +│ │ ├── package.json # depends on @dappbooster/core + react + chakra +│ │ └── vite.config.ts +│ ├── bridge/ +│ ├── portfolio-tracker/ +│ ├── agent-script/ +│ └── ... +│ +├── apps/ ← living examples / dev playground +│ └── demo/ # Current demo app (home page, examples) +│ ├── src/ +│ │ ├── components/pageComponents/ +│ │ ├── routes/ +│ │ └── ... +│ └── package.json +│ +├── turbo.json ← Turborepo task configuration +├── pnpm-workspace.yaml ← workspace package paths +├── .changeset/ ← Changesets configuration +└── package.json ← root scripts + devDeps +``` + +### Package dependency chain + +``` +@dappbooster/core ← no framework dependency + ↓ +@dappbooster/react ← depends on core + ↓ +@dappbooster/chakra ← depends on react (and transitively core) +``` + +`apps/demo` and `templates/*` depend on all three. CLI tools and agent scripts depend on `core` only. + +### Package cross-references (pnpm workspaces) + +```json +// packages/react/package.json +{ "dependencies": { "@dappbooster/core": "workspace:*" } } + +// packages/chakra/package.json +{ "dependencies": { "@dappbooster/react": "workspace:*" } } + +// apps/demo/package.json +{ "dependencies": { + "@dappbooster/core": "workspace:*", + "@dappbooster/react": "workspace:*", + "@dappbooster/chakra": "workspace:*" + } +} +``` + +`workspace:*` resolves to the local package — pnpm symlinks it. Changes to `packages/core/` are immediately visible to react, chakra, and apps. No publish step during development. + +### Turborepo task orchestration + +```bash +pnpm build # Builds core → react → chakra (dependency order, cached) +pnpm test # Tests all packages in parallel +pnpm test --filter=@dappbooster/core # Tests only core +pnpm dev # Starts apps/demo with HMR watching all packages +pnpm codegen # Runs codegen across all configured chain types +``` + +Turborepo caches build outputs. If `packages/core/` hasn't changed since last build, it skips it. Typical rebuild after changing one package: <2 seconds. + +### Where current domain folders land + +The domain folder structure from Task 1 was an intermediate step. In the monorepo, contents scatter by package boundary: + +| Current domain folder | Package | What moves | +|---|---|---| +| `src/core/config/`, `types/`, `utils/` | `packages/core/` | Chain config, shared types, utilities | +| `src/core/ui/` (ExplorerLink, Hash, BigNumberInput...) | `packages/chakra/` | Styled components | +| `src/core/ui/` (Header, Footer, Modal, buttons, Chakra setup) | `templates/evm-defi/` | App layout, design system | +| `src/wallet/connectors/` | `packages/core/src/evm/connectors/` | Subpath exports | +| `src/wallet/hooks/`, `providers/` | `packages/core/src/evm/` | Internals of EvmWalletAdapter | +| `src/wallet/components/` | `packages/chakra/` | WalletGuard, SwitchChain, ConnectButton | +| `src/transactions/providers/` | **Removed** | Replaced by lifecycle hooks | +| `src/transactions/components/` | `packages/chakra/` | TransactionButton, SignButton | +| `src/tokens/hooks/` | `packages/react/` | Token data hooks | +| `src/tokens/types/`, `config/`, `utils/` | `packages/core/` | Token infrastructure | +| `src/tokens/components/` | `packages/chakra/` | TokenSelect, TokenInput | +| `src/contracts/wagmi/` | `packages/core/src/evm/` | wagmi config + plugins | +| `src/contracts/abis/`, `definitions.ts` | `templates/evm-defi/` | App-specific contracts | +| `src/data/` (adapter infrastructure) | `packages/core/` | Data adapter pattern | +| `src/data/` (queries, generated types) | `templates/evm-defi/` | App-specific data | +| `src/components/pageComponents/`, `src/routes/` | `apps/demo/` | Demo app | + +### Subpath exports for EVM connectors + +Connector adapters are thin (~20-30 lines) and use optional peer dependencies: + +```json +// packages/core/package.json +{ + "exports": { + ".": "./src/index.ts", + "./evm": "./src/evm/index.ts", + "./evm/connectors/connectkit": "./src/evm/connectors/connectkit.ts", + "./evm/connectors/rainbowkit": "./src/evm/connectors/rainbowkit.ts", + "./evm/connectors/reown": "./src/evm/connectors/reown.ts" + }, + "peerDependencies": { + "connectkit": "^2.0.0", + "@rainbow-me/rainbowkit": "^2.0.0", + "@reown/appkit-adapter-wagmi": "^1.0.0" + }, + "peerDependenciesMeta": { + "connectkit": { "optional": true }, + "@rainbow-me/rainbowkit": { "optional": true }, + "@reown/appkit-adapter-wagmi": { "optional": true } + } +} +``` + +CLI tools and agent scripts import `@dappbooster/core` — no connector peer dep installed, no connector code bundled. + +--- + +## Glossary + +| Term | Definition | +|---|---| +| **Adapter** | Implementation of `WalletAdapter` or `TransactionAdapter` for a specific chain type | +| **CAIP-2** | Cross-chain standard identifier format: `namespace:reference` (e.g., `eip155:1`, `solana:5eykt4U...`, `cosmos:cosmoshub-4`) | +| **Chain type** | VM/ecosystem family: `'evm'`, `'svm'`, `'movevm-sui'`, `'movevm-aptos'`, `'cosmos'`, `'starknet'`, `'substrate'`, `'near'`, `'ton'` | +| **Chain descriptor** | Metadata about a specific chain: caip2Id, chainId, name, explorer, endpoints, addressConfig, currency | +| **Chain registry** | Lookup structure mapping chainId/caip2Id → chain descriptor, built from adapters and explicit config | +| **Connector** | EVM-specific wallet UI library (ConnectKit, RainbowKit, Reown). Implements `EvmConnectorConfig` | +| **Four-phase cycle** | prepare → submit → confirm. Universal transaction lifecycle across all chains. `TransactionPhase` type: `'prepare' | 'preStep' | 'submit' | 'confirm'` | +| **Lifecycle hooks** | Observer callbacks at transaction/signing phase transitions. Two scopes: global (provider) and per-operation | +| **PreStep** | Prerequisite transaction discovered during `prepare()` (e.g., token approval). Data, not a callback | +| **Style package** | UI component library wrapping hooks with a specific styling solution (`@dappbooster/chakra`, future `@dappbooster/tailwind`) | +| **Server wallet** | `WalletAdapter` implementation for backend use — wraps a private key, `connect()`/`disconnect()` are no-ops | +| **Headless** | Logic with no UI dependency. The hooks and core adapters are headless; components are not | + +--- + +## Appendix A: Chain Tier Analysis + +Research across 23+ blockchain ecosystems informed the ChainDescriptor design. Chains are tiered by ecosystem size, dApp demand, and architectural fit. + +### Tier 1 — Must support + +| Chain type | Chains | ChainDescriptor field exercised | +|---|---|---| +| **EVM** | Ethereum, Arbitrum, Optimism, Polygon, BSC, Avalanche, zkSync, Monad, Berachain | `feeCurrency` (Berachain tri-token) | +| **SVM** | Solana | `caip2Id` (genesis hash as chainId), `explorer.queryParams` (cluster), tx = signature not hash | +| **MoveVM** | Sui, Aptos | Sui: unstable testnet chainId. Aptos: `endpoints` with `rest` protocol, tx = version number | +| **Cosmos** | Cosmos Hub, Osmosis, Injective, dYdX, Sei | `feeCurrency` (multi-denom), `addressConfig.prefix` (bech32 HRP), `endpoints` (3+ protocols), string chainIds | + +### Tier 2 — Worth supporting (can come later) + +| Chain type | Chains | Key descriptor fields needed | +|---|---|---| +| **StarkNet** (Cairo) | StarkNet | `feeCurrency` (ETH + STRK), `caip2Id` normalizes felt252 chainId | +| **Substrate** | Polkadot, Kusama, parachains | `addressConfig` (SS58 format + prefix byte), genesis hash chainId | +| **NEAR** | NEAR Protocol | `addressConfig` with `format: 'named'` for human-readable accounts, 24 decimals | +| **TON** | TON (Telegram) | Negative `chainId` (-239), `addressConfig` for raw vs user-friendly formats | + +### Tier 3 — Excluded from design considerations + +| Chain type | Reason for exclusion | +|---|---| +| **Bitcoin/UTXO** | Not a dApp platform. UTXO model is fundamentally different from account-based. No smart contracts in the dAppBooster sense. | +| **Cardano** | eUTXO model, declining dev ecosystem, no standard RPC, multiple address eras | +| **ICP** | Alien model: canisters, reverse gas, custom binary protocol | +| **Radix** | Tiny ecosystem, entity-type addresses | +| **Fuel** | Early stage, UTXO smart contracts | +| **Tezos, Algorand, Mina** | Small/declining ecosystems | +| **XRP, Stellar** | Exchange/payment focused, not dApp focused | +| **Hedera, MultiversX** | Niche ecosystems | + +### What we gain by excluding Tier 3 + +- No UTXO state model complexity (Bitcoin, Cardano, Fuel) +- No reverse-gas model (ICP) +- No entity-type addresses (Radix) +- No 48-char passphrase chainIds (Stellar) +- `chainId: string | number` remains sufficient for all Tier 1-2 chains +- `AddressConfig` covers all Tier 1-2 address formats without special-casing + +### Dual-VM chain handling (Sei) + +Sei exposes both an EVM interface (chainId 1329) and a Cosmos interface (chainId `pacific-1`) backed by the same validators. From the SDK's perspective, these are two separate chains: + +```typescript +// Sei EVM +{ caip2Id: 'eip155:1329', chainType: 'evm', addressConfig: { format: 'hex', patterns: [/^0x[0-9a-fA-F]{40}$/] } } + +// Sei Cosmos +{ caip2Id: 'cosmos:pacific-1', chainType: 'cosmos', addressConfig: { format: 'bech32', prefix: 'sei', patterns: [/^sei1[a-z0-9]{38}$/] } } +``` + +Each gets its own adapter. The shared infrastructure is transparent to the SDK. diff --git a/docs/architecture/domain-folder-structure.md b/docs/architecture/domain-folder-structure.md new file mode 100644 index 00000000..b9c4cac6 --- /dev/null +++ b/docs/architecture/domain-folder-structure.md @@ -0,0 +1,121 @@ +# dAppBooster Domain Folder Architecture + +## What changed + +The `src/` directory has been reorganized from a flat, role-based structure (`components/`, `hooks/`, `utils/`, `providers/`) into domain-based folders where each folder owns everything related to one concern. + +### Before + +``` +src/ + components/sharedComponents/ # all shared components mixed together + hooks/ # all hooks mixed together + utils/ # all utilities mixed together + providers/ # all providers mixed together + types/ # all types mixed together +``` + +### After + +``` +src/ + core/ # shared UI primitives, utilities, types + wallet/ # wallet connection, chain switching, status + transactions/ # TransactionButton, SignButton, notifications + tokens/ # token lists, balances, TokenSelect, TokenInput + contracts/ # ABIs, contract definitions, wagmi codegen + data/ # subgraph queries, indexer adapters + components/ # page-level components (routes, demos) + routes/ # TanStack Router route definitions +``` + +Each domain folder has **sub-barrel entry points** that define its public API: + +``` +src/wallet/ + components.ts → WalletStatusVerifier, SwitchNetwork, ConnectButton, ... + hooks.ts → useWalletStatus, useWeb3Status + providers.ts → Web3Provider + types.ts → ChainsIds, chains, ... + components/ # implementation files + hooks/ # implementation files + providers/ # implementation files + connectors/ # wallet connector configs +``` + +## How imports work + +Consumers import from sub-barrels, never from implementation files directly: + +```typescript +// Good — imports from the domain's public API +import { WalletStatusVerifier, useWeb3StatusConnected } from '@/src/wallet/components' +import { useWalletStatus } from '@/src/wallet/hooks' +import { TransactionButton } from '@/src/transactions/components' +import { PrimaryButton, Spinner } from '@/src/core/components' +import { withSuspenseAndRetry } from '@/src/core/utils' +import type { Token } from '@/src/tokens/types' + +// Bad — reaches into implementation details +import { useWalletStatus } from '@/src/wallet/hooks/useWalletStatus' +import { PrimaryButton } from '@/src/core/ui/PrimaryButton/index' +``` + +This follows a **Design by Contract** principle: each domain exposes a defined interface. Implementation details can change freely without breaking consumers. + +## Why this matters + +### Tree-shaking + +Sub-barrels (`components.ts`, `hooks.ts`, `utils.ts`) are separate entry points, not one giant `index.ts` that re-exports everything. When you import `{ useWalletStatus }` from `@/src/wallet/hooks`, the bundler only pulls in wallet hooks — not wallet components, providers, or connectors. This keeps production bundles lean. + +### Discoverability + +A new developer (or an AI agent) can look at `src/wallet/components.ts` and immediately see every component the wallet domain exposes. No need to grep through dozens of files. The barrel is the documentation. + +### Isolation + +Each domain is self-contained. `tokens/` doesn't import from `wallet/` internals. `transactions/` doesn't reach into `core/ui/`. If a domain needs something from another domain, it imports from the sub-barrel — making cross-domain dependencies explicit and auditable. + +### Testability + +Moving related code together means tests live next to what they test. `WalletStatusVerifier.test.tsx` lives in `wallet/components/`, not in a separate `__tests__/` tree. This makes it obvious what's tested and what isn't. + +## WalletStatusVerifier context pattern + +As part of this restructuring, `WalletStatusVerifier` was upgraded from a simple wrapper to a **context provider**: + +- `WalletStatusVerifier` provides a React Context with connected wallet data when verification passes +- `useWeb3StatusConnected()` reads from that context — if called outside the tree, it throws a `DeveloperError` with an actionable message +- Error boundaries detect `DeveloperError` and show the message **without** a "Try Again" button (structural errors can't be fixed by retrying) +- `useOPL1CrossDomainMessengerProxy` accepts `walletAddress` as a parameter instead of fetching it internally, making it portable and testable + +This enforces a single pattern: components that need a connected wallet **must** be inside ``. The error message tells you exactly what to do if you forget. + +## What's next + +### `#wallet` connector alias (Task 2) + +Currently, switching wallet connectors (ConnectKit, RainbowKit, Reown) requires editing source code. The next step adds a Vite alias so the connector is selected via an environment variable: + +```bash +# .env.local +PUBLIC_WALLET_CONNECTOR=connectkit # or rainbowkit, reown +``` + +Each connector exports the same interface (`WalletProvider`, `ConnectWalletButton`, `createWalletConfig`). The app imports from `#wallet`, and Vite resolves it to the right connector at build time. Zero code changes to switch. + +### Package extraction (Task 3) + +The sub-barrel structure is designed to map directly to `package.json` `exports` entries. Each domain folder becomes an independently publishable package: + +``` +@dappbooster/core → src/core/ +@dappbooster/wallet → src/wallet/ +@dappbooster/tokens → src/tokens/ +@dappbooster/transactions → src/transactions/ +``` + +Consumers install only what they need. The sub-barrels we have today become the package entry points — no restructuring needed, just packaging. + +Before extraction, remaining cross-domain dependency violations (e.g., `core/` importing from `wallet/` in Header and NotificationToast) need to be resolved by moving those components to an app shell layer. diff --git a/resume.txt b/resume.txt new file mode 100644 index 00000000..1c88b453 --- /dev/null +++ b/resume.txt @@ -0,0 +1,42 @@ +I'm continuing work on the dAppBooster adapter architecture spec. + +Current state: +- Branch `feat/sdk-architecture` — NOT pushed. Rebased onto develop (PR #427 merged 2026-04-01). Single commit `30e00e46b` on top of develop. 239 tests pass. +- Full adapter architecture spec at `docs/architecture/adapter-architecture-spec.md` — NOT committed. ~1800 lines, 13 sections + Appendix A. +- Human-oriented overview at `docs/architecture/adapter-architecture-overview.md` — NOT committed. For sharing with engineering team. +- @dappbooster npm org reserved on npmjs.com (owner: fernandomg, to be transferred to bootnodedev). + +Spec covers (all approved + stress-reviewed): +1. ChainDescriptor: CAIP-2, typed EndpointConfig, AddressConfig, feeCurrency, explorer `{id}`, icon +2. WalletAdapter: accounts[], activeAccount, reconnect(), signTypedData optional via capabilities, switchChain, connectedChainIds[] +3. TransactionAdapter: prepare/execute/confirm, preSteps consumer-provided, Design by Contract on all methods +4. Lifecycle hooks: TransactionLifecycle (onSubmit) + WalletLifecycle (onSign, global-only). createNotificationLifecycle(registry) factory. +5. DAppBoosterProvider: WalletAdapterBundle (adapter + Provider), auto chain registry +6. Hooks: useWallet (throws AmbiguousAdapterError if multiple + no options), useTransaction (autoPreSteps false, executePreStep(i)), useMultiWallet, useReadOnly, useChainRegistry +7. Headless-first: @dappbooster/core → @dappbooster/react → @dappbooster/chakra +8. EVM adapter: EvmConnectorConfig split core/react, uses @wagmi/core actions (not React hooks), WalletAdapterBundle returns Provider +9. PreStep helpers: createApprovalPreStep, createPermitPreStep +10. wrapAdapter() utility for adapter composition (FHE, logging) +11. Codegen: `pnpm codegen` dual output (React hooks + framework-agnostic actions) +12. 14 reference use cases, 4-phase migration path +13. Monorepo structure: packages/core, packages/react, packages/chakra, templates/, apps/demo +14. Chain tiers: T1 (EVM, SVM, MoveVM, Cosmos), T2 (StarkNet, Substrate, NEAR, TON), T3 excluded + +Stress review completed: +- 3-agent review found 26 issues (9 blockers, 5 contradictions, 8 incomplete, 4 warnings) +- ALL resolved and applied to spec with Design by Contract annotations +- Architecture validated as sound — all issues were interface-level, no structural redesign needed + +Decisions made: +- Monorepo: Turborepo + pnpm workspaces + Changesets +- npm scope: @dappbooster/* +- Connectors: subpath exports with optional peer deps (not separate packages) +- Task 2 (#wallet alias): absorbed into adapter architecture +- PreSteps: consumer-provided, not auto-detected; SDK ships helper functions for common patterns + +Next steps: +1. Finish any remaining spec review (user was reviewing when session ended) +2. Transition to implementation planning (writing-plans skill) +3. Push feat/sdk-architecture branch, open PR for domain reorg + +Check memory for full context, then read `docs/architecture/adapter-architecture-spec.md` to get up to speed on the spec. \ No newline at end of file diff --git a/src/sdk/react/provider/index.ts b/src/sdk/react/provider/index.ts index 8c89d629..58cf54be 100644 --- a/src/sdk/react/provider/index.ts +++ b/src/sdk/react/provider/index.ts @@ -1,3 +1,2 @@ export type { DAppBoosterContextValue } from './context' -export { useProviderContext } from './context' export { DAppBoosterProvider } from './DAppBoosterProvider' From f6dbeda3e47d7f9a0779b9d584a795bd7d5e6e06 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 7 Apr 2026 14:14:19 +0200 Subject: [PATCH 16/16] fix: enforce Design by Contract preconditions and add JSDoc annotations Add @precondition, @postcondition, @throws, and @invariant JSDoc annotations to all public SDK methods matching the spec contracts. Add missing runtime precondition enforcement: - switchChain() now checks connected state before chain validation - execute() now validates params.chainId against supportedChains --- src/sdk/core/chain/explorer.ts | 6 +- src/sdk/core/chain/registry.ts | 7 +- src/sdk/core/evm/server-wallet.ts | 67 +++++++++++++- src/sdk/core/evm/transaction.test.ts | 21 ++++- src/sdk/core/evm/transaction.ts | 39 ++++++++- src/sdk/core/evm/wallet.test.ts | 7 ++ src/sdk/core/evm/wallet.tsx | 87 +++++++++++++++++++ src/sdk/react/hooks/useTransaction.ts | 17 ++++ src/sdk/react/hooks/useWallet.ts | 18 ++++ .../react/provider/DAppBoosterProvider.tsx | 8 ++ 10 files changed, 270 insertions(+), 7 deletions(-) diff --git a/src/sdk/core/chain/explorer.ts b/src/sdk/core/chain/explorer.ts index 535ce7bf..156f6bae 100644 --- a/src/sdk/core/chain/explorer.ts +++ b/src/sdk/core/chain/explorer.ts @@ -8,8 +8,10 @@ type ExplorerParams = /** * Builds an explorer URL for a transaction, address, or block. * - * Returns null if the chain is not found, has no explorer config, or the - * requested path type (e.g. blockPath) is not defined for that explorer. + * @precondition registry is a valid ChainRegistry + * @precondition params.chainId identifies a chain, params contains exactly one of tx/address/block + * @postcondition returns a fully qualified URL string, or null if chain/explorer/path not found + * @postcondition if explorer.queryParams is defined, they are appended as URL search params */ export function getExplorerUrl(registry: ChainRegistry, params: ExplorerParams): string | null { const descriptor = registry.getChain(params.chainId) diff --git a/src/sdk/core/chain/registry.ts b/src/sdk/core/chain/registry.ts index 9c350d1f..b9d180ae 100644 --- a/src/sdk/core/chain/registry.ts +++ b/src/sdk/core/chain/registry.ts @@ -18,8 +18,11 @@ export interface ChainRegistry { /** * Creates an immutable ChainRegistry from the provided descriptors. * - * Throws ChainRegistryConflictError at construction time if any two descriptors - * share the same chainId or the same caip2Id. + * @precondition no two descriptors share the same chainId + * @precondition no two descriptors share the same caip2Id + * @postcondition registry is immutable — lookups never mutate internal state + * @postcondition getAllChains() returns a copy of the input array + * @throws {ChainRegistryConflictError} if any two descriptors share the same chainId or caip2Id */ export function createChainRegistry(chains: ChainDescriptor[]): ChainRegistry { const byChainId = new Map() diff --git a/src/sdk/core/evm/server-wallet.ts b/src/sdk/core/evm/server-wallet.ts index 5903635d..186f0ed3 100644 --- a/src/sdk/core/evm/server-wallet.ts +++ b/src/sdk/core/evm/server-wallet.ts @@ -31,6 +31,14 @@ export interface EvmServerWalletConfig { /** * Creates a server-side EVM wallet adapter backed by a private key. * Returns no Provider — server wallets have no UI layer. + * + * @precondition config.privateKey is a valid hex-encoded private key + * @precondition config.chain is a valid viem Chain + * @postcondition returned adapter.chainType === 'evm' + * @postcondition returned bundle has no Provider (server wallets have no UI) + * @invariant adapter.chainType never changes after construction + * @invariant adapter.supportedChains never changes after construction + * @invariant getStatus().connected === true (always connected) */ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdapterBundle { const account = privateKeyToAccount(config.privateKey) @@ -58,6 +66,13 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap }, }, + /** + * No-op connect — server wallet is always connected via private key. + * + * @precondition none + * @postcondition returns WalletConnection with the private key account + * @postcondition result.accounts.length === 1 + */ async connect(_options?: ConnectOptions): Promise { return { accounts: [account.address], @@ -66,7 +81,12 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap } }, - // Server wallet is always connected — reconnect() always returns a connection, never null. + /** + * Always returns a connection — server wallet is always connected. + * + * @precondition none + * @postcondition always returns WalletConnection (never null) + */ async reconnect(): Promise { return { accounts: [account.address], @@ -75,10 +95,24 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap } }, + /** + * No-op — private key wallet is always connected and cannot be disconnected. + * + * @precondition none + * @postcondition getStatus().connected remains true + */ async disconnect(): Promise { // no-op: private key wallet is always connected }, + /** + * Returns wallet status — always connected for server wallets. + * + * @precondition none + * @postcondition connected === true + * @postcondition activeAccount === the private key's derived address + * @invariant status never changes for a private key wallet + */ getStatus(): WalletStatus { return { connected: true, @@ -88,6 +122,13 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap } }, + /** + * Emits current status immediately; no further changes occur for a server wallet. + * + * @precondition none + * @postcondition listener fires once with current (always-connected) status + * @returns unsubscribe function (no-op — status never changes) + */ onStatusChange(listener: (status: WalletStatus) => void): () => void { // Emit current status immediately so callers receive initial state without polling. listener({ @@ -101,6 +142,12 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap } }, + /** + * Signs an arbitrary message with the server wallet's private key. + * + * @precondition none (server wallet is always connected) + * @postcondition result.address matches the private key's derived address + */ async signMessage(input: SignMessageInput): Promise { const message = input.message instanceof Uint8Array ? { raw: input.message } : input.message const signature = await walletClient.signMessage({ message } as Parameters< @@ -109,6 +156,12 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap return { signature, address: account.address } }, + /** + * Signs EIP-712 typed data with the server wallet's private key. + * + * @precondition none (server wallet is always connected) + * @postcondition result.address matches the private key's derived address + */ async signTypedData(input: SignTypedDataInput): Promise { const signature = await walletClient.signTypedData({ domain: input.domain, @@ -119,10 +172,22 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap return { signature, address: account.address } }, + /** + * Returns the viem WalletClient — always available for server wallets. + * + * @precondition none + * @postcondition always returns the WalletClient (never null) + */ async getSigner(): Promise { return walletClient }, + /** + * Always throws — server wallets are bound to a single chain. + * + * @precondition none + * @throws {CapabilityNotSupportedError} always (switchChain not supported) + */ async switchChain(_chainId: string | number): Promise { throw new CapabilityNotSupportedError('switchChain') }, diff --git a/src/sdk/core/evm/transaction.test.ts b/src/sdk/core/evm/transaction.test.ts index 6288dadf..26c443c0 100644 --- a/src/sdk/core/evm/transaction.test.ts +++ b/src/sdk/core/evm/transaction.test.ts @@ -3,7 +3,7 @@ import { mainnet } from 'viem/chains' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TransactionRef } from '../adapters/transaction' -import { InsufficientFundsError, InvalidSignerError } from '../errors' +import { ChainNotSupportedError, InsufficientFundsError, InvalidSignerError } from '../errors' import { createEvmTransactionAdapter } from './transaction' import type { EvmContractCall, EvmRawTransaction } from './types' @@ -192,6 +192,25 @@ describe('createEvmTransactionAdapter', () => { ).rejects.toThrow(InvalidSignerError) }) + it('throws ChainNotSupportedError when execute targets an unsupported chain', async () => { + const adapter = createEvmTransactionAdapter({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + }) + + const mockWalletClient = { + sendTransaction: vi.fn().mockResolvedValue('0xhash'), + writeContract: vi.fn(), + } + + await expect( + adapter.execute( + { chainId: 999999, payload: { to: '0xabc' as `0x${string}` } as EvmRawTransaction }, + mockWalletClient as never, + ), + ).rejects.toThrow(ChainNotSupportedError) + }) + it('returns TransactionRef with hash for EvmRawTransaction', async () => { const adapter = createEvmTransactionAdapter({ chains: [mainnet], diff --git a/src/sdk/core/evm/transaction.ts b/src/sdk/core/evm/transaction.ts index c571c094..0c0d545b 100644 --- a/src/sdk/core/evm/transaction.ts +++ b/src/sdk/core/evm/transaction.ts @@ -16,7 +16,7 @@ import type { TransactionResult, } from '../adapters/transaction' import type { ChainSigner } from '../adapters/wallet' -import { InsufficientFundsError, InvalidSignerError } from '../errors' +import { ChainNotSupportedError, InsufficientFundsError, InvalidSignerError } from '../errors' import { fromViemChain } from './chains' import type { EvmContractCall, EvmRawTransaction, EvmTransactionPayload } from './types' @@ -41,6 +41,12 @@ function isWalletClient(signer: unknown): signer is WalletClient { /** * Creates an EVM TransactionAdapter backed by viem's PublicClient (reads) and WalletClient (writes). + * + * @precondition config.chains entries must have corresponding transports + * @postcondition returned adapter.chainType === 'evm' + * @postcondition returned adapter.supportedChains matches config.chains (mapped via fromViemChain) + * @invariant adapter.chainType never changes after construction + * @invariant adapter.supportedChains never changes after construction */ export function createEvmTransactionAdapter( config: EvmTransactionConfig = { chains: [], transports: {} }, @@ -72,6 +78,14 @@ export function createEvmTransactionAdapter( confirmationModel: 'blockConfirmations', }, + /** + * Estimates gas and validates readiness for the given transaction params. + * + * @precondition params.chainId is in supportedChains + * @postcondition if ready === true -> execute() can be called with these params + * @postcondition if ready === false -> reason explains why (human-readable) + * @throws {InsufficientFundsError} if balance too low for gas estimation + */ async prepare(params: TransactionParams): Promise { const numericId = typeof params.chainId === 'string' ? Number.parseInt(params.chainId, 10) : params.chainId @@ -138,10 +152,25 @@ export function createEvmTransactionAdapter( } }, + /** + * Submits the transaction to the network via the provided WalletClient signer. + * + * @precondition signer is a valid WalletClient for this adapter's chainType + * @precondition params.chainId is in supportedChains + * @postcondition returns TransactionRef with a unique id (tx hash) + * @postcondition the transaction has been submitted to the network (not yet confirmed) + * @throws {InvalidSignerError} if signer is not a WalletClient + * @throws {ChainNotSupportedError} if chainId not in supportedChains + */ async execute(params: TransactionParams, signer: ChainSigner): Promise { if (!isWalletClient(signer)) { throw new InvalidSignerError('WalletClient') } + const numericId = + typeof params.chainId === 'string' ? Number.parseInt(params.chainId, 10) : params.chainId + if (!publicClients.has(numericId)) { + throw new ChainNotSupportedError(params.chainId) + } const payload = params.payload as EvmTransactionPayload @@ -171,6 +200,14 @@ export function createEvmTransactionAdapter( return { chainType: 'evm', id: hash as string, chainId: params.chainId } }, + /** + * Waits for the transaction to be confirmed or times out. + * + * @precondition ref was returned by a previous execute() call on this adapter + * @postcondition result.status is 'success', 'reverted', or 'timeout' + * @postcondition if 'success' -> result.receipt contains a viem TransactionReceipt + * @throws never (timeout returns TransactionResult with status: 'timeout') + */ async confirm(ref: TransactionRef, options?: ConfirmOptions): Promise { const publicClient = getPublicClient(ref.chainId) const hash = ref.id as Hex diff --git a/src/sdk/core/evm/wallet.test.ts b/src/sdk/core/evm/wallet.test.ts index c64fb3c1..27fba669 100644 --- a/src/sdk/core/evm/wallet.test.ts +++ b/src/sdk/core/evm/wallet.test.ts @@ -292,9 +292,16 @@ describe('createEvmWalletAdapter — unit tests', () => { describe('switchChain()', () => { it('throws ChainNotSupportedError for unsupported chainId', async () => { + vi.mocked(getAccount).mockReturnValue(makeConnectedAccount()) const { adapter } = makeAdapter() await expect(adapter.switchChain(999999)).rejects.toThrow(ChainNotSupportedError) }) + + it('throws WalletNotConnectedError when switchChain is called while disconnected', async () => { + vi.mocked(getAccount).mockReturnValue(makeDisconnectedAccount()) + const { adapter } = makeAdapter() + await expect(adapter.switchChain(mainnet.id)).rejects.toThrow(WalletNotConnectedError) + }) }) // ------------------------------------------------------------------------- diff --git a/src/sdk/core/evm/wallet.tsx b/src/sdk/core/evm/wallet.tsx index 67652bd2..cf5ec341 100644 --- a/src/sdk/core/evm/wallet.tsx +++ b/src/sdk/core/evm/wallet.tsx @@ -106,6 +106,16 @@ function toWalletStatus(account: ReturnType): WalletStatus { // Factory // --------------------------------------------------------------------------- +/** + * Creates a browser-side EVM WalletAdapter backed by wagmi actions and a connector UI. + * + * @precondition config.chains.length >= 1 + * @precondition config.connector provides createConfig and WalletProvider + * @postcondition returned adapter.chainType === 'evm' + * @postcondition returned adapter.supportedChains matches config.chains (mapped via fromViemChain) + * @invariant adapter.chainType never changes after construction + * @invariant adapter.supportedChains never changes after construction + */ export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBundle { const wagmiConfig = config.wagmiConfig ?? config.connector.createConfig(config.chains, config.transports) @@ -132,6 +142,17 @@ export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBu }, }, + /** + * Connects to the first available wagmi connector. + * + * @precondition none (can be called when already connected — reconnects) + * @postcondition getStatus().connected === true + * @postcondition result.accounts.length >= 1 + * @postcondition result.activeAccount is included in result.accounts + * @throws {WalletNotInstalledError} if no connector is available + * @throws {WalletConnectionRejectedError} if user cancels + * @throws {ChainNotSupportedError} if options.chainId is not in supportedChains + */ async connect(options?: ConnectOptions): Promise { const connector = wagmiConfig.connectors[0] if (!connector) { @@ -159,6 +180,13 @@ export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBu } }, + /** + * Restores a previous wallet session on page reload. + * + * @precondition none + * @postcondition if session exists -> returns WalletConnection, getStatus().connected === true + * @postcondition if no session -> returns null, getStatus() unchanged + */ async reconnect(): Promise { const results = await reconnect(wagmiConfig) if (results.length === 0) { @@ -173,14 +201,36 @@ export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBu } }, + /** + * Disconnects the active wallet session. + * + * @precondition none (no-op if already disconnected) + * @postcondition getStatus().connected === false + * @postcondition getSigner() === null + */ async disconnect(): Promise { await disconnect(wagmiConfig) }, + /** + * Returns the current wallet connection status snapshot. + * + * @precondition none (callable at any time) + * @postcondition returns current snapshot — not reactive + * @invariant if connected === false -> activeAccount === null, connectedChainIds === [] + * @invariant if connected === true -> activeAccount !== null, connectedChainIds.length >= 1 + */ getStatus(): WalletStatus { return toWalletStatus(getAccount(wagmiConfig)) }, + /** + * Subscribes to wallet status changes (account and chain changes). + * + * @precondition none + * @postcondition listener fires on every status change + * @returns unsubscribe function — calling it stops notifications + */ onStatusChange(listener: (status: WalletStatus) => void): () => void { const unsubAccount = watchAccount(wagmiConfig, { onChange(account) { @@ -198,6 +248,14 @@ export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBu } }, + /** + * Signs an arbitrary message with the connected wallet. + * + * @precondition getStatus().connected === true + * @postcondition result.address matches the signing account + * @throws {WalletNotConnectedError} if precondition violated + * @throws {SigningRejectedError} if user cancels + */ async signMessage(input: SignMessageInput): Promise { const account = getAccount(wagmiConfig) if (!account.isConnected || !account.address) { @@ -214,6 +272,15 @@ export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBu } }, + /** + * Signs EIP-712 typed data with the connected wallet. + * + * @precondition getStatus().connected === true + * @precondition metadata.capabilities.signTypedData === true + * @postcondition result.address matches the signing account + * @throws {WalletNotConnectedError} if not connected + * @throws {SigningRejectedError} if user cancels + */ async signTypedData(input: SignTypedDataInput): Promise { const account = getAccount(wagmiConfig) if (!account.isConnected || !account.address) { @@ -234,6 +301,13 @@ export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBu } }, + /** + * Returns the chain-native signer (wagmi WalletClient) for transaction execution. + * + * @precondition none + * @postcondition if connected -> returns chain-native signer (never null) + * @postcondition if not connected -> returns null + */ async getSigner(): Promise { const account = getAccount(wagmiConfig) if (!account.isConnected) { @@ -242,7 +316,20 @@ export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBu return getWalletClient(wagmiConfig) }, + /** + * Switches the connected wallet to the specified chain. + * + * @precondition getStatus().connected === true + * @precondition chainId is in supportedChains + * @postcondition chainId is included in getStatus().connectedChainIds + * @throws {WalletNotConnectedError} if not connected + * @throws {ChainNotSupportedError} if chainId not in supportedChains + */ async switchChain(chainId: string | number): Promise { + const account = getAccount(wagmiConfig) + if (!account.isConnected) { + throw new WalletNotConnectedError() + } const numericId = typeof chainId === 'string' ? Number.parseInt(chainId, 10) : chainId const isSupported = supportedChains.some((chain) => chain.chainId === numericId) if (!isSupported) { diff --git a/src/sdk/react/hooks/useTransaction.ts b/src/sdk/react/hooks/useTransaction.ts index c2746313..fc09ff43 100644 --- a/src/sdk/react/hooks/useTransaction.ts +++ b/src/sdk/react/hooks/useTransaction.ts @@ -61,6 +61,23 @@ function fireLifecycle( /** * Executes a chain transaction through the registered TransactionAdapter, * managing phase transitions, preSteps, lifecycle hooks, and error state. + * + * @precondition must be called inside a DAppBoosterProvider + * @postcondition execute() runs the full cycle: prepare -> preSteps -> submit -> confirm + * @postcondition lifecycle hooks fire: global (from provider) first, per-transaction (from options) second + * @postcondition hook errors in lifecycle callbacks are logged but never abort the transaction + * @invariant phase transitions follow: idle -> prepare -> preStep -> submit -> confirm -> idle + * + * execute() contract: + * @precondition params.chainId must match a registered TransactionAdapter + * @precondition params.chainId must match a registered WalletAdapter + * @precondition wallet must be connected (getSigner() !== null) + * @precondition if autoPreSteps === false and preSteps exist -> throws PreStepsNotExecutedError + * @postcondition returns TransactionResult with status 'success', 'reverted', or 'timeout' + * @throws {AdapterNotFoundError} if no transaction or wallet adapter supports params.chainId + * @throws {WalletNotConnectedError} if wallet is not connected + * @throws {TransactionNotReadyError} if prepare() returns ready === false + * @throws {PreStepsNotExecutedError} if autoPreSteps === false and preSteps exist */ export function useTransaction(options: UseTransactionOptions = {}): UseTransactionReturn { const { diff --git a/src/sdk/react/hooks/useWallet.ts b/src/sdk/react/hooks/useWallet.ts index a12e3f64..c6f82848 100644 --- a/src/sdk/react/hooks/useWallet.ts +++ b/src/sdk/react/hooks/useWallet.ts @@ -75,6 +75,16 @@ interface ResolvedAdapter { key: string | null } +/** + * Resolves a single WalletAdapter from the registered adapters using the provided options. + * + * @precondition if options.adapter is set, it is used directly (bypasses provider resolution) + * @precondition if options.chainType is set, at least one adapter must match that chainType + * @precondition if options.chainId is set, at least one adapter must support that chainId + * @postcondition if exactly one adapter is registered and no options given, returns that adapter + * @throws {AdapterNotFoundError} if no adapter matches the requested chainType or chainId + * @throws {AmbiguousAdapterError} if multiple adapters exist and no disambiguating option is given + */ function resolveAdapter( walletAdapters: Record, options: UseWalletOptions, @@ -116,6 +126,14 @@ function resolveAdapter( * * Pass `chainType`, `chainId`, or `adapter` in options to disambiguate when multiple adapters * are registered. With a single adapter and no options, it resolves automatically. + * + * @precondition must be called inside a DAppBoosterProvider + * @precondition if multiple adapters registered, options must include chainType, chainId, or adapter + * @postcondition status is reactive — re-renders on every wallet status change + * @postcondition signMessage/signTypedData fire global walletLifecycle hooks from provider + * @throws {AdapterNotFoundError} if no adapter matches the requested chain + * @throws {AmbiguousAdapterError} if multiple adapters exist and no option disambiguates + * @throws {CapabilityNotSupportedError} if signTypedData called on adapter without the capability */ export function useWallet(options: UseWalletOptions = {}): UseWalletReturn { const { walletAdapters, walletLifecycle, connectModalsRef } = useProviderContext() diff --git a/src/sdk/react/provider/DAppBoosterProvider.tsx b/src/sdk/react/provider/DAppBoosterProvider.tsx index 620934ba..1653e39a 100644 --- a/src/sdk/react/provider/DAppBoosterProvider.tsx +++ b/src/sdk/react/provider/DAppBoosterProvider.tsx @@ -52,6 +52,14 @@ function ConnectModalBridge({ * the bundle's Provider tree. The resulting `open` functions are stored per * adapter key so that `useWallet` can resolve the correct modal for any adapter. * + * @precondition each wallet adapter's supportedChains entries must have chainType matching adapter.chainType + * @precondition each transaction adapter's supportedChains entries must have chainType matching adapter.chainType + * @precondition no two chain descriptors with the same caip2Id may be structurally different + * @postcondition ChainRegistry is built from config.chains + wallet + transaction adapter chains (deduped) + * @postcondition all bundle Providers are nested in the React tree + * @postcondition connect modal open functions are registered per adapter key + * @throws {Error} if any adapter's supportedChains contains a chainType mismatch + * @throws {ChainRegistryConflictError} if duplicate chainId or caip2Id with different descriptors * @note Memoize the `config` prop (e.g. with `useMemo`) to avoid rebuilding * the chain registry on every parent re-render. */