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/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/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/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/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/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} 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/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/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 } 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/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) 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",