diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..45395ccb3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +LI.FI Widget monorepo — a cross-chain DeFi swap/bridge widget supporting Ethereum, Solana, Bitcoin, and Sui ecosystems. Managed with pnpm workspaces, Lerna (independent versioning), and TypeScript composite builds. + +## Commands + +```bash +# Development +pnpm dev # Start widget-playground-vite on port 3000 +pnpm dev:next # Start Next.js playground + +# Building +pnpm build # Build all packages (libs first, then playgrounds/embedded) + +# Code Quality +pnpm check # Biome lint + format check +pnpm check:write # Auto-fix Biome issues +pnpm check:types # TypeScript check all packages in parallel +pnpm check:circular-deps # Detect circular deps via madge + +# Single-package type check +pnpm --filter @lifi/widget check:types + +# Testing (vitest) +pnpm --filter @lifi/widget test # Run widget tests +pnpm --filter @lifi/widget-light test # Run widget-light tests + +# Unused code detection +pnpm knip:check +``` + +## Architecture + +### Package Dependency Graph + +``` +@lifi/widget-provider ← base contexts (Ethereum/Solana/Bitcoin/Sui) + ↑ +@lifi/widget-provider-{ethereum,solana,bitcoin,sui} ← chain-specific implementations + ↑ +@lifi/wallet-management ← wallet UI + connection logic + ↑ +@lifi/widget ← full widget (MUI, Zustand, TanStack Router, i18next) + +@lifi/widget-light ← lightweight iframe host/guest bridge (zero dependencies) +@lifi/widget-embedded ← Vite app that runs inside the iframe (private) +``` + +### widget-light iframe bridge + +`widget-light` provides an iframe-based integration where the widget runs inside an iframe (`widget-embedded`) and communicates with the host page via `postMessage`. + +**Message flow** (defined in `packages/widget-light/src/shared/protocol.ts`): +- Guest sends `READY` → Host responds with `INIT` (config + ecosystem states) +- Host sends `CONFIG_UPDATE` when config changes after init +- Guest forwards `RPC_REQUEST` → Host routes to ecosystem handler → sends `RPC_RESPONSE` +- Host pushes wallet `EVENT`s to guest when wallet state changes +- Guest sends `WIDGET_EVENT` for subscribed events → Host dispatches to `WidgetLightEventBus` +- Host sends `WIDGET_EVENT_SUBSCRIBE`/`UNSUBSCRIBE` to control which events the guest forwards + +**Key modules**: +- `src/host/useWidgetLightHost.ts` — React hook managing the host side (handshake, RPC routing, config updates) +- `src/host/WidgetLightEventBus.ts` — Module-level singleton with ref-counted subscriptions +- `src/host/useWidgetLightEvents.ts` — Public hook returning `{ on, off }` emitter +- `src/guest/GuestBridge.ts` — Singleton managing guest-side communication +- `src/shared/widgetConfig.ts` — Zero-dependency serializable config types (no React nodes, no callbacks, no MUI types) +- `src/shared/widgetLightEvents.ts` — Serializable event types mirroring widget events + +### widget internals + +- **State**: Zustand stores in `packages/widget/src/stores/` (form, routes, chains, settings) +- **Routing**: TanStack Router with page components in `src/pages/` +- **Theming**: MUI v7 + Emotion; custom themes in `src/themes/` +- **Events**: mitt event bus (`widgetEvents` singleton in `src/hooks/useWidgetEvents.ts`) +- **i18n**: i18next with 20 language translations in `src/i18n/` + +### Provider layering (widget) + +QueryClient → Settings → WidgetConfig → I18n → Theme → SDK → Wallet → Store + +## Conventions + +- **ESM only** — all packages output to `dist/esm/`. No CJS. +- **Biome** for linting and formatting (not ESLint/Prettier). Single quotes, no semicolons, 2-space indent, trailing commas (ES5). **Always run `pnpm check:write` after making changes** so Biome can auto-fix formatting. +- **Biome sorts imports** — running `pnpm check:write` may reorder import/export statements. This is expected. +- **Conventional commits** enforced by commitlint (`feat:`, `fix:`, `chore:`, etc.). +- **`console.log` is an error** — only `console.warn` and `console.error` are allowed (except in `examples/`). +- **`useExhaustiveDependencies`** and **`useHookAtTopLevel`** are errors. +- **No unused variables or imports** — enforced as errors. +- **widget-light must have zero `dependencies`** — all types are self-contained duplicates. Chain-specific integrations are optional peer deps exposed via subpath exports (`@lifi/widget-light/ethereum`, etc.). +- Package entry points use TypeScript source (`src/index.ts`). The `scripts/formatPackageJson.js` rewrites paths to `dist/esm/` at publish time. +- TypeScript target is ES2020, module resolution is Bundler. +- **PR template** at `.github/pull_request_template.md` — always use it when creating PRs via `gh pr create`. +- `packages/widget-embedded/README.md` — main integration guide for widget-light (not a typical package readme). + +## Release + +Independent versioning via Lerna. Release flow: +1. `pnpm release:version` — bump versions +2. `pnpm release:build` — build all packages +3. `standard-version` — generate changelog +4. Git tag triggers GitHub Actions publish (`alpha`, `beta`, or `latest` npm tags) + +`scripts/version.js` generates `src/config/version.ts` per-package during build. diff --git a/examples/connectkit/package.json b/examples/connectkit/package.json index 1994ae628..60e533221 100644 --- a/examples/connectkit/package.json +++ b/examples/connectkit/package.json @@ -10,8 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "@lifi/wallet-management": "^3.22.5", - "@lifi/widget": "^3.40.6", + "@lifi/wallet-management": "^3.22.8", + "@lifi/widget": "^3.40.12", "@mui/icons-material": "^7.3.6", "@mui/material": "^7.3.6", "@solana/wallet-adapter-base": "^0.9.27", @@ -22,15 +22,15 @@ "mitt": "^3.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "viem": "^2.45.1", + "viem": "^2.47.4", "wagmi": "^2.19.4" }, "devDependencies": { "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react": "^6.0.1", "globals": "^17.3.0", "typescript": "~5.9.3", - "vite": "^7.3.0" + "vite": "^8.0.0" } } diff --git a/examples/deposit-flow/package.json b/examples/deposit-flow/package.json index 5da63a227..12726e756 100644 --- a/examples/deposit-flow/package.json +++ b/examples/deposit-flow/package.json @@ -10,24 +10,24 @@ "preview": "vite preview" }, "dependencies": { - "@lifi/sdk": "^3.15.5", - "@lifi/widget": "^3.40.6", + "@lifi/sdk": "^3.16.1", + "@lifi/widget": "^3.40.12", "@mui/material": "^7.3.6", "@tanstack/react-query": "^5.90.20", "events": "^3.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "viem": "^2.45.1", + "viem": "^2.47.4", "wagmi": "^2.19.4" }, "devDependencies": { "@types/events": "^3.0.3", - "@types/node": "^25.2.1", + "@types/node": "^25.5.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react": "^6.0.1", "typescript": "^5.9.3", - "vite": "^7.3.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "^0.25.0" } } diff --git a/examples/deposit-flow/vite.config.ts b/examples/deposit-flow/vite.config.ts index 0ebd88173..7c53eeb94 100644 --- a/examples/deposit-flow/vite.config.ts +++ b/examples/deposit-flow/vite.config.ts @@ -5,7 +5,7 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), nodePolyfills()], - esbuild: { + oxc: { target: 'esnext', }, diff --git a/examples/dynamic/package.json b/examples/dynamic/package.json index 6c566441a..3ef1e33d0 100644 --- a/examples/dynamic/package.json +++ b/examples/dynamic/package.json @@ -9,19 +9,19 @@ "preview": "vite preview" }, "dependencies": { - "@bigmi/client": "^0.7.0", - "@bigmi/core": "^0.7.0", - "@bigmi/react": "^0.7.0", - "@dynamic-labs/bitcoin": "^4.60.0", - "@dynamic-labs/ethereum": "^4.60.0", - "@dynamic-labs/ethereum-aa": "^4.60.0", - "@dynamic-labs/sdk-react-core": "^4.60.0", - "@dynamic-labs/solana": "^4.60.0", - "@dynamic-labs/solana-core": "^4.60.0", - "@dynamic-labs/wagmi-connector": "^4.60.0", - "@lifi/sdk": "^3.15.5", - "@lifi/wallet-management": "^3.22.5", - "@lifi/widget": "^3.40.6", + "@bigmi/client": "^0.7.1", + "@bigmi/core": "^0.7.1", + "@bigmi/react": "^0.7.1", + "@dynamic-labs/bitcoin": "^4.67.2", + "@dynamic-labs/ethereum": "^4.67.2", + "@dynamic-labs/ethereum-aa": "^4.67.2", + "@dynamic-labs/sdk-react-core": "^4.67.2", + "@dynamic-labs/solana": "^4.67.2", + "@dynamic-labs/solana-core": "^4.67.2", + "@dynamic-labs/wagmi-connector": "^4.67.2", + "@lifi/sdk": "^3.16.1", + "@lifi/wallet-management": "^3.22.8", + "@lifi/widget": "^3.40.12", "@mui/material": "^7.3.6", "@solana/wallet-adapter-base": "^0.9.27", "@solana/wallet-adapter-react": "^0.15.39", @@ -35,16 +35,16 @@ "mitt": "^3.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "viem": "^2.45.1", + "viem": "^2.47.4", "vite-plugin-env-compatible": "^2.0.1", "wagmi": "^2.19.4" }, "devDependencies": { "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react-swc": "^4.2.3", + "@vitejs/plugin-react": "^6.0.1", "globals": "^17.3.0", "typescript": "^5.9.3", - "vite": "^7.3.0" + "vite": "^8.0.0" } } diff --git a/examples/dynamic/vite.config.ts b/examples/dynamic/vite.config.ts index bafb011a1..8aad5587f 100644 --- a/examples/dynamic/vite.config.ts +++ b/examples/dynamic/vite.config.ts @@ -1,4 +1,4 @@ -import react from '@vitejs/plugin-react-swc' +import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import EnvCompatible from 'vite-plugin-env-compatible' diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index be89693cb..abc7660f1 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -11,15 +11,15 @@ "node": ">=21.0.0" }, "dependencies": { - "@lifi/sdk": "^3.15.5", - "@lifi/widget": "^3.40.6", + "@lifi/sdk": "^3.16.1", + "@lifi/widget": "^3.40.12", "@mui/material-nextjs": "^7.3.6", "next": "^16", "react": "^19.2.4", "react-dom": "^19.2.4" }, "devDependencies": { - "@types/node": "^25.2.1", + "@types/node": "^25.5.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "typescript": "^5.9.3" diff --git a/examples/nextjs14-page-router/package.json b/examples/nextjs14-page-router/package.json index c33755ac4..7a948b4ea 100644 --- a/examples/nextjs14-page-router/package.json +++ b/examples/nextjs14-page-router/package.json @@ -9,13 +9,13 @@ "lint": "next lint" }, "dependencies": { - "@lifi/widget": "^3.40.6", + "@lifi/widget": "^3.40.12", "next": "^14", "react": "^19.2.4", "react-dom": "^19.2.4" }, "devDependencies": { - "@types/node": "^25.2.1", + "@types/node": "^25.5.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "eslint": "^8", diff --git a/examples/nextjs14/package.json b/examples/nextjs14/package.json index 79572b907..c32f0952e 100644 --- a/examples/nextjs14/package.json +++ b/examples/nextjs14/package.json @@ -9,15 +9,15 @@ "lint": "next lint" }, "dependencies": { - "@lifi/sdk": "^3.15.5", - "@lifi/widget": "^3.40.6", + "@lifi/sdk": "^3.16.1", + "@lifi/widget": "^3.40.12", "@mui/material-nextjs": "^7.3.6", "next": "^14", "react": "^19.2.4", "react-dom": "^19.2.4" }, "devDependencies": { - "@types/node": "^25.2.1", + "@types/node": "^25.5.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "eslint": "^8", diff --git a/examples/nextjs15/package.json b/examples/nextjs15/package.json index 2e5ef0e5a..153044d90 100644 --- a/examples/nextjs15/package.json +++ b/examples/nextjs15/package.json @@ -11,15 +11,15 @@ "node": ">=21.0.0" }, "dependencies": { - "@lifi/sdk": "^3.15.5", - "@lifi/widget": "^3.40.6", + "@lifi/sdk": "^3.16.1", + "@lifi/widget": "^3.40.12", "@mui/material-nextjs": "^7.3.6", "next": "^15", "react": "^19.2.4", "react-dom": "^19.2.4" }, "devDependencies": { - "@types/node": "^25.2.1", + "@types/node": "^25.5.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "typescript": "^5.9.3" diff --git a/examples/nft-checkout/package.json b/examples/nft-checkout/package.json index d2d223caf..7e1a79406 100644 --- a/examples/nft-checkout/package.json +++ b/examples/nft-checkout/package.json @@ -13,7 +13,7 @@ }, "author": "Eugene Chybisov ", "dependencies": { - "@lifi/sdk": "^4.0.0-alpha.13", + "@lifi/sdk": "4.0.0-beta.0", "@lifi/wallet-management": "workspace:*", "@lifi/widget": "workspace:*", "@lifi/widget-provider-bitcoin": "workspace:*", @@ -23,7 +23,7 @@ "@mui/icons-material": "^7.3.6", "@mui/material": "^7.3.6", "@mui/system": "^7.3.6", - "@opensea/seaport-js": "4.0.6", + "@opensea/seaport-js": "4.0.7", "@tanstack/react-query": "^5.90.20", "bignumber.js": "^9.3.0", "ethers": "^6.16.0", @@ -31,15 +31,14 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.0", - "viem": "^2.45.1", - "wagmi": "^3.1.0" + "viem": "^2.47.4", + "wagmi": "^3.5.0" }, "devDependencies": { - "@esbuild-plugins/node-globals-polyfill": "^0.2.3", - "@vitejs/plugin-react-swc": "^4.2.3", + "@vitejs/plugin-react": "^6.0.1", "source-map-explorer": "^2.5.3", "typescript": "^5.9.3", - "vite": "^7.3.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "0.25.0", "web-vitals": "^5.1.0" }, diff --git a/examples/nft-checkout/vite.config.ts b/examples/nft-checkout/vite.config.ts index 1dfef3f6a..c4f3c705a 100644 --- a/examples/nft-checkout/vite.config.ts +++ b/examples/nft-checkout/vite.config.ts @@ -1,30 +1,16 @@ -import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill' -import react from '@vitejs/plugin-react-swc' +import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import { nodePolyfills } from 'vite-plugin-node-polyfills' // https://vitejs.dev/config/ export default defineConfig({ plugins: [nodePolyfills(), react()], - esbuild: { + oxc: { target: 'esnext', }, build: { sourcemap: true, }, - optimizeDeps: { - esbuildOptions: { - define: { - global: 'globalThis', - }, - plugins: [ - NodeGlobalsPolyfillPlugin({ - process: true, - buffer: true, - }), - ], - }, - }, server: { port: 3000, open: true, diff --git a/examples/nuxt/package.json b/examples/nuxt/package.json index 9001ec9b6..d1b1df20e 100644 --- a/examples/nuxt/package.json +++ b/examples/nuxt/package.json @@ -10,11 +10,11 @@ "postinstall": "nuxt prepare" }, "dependencies": { - "@lifi/widget": "^3.40.6", + "@lifi/widget": "^3.40.12", "nuxt": "3.17.7", "veaury": "^2.6.3", "vite-plugin-node-polyfills": "^0.25.0", - "vue": "^3.5.27", + "vue": "^3.5.30", "vue-router": "^4.6.4" } } diff --git a/examples/privy-ethers/package.json b/examples/privy-ethers/package.json index ccc68ebb2..56827466f 100644 --- a/examples/privy-ethers/package.json +++ b/examples/privy-ethers/package.json @@ -10,8 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "@lifi/wallet-management": "^3.22.5", - "@lifi/widget": "^3.40.6", + "@lifi/wallet-management": "^3.22.8", + "@lifi/widget": "^3.40.12", "@mui/icons-material": "^7.3.6", "@mui/material": "^7.3.6", "@privy-io/react-auth": "^2.25.0", @@ -24,21 +24,21 @@ "mitt": "^3.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "viem": "^2.45.1", + "viem": "^2.47.4", "wagmi": "^2.19.4" }, "devDependencies": { "@eslint/js": "^9.39.2", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.34.0", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.26", + "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.3.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.54.0", - "vite": "^7.3.0", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "^0.25.0" } } diff --git a/examples/privy/package.json b/examples/privy/package.json index f4c6e1c02..08ef69a7a 100644 --- a/examples/privy/package.json +++ b/examples/privy/package.json @@ -10,13 +10,13 @@ "preview": "vite preview" }, "dependencies": { - "@lifi/wallet-management": "^3.22.5", - "@lifi/widget": "^3.40.6", + "@lifi/wallet-management": "^3.22.8", + "@lifi/widget": "^3.40.12", "@mui/icons-material": "^7.3.6", "@mui/material": "^7.3.6", "@privy-io/react-auth": "^2.25.0", "@privy-io/wagmi": "^1.0.6", - "@solana/kit": "^5.5.0", + "@solana/kit": "^6.3.1", "@solana/wallet-adapter-base": "^0.9.27", "@solana/wallet-adapter-react": "^0.15.39", "@solana/web3.js": "^1.98.4", @@ -25,17 +25,17 @@ "permissionless": "^0.2.57", "react": "^19.2.4", "react-dom": "^19.2.4", - "viem": "^2.45.1", + "viem": "^2.47.4", "wagmi": "^2.19.4" }, "devDependencies": { "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react": "^6.0.1", "globals": "^17.3.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.54.0", - "vite": "^7.3.0", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "^0.25.0" } } diff --git a/examples/rainbowkit/package.json b/examples/rainbowkit/package.json index f27bcea6d..70ee7f249 100644 --- a/examples/rainbowkit/package.json +++ b/examples/rainbowkit/package.json @@ -9,21 +9,21 @@ "preview": "vite preview" }, "dependencies": { - "@lifi/wallet-management": "^3.22.5", - "@lifi/widget": "^3.40.6", + "@lifi/wallet-management": "^3.22.8", + "@lifi/widget": "^3.40.12", "@rainbow-me/rainbowkit": "^2.2.10", "@tanstack/react-query": "^5.90.20", "react": "^19.2.4", "react-dom": "^19.2.4", - "viem": "^2.45.1", + "viem": "^2.47.4", "wagmi": "^2.19.4" }, "devDependencies": { "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react-swc": "^4.2.3", + "@vitejs/plugin-react": "^6.0.1", "typescript": "^5.9.3", - "vite": "^7.3.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "^0.25.0" } } diff --git a/examples/rainbowkit/vite.config.ts b/examples/rainbowkit/vite.config.ts index 8bac88d3b..6b5562be1 100644 --- a/examples/rainbowkit/vite.config.ts +++ b/examples/rainbowkit/vite.config.ts @@ -1,4 +1,4 @@ -import react from '@vitejs/plugin-react-swc' +import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import { nodePolyfills } from 'vite-plugin-node-polyfills' diff --git a/examples/react-router-7/package.json b/examples/react-router-7/package.json index 54479b5f5..8b0d08b23 100644 --- a/examples/react-router-7/package.json +++ b/examples/react-router-7/package.json @@ -10,10 +10,10 @@ "typecheck": "react-router typegen && tsc" }, "dependencies": { - "@lifi/widget": "^3.40.6", + "@lifi/widget": "^3.40.12", "@react-router/node": "^7.13.0", "@react-router/serve": "^7.13.0", - "isbot": "^5.1.34", + "isbot": "^5.1.36", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router": "^7.13.0", @@ -26,8 +26,8 @@ "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "typescript": "^5.9.3", - "vite": "^7.3.0", - "vite-tsconfig-paths": "^5.1.4" + "vite": "^8.0.0", + "vite-tsconfig-paths": "^6.1.1" }, "engines": { "node": ">=18.0.0" diff --git a/examples/remix/package.json b/examples/remix/package.json index e4d745720..5bb201534 100644 --- a/examples/remix/package.json +++ b/examples/remix/package.json @@ -10,23 +10,23 @@ "typecheck": "tsc" }, "dependencies": { - "@lifi/widget": "^3.40.6", + "@lifi/widget": "^3.40.12", "@remix-run/css-bundle": "^2.17.2", "@remix-run/node": "^2.17.2", "@remix-run/react": "^2.17.2", "@remix-run/serve": "^2.17.2", - "isbot": "^5.1.34", + "isbot": "^5.1.36", "react": "^19.2.4", "react-dom": "^19.2.4", - "remix-utils": "^9.0.1" + "remix-utils": "^9.3.1" }, "devDependencies": { "@remix-run/dev": "^2.17.2", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "typescript": "^5.9.3", - "vite": "^7.3.0", - "vite-tsconfig-paths": "^5.1.4" + "vite": "^8.0.0", + "vite-tsconfig-paths": "^6.1.1" }, "engines": { "node": ">=18.0.0" diff --git a/examples/reown/package.json b/examples/reown/package.json index b152e5ce7..8375dc25f 100644 --- a/examples/reown/package.json +++ b/examples/reown/package.json @@ -10,10 +10,13 @@ "preview": "vite preview" }, "dependencies": { - "@lifi/wallet-management": "^3.22.5", - "@lifi/widget": "^3.40.6", + "@bigmi/client": "^0.7.1", + "@bigmi/core": "^0.7.1", + "@bigmi/react": "^0.7.1", + "@lifi/wallet-management": "^3.22.8", + "@lifi/widget": "^3.40.12", "@mui/material": "^7.3.6", - "@reown/appkit": ">=1.8.17", + "@reown/appkit": ">=1.8.18", "@reown/appkit-adapter-bitcoin": "^1.8.17", "@reown/appkit-adapter-solana": "^1.8.17", "@reown/appkit-adapter-wagmi": "^1.8.17", @@ -25,19 +28,16 @@ "mitt": "^3.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "viem": "^2.45.1", - "wagmi": "^3.3.2", - "@bigmi/client": "^0.7.0", - "@bigmi/core": "^0.7.0", - "@bigmi/react": "^0.7.0" + "viem": "^2.47.4", + "wagmi": "^3.5.0" }, "devDependencies": { "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react": "^6.0.1", "globals": "^17.3.0", "typescript": "~5.9.3", - "vite": "^7.3.0", + "vite": "^8.0.0", "vite-plugin-env-compatible": "^2.0.1" } } diff --git a/examples/svelte/package.json b/examples/svelte/package.json index 7e61307e1..0c0335475 100644 --- a/examples/svelte/package.json +++ b/examples/svelte/package.json @@ -13,19 +13,19 @@ "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tsconfig/svelte": "^5.0.7", "@types/events": "^3.0.3", - "@types/node": "^25.2.1", + "@types/node": "^25.5.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "svelte": "^5.49.2", - "svelte-check": "^4.3.6", + "svelte": "^5.53.12", + "svelte-check": "^4.4.5", "svelte-preprocess": "^6.0.3", "tslib": "^2.8.1", "typescript": "^5.9.3", - "vite": "^7.3.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "^0.25.0" }, "dependencies": { - "@lifi/widget": "^3.40.6", + "@lifi/widget": "^3.40.12", "react": "^19.2.4", "react-dom": "^19.2.4" } diff --git a/examples/svelte/vite.config.ts b/examples/svelte/vite.config.ts index 6fd31bd74..23cbb4d38 100644 --- a/examples/svelte/vite.config.ts +++ b/examples/svelte/vite.config.ts @@ -5,7 +5,7 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills' // https://vitejs.dev/config/ export default defineConfig({ plugins: [svelte(), nodePolyfills()], - esbuild: { + oxc: { target: 'esnext', }, server: { diff --git a/examples/tanstack-router/package.json b/examples/tanstack-router/package.json index 90c47510c..9da63e595 100644 --- a/examples/tanstack-router/package.json +++ b/examples/tanstack-router/package.json @@ -11,16 +11,16 @@ "dependencies": { "@lifi/widget": "workspace:*", "@tanstack/react-query": "^5.90.20", - "@tanstack/react-router": "^1.158.1", + "@tanstack/react-router": "^1.167.3", "react": "^19.2.4", "react-dom": "^19.2.4" }, "devDependencies": { - "@types/node": "^25.2.1", + "@types/node": "^25.5.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react": "^6.0.1", "typescript": "^5.9.3", - "vite": "^7.3.0" + "vite": "^8.0.0" } } diff --git a/examples/tanstack-router/vite.config.ts b/examples/tanstack-router/vite.config.ts index 12f801bdd..cc08567b8 100644 --- a/examples/tanstack-router/vite.config.ts +++ b/examples/tanstack-router/vite.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from 'vite' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], - esbuild: { + oxc: { target: 'esnext', }, server: { diff --git a/examples/vite-iframe-wagmi/package.json b/examples/vite-iframe-wagmi/package.json index 1b9fc55ac..f31db9c34 100644 --- a/examples/vite-iframe-wagmi/package.json +++ b/examples/vite-iframe-wagmi/package.json @@ -13,14 +13,14 @@ "@tanstack/react-query": "^5.90.20", "react": "^19.2.4", "react-dom": "^19.2.4", - "viem": "^2.45.1", - "wagmi": "^3.4.2" + "viem": "^2.47.4", + "wagmi": "^3.5.0" }, "devDependencies": { "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react": "^6.0.1", "typescript": "^5.9.3", - "vite": "^7.3.0" + "vite": "^8.0.0" } } diff --git a/examples/vite-iframe-wagmi/src/App.tsx b/examples/vite-iframe-wagmi/src/App.tsx index 464ae8d29..b2a98eab3 100644 --- a/examples/vite-iframe-wagmi/src/App.tsx +++ b/examples/vite-iframe-wagmi/src/App.tsx @@ -4,9 +4,6 @@ import { useMemo } from 'react' import { WalletHeader } from './components/WalletHeader' import { widgetConfig } from './widgetConfig' -const WIDGET_URL = import.meta.env.VITE_WIDGET_URL || 'https://widget.li.fi' -const WIDGET_ORIGIN = new URL(WIDGET_URL).origin - export function HostApp() { const ethHandler = useEthereumIframeHandler() const handlers = useMemo(() => [ethHandler], [ethHandler]) @@ -17,10 +14,8 @@ export function HostApp() {
=4.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "viem": "^2.45.1", - "wagmi": "^3.4.2" + "viem": "^2.47.4", + "wagmi": "^3.5.0" }, "devDependencies": { - "@types/node": "^25.2.1", + "@types/node": "^25.5.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react": "^6.0.1", "typescript": "^5.9.3", - "vite": "^7.3.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "^0.25.0" } } diff --git a/examples/vite-iframe/src/App.tsx b/examples/vite-iframe/src/App.tsx index be6582ce3..4942ba740 100644 --- a/examples/vite-iframe/src/App.tsx +++ b/examples/vite-iframe/src/App.tsx @@ -1,4 +1,5 @@ -import { useAccount } from '@lifi/wallet-management' +import { useAccount, useWalletMenu } from '@lifi/wallet-management' +import type { ConnectWalletArgs } from '@lifi/widget-light' import { LiFiWidgetLight } from '@lifi/widget-light' import { useBitcoinIframeHandler } from '@lifi/widget-light/bitcoin' import { useEthereumIframeHandler } from '@lifi/widget-light/ethereum' @@ -8,17 +9,44 @@ import { useSolanaWalletStandard, useWalletAccount, } from '@lifi/widget-provider-solana' -import { Box, Typography } from '@mui/material' -import { useMemo } from 'react' +import { Box, Button, Typography } from '@mui/material' +import { useCallback, useMemo, useState } from 'react' import { WalletHeader } from './components/WalletHeader' -import { widgetConfig } from './widgetConfig' +import { WidgetEventsLogger } from './components/WidgetEventsLogger' +import { widgetConfig as baseWidgetConfig } from './widgetConfig' -const WIDGET_URL = import.meta.env.VITE_WIDGET_URL || 'https://widget.li.fi' -const WIDGET_ORIGIN = new URL(WIDGET_URL).origin +// When VITE_WIDGET_URL is set (e.g. via --mode localhost), override the default. +const WIDGET_URL = import.meta.env.VITE_WIDGET_URL || undefined export function HostApp() { const { account } = useAccount() + const { openWalletMenu } = useWalletMenu() + // -- Config reactivity demo: toggle variant between 'wide' and 'compact' -- + const [variant, setVariant] = useState<'wide' | 'compact'>('wide') + const toggleVariant = useCallback( + () => setVariant((v) => (v === 'wide' ? 'compact' : 'wide')), + [] + ) + + // -- External wallet connect: open host wallet modal on iframe request -- + const handleConnect = useCallback( + (args?: ConnectWalletArgs) => { + console.log('[HostApp] onConnect request from widget', args) + openWalletMenu() + }, + [openWalletMenu] + ) + + const widgetConfig = useMemo( + () => ({ + ...baseWidgetConfig, + variant, + }), + [variant] + ) + + // -- Ecosystem handlers -- const ethHandler = useEthereumIframeHandler() const { selectedWallet, connected } = useSolanaWalletStandard() @@ -56,16 +84,24 @@ export function HostApp() { )} + + + + + + ) } diff --git a/examples/vite-iframe/src/components/WidgetEventsLogger.tsx b/examples/vite-iframe/src/components/WidgetEventsLogger.tsx new file mode 100644 index 000000000..f0a900e61 --- /dev/null +++ b/examples/vite-iframe/src/components/WidgetEventsLogger.tsx @@ -0,0 +1,37 @@ +import { + useWidgetLightEvents, + WidgetLightEvent, + type WidgetLightEvents, +} from '@lifi/widget-light' +import { useEffect } from 'react' + +/** + * Subscribes to all widget-light events and logs them to the console. + * Demonstrates that `useWidgetLightEvents` can be used in any component, + * independent of where `` is mounted. + */ +export function WidgetEventsLogger() { + const events = useWidgetLightEvents() + + useEffect(() => { + const eventNames = Object.values(WidgetLightEvent) as Array< + keyof WidgetLightEvents + > + + const handlers = eventNames.map((eventName) => { + const handler = (data: WidgetLightEvents[typeof eventName]) => { + console.log(`[WidgetLightEvent] ${eventName}`, data) + } + events.on(eventName, handler) + return { eventName, handler } + }) + + return () => { + for (const { eventName, handler } of handlers) { + events.off(eventName, handler) + } + } + }, [events]) + + return null +} diff --git a/examples/vite-iframe/vite.config.ts b/examples/vite-iframe/vite.config.ts index 28ce9a2df..98fae4e14 100644 --- a/examples/vite-iframe/vite.config.ts +++ b/examples/vite-iframe/vite.config.ts @@ -6,7 +6,7 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills' // The guest (iframe) page is served from VITE_WIDGET_URL (default: https://widget.li.fi). export default defineConfig({ plugins: [nodePolyfills(), react()], - esbuild: { + oxc: { target: 'esnext', }, server: { diff --git a/examples/vite/package.json b/examples/vite/package.json index 60417c9ab..9b0ef7435 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -10,26 +10,26 @@ "preview": "vite preview" }, "dependencies": { - "@lifi/sdk": "^3.15.5", - "@lifi/wallet-management": "^3.22.5", - "@lifi/widget": "^3.40.6", + "@lifi/sdk": "^3.16.1", + "@lifi/wallet-management": "^3.22.8", + "@lifi/widget": "^3.40.12", "@mui/material": "^7.3.6", "@tanstack/react-query": "^5.90.20", "@wagmi/connectors": "^7.1.6", "events": "^3.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "viem": "^2.45.1", + "viem": "^2.47.4", "wagmi": "^2.19.4" }, "devDependencies": { "@types/events": "^3.0.3", - "@types/node": "^25.2.1", + "@types/node": "^25.5.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react": "^6.0.1", "typescript": "^5.9.3", - "vite": "^7.3.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "^0.25.0" } } diff --git a/examples/vite/vite.config.ts b/examples/vite/vite.config.ts index 0ebd88173..7c53eeb94 100644 --- a/examples/vite/vite.config.ts +++ b/examples/vite/vite.config.ts @@ -5,7 +5,7 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), nodePolyfills()], - esbuild: { + oxc: { target: 'esnext', }, diff --git a/examples/vue/package.json b/examples/vue/package.json index 78d5255a4..c579c131e 100644 --- a/examples/vue/package.json +++ b/examples/vue/package.json @@ -9,16 +9,16 @@ "preview": "vite preview" }, "dependencies": { - "@lifi/widget": "^3.40.6", + "@lifi/widget": "^3.40.12", "veaury": "^2.6.3", - "vue": "^3.5.27" + "vue": "^3.5.30" }, "devDependencies": { - "@vitejs/plugin-react": "^5.1.3", - "@vitejs/plugin-vue": "^6.0.4", - "@vitejs/plugin-vue-jsx": "^5.1.4", + "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-vue": "^6.0.5", + "@vitejs/plugin-vue-jsx": "^5.1.5", "typescript": "^5.9.3", - "vite": "^7.3.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "^0.25.0", "vue-tsc": "^3.2.4" } diff --git a/examples/vue/vite.config.ts b/examples/vue/vite.config.ts index e76ebe183..c4f210e24 100644 --- a/examples/vue/vite.config.ts +++ b/examples/vue/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ }), nodePolyfills(), ], - esbuild: { + oxc: { target: 'esnext', }, server: { diff --git a/examples/zustand-widget-config/package.json b/examples/zustand-widget-config/package.json index 42cca7bdc..1233399d3 100644 --- a/examples/zustand-widget-config/package.json +++ b/examples/zustand-widget-config/package.json @@ -9,21 +9,21 @@ "preview": "vite preview" }, "dependencies": { - "@lifi/widget": "^3.40.6", + "@lifi/widget": "^3.40.12", "@mui/material": "^7.3.6", "@tanstack/react-query": "^5.90.20", "react": "^19.2.4", "react-dom": "^19.2.4", "wagmi": "^2.19.4", - "zustand": "^5.0.11" + "zustand": "^5.0.12" }, "devDependencies": { "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react": "^6.0.1", "globals": "^17.3.0", "typescript": "^5.9.3", - "vite": "^7.3.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "^0.25.0" } } diff --git a/knip.json b/knip.json index d51617671..f5c0d9431 100644 --- a/knip.json +++ b/knip.json @@ -9,7 +9,7 @@ "viem", "@bigmi/client", "@bigmi/react", - "@mysten/dapp-kit", + "@mysten/dapp-kit-react", "@wallet-standard/base" ] }, diff --git a/package.json b/package.json index ae311babf..d07d89fb3 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "4.0.0-alpha.2", + "version": "4.0.0-alpha.3", "private": true, "sideEffects": false, "type": "module", @@ -54,17 +54,17 @@ } }, "devDependencies": { - "@biomejs/biome": "^2.3.14", - "@commitlint/cli": "^20.4.1", - "@commitlint/config-conventional": "^20.4.1", - "@types/node": "^25.2.1", + "@biomejs/biome": "^2.4.7", + "@commitlint/cli": "^20.5.0", + "@commitlint/config-conventional": "^20.5.0", + "@types/node": "^25.5.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "fs-extra": "^11.3.3", "husky": "^9.1.7", - "knip": "^5.83.0", - "lerna": "^9.0.3", - "lint-staged": "^16.2.7", + "knip": "^5.87.0", + "lerna": "^9.0.7", + "lint-staged": "^16.4.0", "standard-version": "^9.5.0", "typescript": "^5.9.3" }, @@ -91,5 +91,5 @@ "zod": ">=4.1.11" } }, - "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017" + "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" } diff --git a/packages/wallet-management/package.json b/packages/wallet-management/package.json index 9cbbaf389..19f449aec 100644 --- a/packages/wallet-management/package.json +++ b/packages/wallet-management/package.json @@ -1,6 +1,6 @@ { "name": "@lifi/wallet-management", - "version": "4.0.0-alpha.2", + "version": "4.0.0-alpha.3", "description": "LI.FI Wallet Management solution.", "type": "module", "main": "./src/index.ts", @@ -46,15 +46,15 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@lifi/sdk": "^4.0.0-alpha.20", + "@lifi/sdk": "4.0.0-beta.0", "@lifi/widget-provider": "workspace:*", "@mui/icons-material": "^7.3.6", "@mui/material": "^7.3.6", "@mui/system": "^7.3.6", - "i18next": "^25.8.4", + "i18next": "^25.8.18", "mitt": "^3.0.1", - "react-i18next": "^16.5.4", - "zustand": "^5.0.11" + "react-i18next": "^16.5.8", + "zustand": "^5.0.12" }, "devDependencies": { "cpy-cli": "^7.0.0", diff --git a/packages/wallet-management/src/hooks/useAccount.ts b/packages/wallet-management/src/hooks/useAccount.ts index ae5801202..45f27f2a7 100644 --- a/packages/wallet-management/src/hooks/useAccount.ts +++ b/packages/wallet-management/src/hooks/useAccount.ts @@ -90,11 +90,12 @@ export const useAccount = (args?: UseAccountArgs): AccountResult => { }) || connectedAccounts[0] : connectedAccounts[0] - return { + const result = { account: selectedChainTypeAccount || selectedAccount || defaultAccount, // We need to return only connected account list accounts: connectedAccounts, } + return result }, [ solanaAccount?.address, solanaAccount?.status, diff --git a/packages/widget-embedded/README.md b/packages/widget-embedded/README.md index 51e546b23..acd8db8d2 100644 --- a/packages/widget-embedded/README.md +++ b/packages/widget-embedded/README.md @@ -1,35 +1,464 @@ -# LI.FI Widget NFT Checkout +# LI.FI Widget Light — Integration Guide -The demo of the LI.FI Widget NFT Checkout based on the OpenSea API. +LI.FI Widget Light (`@lifi/widget-light`) lets you embed the full LI.FI cross-chain swap and bridge widget into your application via an iframe, while keeping wallet connections on your side. Your users sign transactions with their existing wallet — the widget never touches browser extensions or private keys directly. -### How to run? +## Features +- **Iframe isolation** — the widget runs in a sandboxed iframe; your page stays in full control +- **Multi-ecosystem support** — Ethereum (EVM), Solana, Bitcoin, and Sui out of the box +- **Your wallet, your UX** — transactions are signed by wallets you already manage (wagmi, wallet-standard, bigmi, dapp-kit) +- **Zero core dependencies** — `@lifi/widget-light` ships nothing beyond React as a peer dep; chain-specific handlers are tree-shakeable subpath imports +- **Reactive configuration** — update config at any time without reloading the iframe +- **Typed event system** — subscribe to route execution, wallet, and UI events with full TypeScript support + +## How It Works + +``` +┌─────────────────────────────────┐ ┌────────────────────────────────┐ +│ YOUR APPLICATION │ │ IFRAME (widget.li.fi) │ +│ │ │ │ +│ LiFiWidgetLight component │◄─────►│ Full LI.FI Widget │ +│ + Ecosystem handlers (wagmi…) │ post │ Receives config, sends RPC │ +│ + Event subscriptions │ Msg │ requests back to your wallets │ +└─────────────────────────────────┘ └────────────────────────────────┘ +``` + +1. Your app renders `` pointing at the hosted widget URL +2. The iframe sends a `READY` signal; your app responds with config + wallet state +3. When the widget needs a signature or chain switch, it sends an RPC request via `postMessage` +4. Your ecosystem handler (e.g. wagmi) executes the request and returns the result +5. Wallet state changes (account switch, network change) are pushed to the iframe automatically + +## Quick Start — EVM Only (wagmi) + +This is the minimal setup for EVM chains using wagmi. See [Full Multi-Ecosystem Setup](#full-multi-ecosystem-setup) for all chains. + +### 1. Install + +```bash +pnpm add @lifi/widget-light wagmi viem @wagmi/core @tanstack/react-query +``` + +### 2. Configure your wallet provider + +Set up wagmi as you normally would. Widget Light reads wallet state from your existing wagmi context. + +```tsx +// providers/WalletProvider.tsx +import type { FC, PropsWithChildren } from 'react' +import { createClient, http } from 'viem' +import { arbitrum, base, mainnet, optimism, polygon } from 'viem/chains' +import { createConfig, WagmiProvider } from 'wagmi' +import { injected } from 'wagmi/connectors' + +const config = createConfig({ + chains: [mainnet, arbitrum, optimism, base, polygon], + connectors: [injected()], + client({ chain }) { + return createClient({ chain, transport: http() }) + }, + multiInjectedProviderDiscovery: true, + ssr: false, +}) + +export const WalletProvider: FC = ({ children }) => ( + {children} +) +``` + +### 3. Set up the provider tree + +```tsx +// main.tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import ReactDOM from 'react-dom/client' +import { App } from './App' +import { WalletProvider } from './providers/WalletProvider' + +const queryClient = new QueryClient() + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +) +``` + +### 4. Render the widget + +```tsx +// App.tsx +import { LiFiWidgetLight } from '@lifi/widget-light' +import type { WidgetLightConfig } from '@lifi/widget-light' +import { useEthereumIframeHandler } from '@lifi/widget-light/ethereum' +import { useMemo } from 'react' + +const widgetConfig: WidgetLightConfig = { + integrator: 'your-project-name', // Required — identifies you in LI.FI analytics + variant: 'wide', // 'compact' | 'wide' | 'drawer' + theme: { + container: { + border: '1px solid rgb(234, 234, 234)', + borderRadius: '16px', + }, + }, + sdkConfig: { + routeOptions: { + maxPriceImpact: 0.4, + }, + }, +} + +export function App() { + const ethHandler = useEthereumIframeHandler() + const handlers = useMemo(() => [ethHandler], [ethHandler]) + + return ( + + ) +} +``` + +That's it. The widget renders inside an iframe and all EVM transactions are signed through your wagmi-managed wallet. + +## Full Multi-Ecosystem Setup + +To support Solana, Bitcoin, and Sui alongside EVM, install the additional handler peer dependencies and pass all handlers to the widget. + +### Install + +```bash +# Core +pnpm add @lifi/widget-light @tanstack/react-query + +# EVM +pnpm add wagmi viem @wagmi/core + +# Solana (optional) +pnpm add @wallet-standard/base + +# Bitcoin (optional) +pnpm add @bigmi/client @bigmi/react + +# Sui (optional) +pnpm add @mysten/dapp-kit-react +``` + +### Create handlers for each ecosystem + +```tsx +import { LiFiWidgetLight } from '@lifi/widget-light' +import { useEthereumIframeHandler } from '@lifi/widget-light/ethereum' +import { useSolanaIframeHandler } from '@lifi/widget-light/solana' +import { useBitcoinIframeHandler } from '@lifi/widget-light/bitcoin' +import { useSuiIframeHandler } from '@lifi/widget-light/sui' +import { useMemo, useCallback } from 'react' + +export function App() { + // EVM — reads wallet state from wagmi context automatically + const ethHandler = useEthereumIframeHandler() + + // Solana — pass wallet state explicitly (library-agnostic) + const solHandler = useSolanaIframeHandler({ + address: solanaAddress, // string | undefined + connected: solanaConnected, // boolean + wallet: solanaWallet, // Wallet from @wallet-standard/base + }) + + // Bitcoin — reads from @bigmi/react context automatically + const btcHandler = useBitcoinIframeHandler() + + // Sui — reads from @mysten/dapp-kit-react context automatically + const suiHandler = useSuiIframeHandler() + + const handlers = useMemo( + () => [ethHandler, solHandler, btcHandler, suiHandler], + [ethHandler, solHandler, btcHandler, suiHandler] + ) + + return ( + + ) +} +``` + +> Only include the handlers you need. If you only support EVM and Solana, pass `[ethHandler, solHandler]`. Unused ecosystem packages are fully tree-shaken. + +## Component Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `src` | `string` | No | `'https://widget.li.fi'` | URL of the hosted widget | +| `config` | `WidgetLightConfig` | Yes | — | JSON-serializable widget configuration | +| `handlers` | `IframeEcosystemHandler[]` | No | `[]` | Ecosystem handlers for wallet/RPC bridging | +| `iframeOrigin` | `string` | No | Derived from `src` | Restrict `postMessage` to this origin | +| `autoResize` | `boolean` | No | `false` | When true, iframe height auto-adjusts to match content | +| `onConnect` | `(args?: ConnectWalletArgs) => void` | No | — | Called when the widget requests a wallet connection (external wallet management) | +| `style` | `CSSProperties` | No | — | Inline styles for the iframe element | +| `className` | `string` | No | — | CSS class for the iframe element | +| `title` | `string` | No | `'LI.FI Widget'` | Accessible title for the iframe | + +## Configuration + +`WidgetLightConfig` must be **JSON-serializable** — no React nodes, no callback functions, no MUI theme objects. + +### Required + +| Field | Type | Description | +|-------|------|-------------| +| `integrator` | `string` | Your project identifier for LI.FI analytics | + +### Common Options + +```ts +const config: WidgetLightConfig = { + integrator: 'your-project-name', + + // Layout + variant: 'wide', // 'compact' | 'wide' | 'drawer' + subvariant: 'default', // 'default' | 'split' | 'custom' | 'refuel' + appearance: 'light', // 'light' | 'dark' | 'system' + + // Pre-fill form + fromChain: 1, // Source chain ID + toChain: 42161, // Destination chain ID + fromToken: '0x...', // Source token address + toToken: '0x...', // Destination token address + fromAmount: '10', // Pre-filled amount + + // Theming (CSS properties only — no MUI) + theme: { + container: { + border: '1px solid #eaeaea', + borderRadius: '16px', + }, + }, + + // API and routing + apiKey: 'your-api-key', + fee: 0.03, // Integrator fee (0-1) + sdkConfig: { + routeOptions: { + maxPriceImpact: 0.4, + }, + }, + + // Filter chains, bridges, exchanges + chains: { + allow: [1, 137, 42161], // Only show these chains + }, + bridges: { + deny: ['stargate'], // Hide specific bridges + }, + + // UI controls + hiddenUI: ['appearance', 'language'], + disabledUI: ['toAddress'], + requiredUI: ['toAddress'], +} +``` + +### Reactive Config Updates + +Configuration is reactive. When you pass a new `config` object, the widget updates without reloading the iframe. + +```tsx +const [variant, setVariant] = useState<'wide' | 'compact'>('wide') + +const widgetConfig = useMemo( + () => ({ ...baseConfig, variant }), + [variant] +) + +// Changing `variant` state updates the widget in real-time + +``` + +## External Wallet Management + +If your app has its own wallet connection UI, pass `onConnect` to handle wallet connection requests from the widget. The widget will call your handler instead of opening its built-in wallet menu. + +```tsx +import type { ConnectWalletArgs } from '@lifi/widget-light' + +function App() { + const handleConnect = useCallback((args?: ConnectWalletArgs) => { + // Open your wallet modal/dialog + openYourWalletModal() + }, []) + + return ( + + ) +} ``` -pnpm dev + +## Events + +Subscribe to widget events from any component using the `useWidgetLightEvents` hook. No provider wrapping required — the event bus is a module-level singleton. + +```tsx +import { + useWidgetLightEvents, + WidgetLightEvent, + type WidgetLightRouteExecutionUpdate, +} from '@lifi/widget-light' +import { useEffect } from 'react' + +function TransactionTracker() { + const events = useWidgetLightEvents() + + useEffect(() => { + const onComplete = (data: WidgetLightRouteExecutionUpdate) => { + console.log('Route completed:', data) + } + events.on(WidgetLightEvent.RouteExecutionCompleted, onComplete) + return () => events.off(WidgetLightEvent.RouteExecutionCompleted, onComplete) + }, [events]) + + return null +} ``` -### How to test? +### Available Events + +| Event | Payload Type | Description | +|-------|-------------|-------------| +| `RouteExecutionStarted` | `WidgetLightRouteExecutionUpdate` | A route has started executing | +| `RouteExecutionUpdated` | `WidgetLightRouteExecutionUpdate` | Step progress update | +| `RouteExecutionCompleted` | `WidgetLightRouteExecutionUpdate` | Route completed successfully | +| `RouteExecutionFailed` | `WidgetLightRouteExecutionUpdate` | Route execution failed | +| `RouteSelected` | `WidgetLightRouteSelected` | User selected a route | +| `RouteHighValueLoss` | `WidgetLightRouteHighValueLoss` | High value loss detected | +| `SourceChainTokenSelected` | `WidgetLightChainTokenSelected` | Source token changed | +| `DestinationChainTokenSelected` | `WidgetLightChainTokenSelected` | Destination token changed | +| `FormFieldChanged` | `WidgetLightFormFieldChanged` | Any form field changed | +| `WalletConnected` | `WidgetLightWalletConnected` | Wallet connected | +| `WalletDisconnected` | `WidgetLightWalletDisconnected` | Wallet disconnected | +| `ReviewTransactionPageEntered` | — | User entered review page | +| `ContactSupport` | `WidgetLightContactSupport` | User clicked contact support | +| `AvailableRoutes` | — | Routes fetched and available | +| `PageEntered` | — | Page navigation | +| `WidgetExpanded` | — | Widget expanded (drawer variant) | +| `SendToWalletToggled` | — | Send-to-wallet toggle changed | +| `SettingUpdated` | `WidgetLightSettingUpdated` | User changed a setting | +| `ChainPinned` | `WidgetLightChainPinned` | Chain pinned/unpinned | +| `TokenSearch` | `WidgetLightTokenSearch` | User searched for a token | +| `LowAddressActivityConfirmed` | `WidgetLightLowAddressActivityConfirmed` | Low activity address confirmed | + +## Ecosystem Handler Reference + +Each handler implements the `IframeEcosystemHandler` interface and bridges RPC calls between the iframe and your wallet provider. + +### EVM — `useEthereumIframeHandler()` + +```tsx +import { useEthereumIframeHandler } from '@lifi/widget-light/ethereum' +``` + +- **Reads from:** wagmi context (`useConnection`, `useWalletClient`, `usePublicClient`, `useSwitchChain`) +- **Peer deps:** `wagmi`, `viem`, `@wagmi/core` +- **RPC methods handled:** `eth_accounts`, `eth_requestAccounts`, `eth_chainId`, `eth_sendTransaction`, `personal_sign`, `eth_signTypedData_v4`, `wallet_switchEthereumChain`, `wallet_addEthereumChain`, `wallet_sendCalls`, `wallet_getCallsStatus`, `wallet_getCapabilities`, and more +- **No parameters** — all state is read from wagmi hooks + +### Solana — `useSolanaIframeHandler(params)` + +```tsx +import { useSolanaIframeHandler } from '@lifi/widget-light/solana' +``` + +- **Peer deps:** `@wallet-standard/base` +- **Parameters:** + - `address: string | undefined` — connected wallet address + - `connected: boolean` — whether a wallet is connected + - `wallet: Wallet | undefined` — wallet-standard `Wallet` instance +- **RPC methods:** `getAccount`, `signTransaction`, `signMessage`, `signAndSendTransaction` +- **Library-agnostic** — works with any Solana wallet adapter that provides a wallet-standard `Wallet` + +### Bitcoin — `useBitcoinIframeHandler()` + +```tsx +import { useBitcoinIframeHandler } from '@lifi/widget-light/bitcoin' +``` + +- **Reads from:** `@bigmi/react` context (`useAccount`, `useConfig`) +- **Peer deps:** `@bigmi/client`, `@bigmi/react` +- **No parameters** + +### Sui — `useSuiIframeHandler()` + +```tsx +import { useSuiIframeHandler } from '@lifi/widget-light/sui' +``` + +- **Reads from:** `@mysten/dapp-kit-react` hooks +- **Peer deps:** `@mysten/dapp-kit-react` +- **RPC methods:** `getAccount`, `signTransaction`, `signPersonalMessage`, `signAndExecuteTransaction` +- **No parameters** + +## Security + +`iframeOrigin` is automatically derived from `src` (defaults to `https://widget.li.fi`), so `postMessage` communication is restricted to the correct origin out of the box. If you use a custom `src`, `iframeOrigin` will be derived from it automatically. You only need to set `iframeOrigin` explicitly if you want to override the derived value. + +## Examples + +Working examples are available in the repository: + +| Example | Description | Path | +|---------|-------------|------| +| **vite-iframe-wagmi** | Minimal EVM-only integration | `examples/vite-iframe-wagmi/` | +| **vite-iframe** | Full multi-ecosystem with events, config reactivity, and external wallet management | `examples/vite-iframe/` | + +Run an example locally: + +```bash +# From the repository root +pnpm install +pnpm --filter vite-iframe-wagmi dev +# or +pnpm --filter vite-iframe dev +``` + +## FAQ + +### What URL should I use for `src`? + +The default (`https://widget.li.fi`) is the production-hosted widget — you don't need to set `src` at all for most use cases. For testing against a specific version or self-hosted deployment, pass your custom URL as `src`. + +### Can I run multiple widgets on the same page? -1. Find an NFT on the [OpenSea](https://opensea.io/). Please make sure it has an active listing and the test wallet has enough tokens to buy it. While we will be able to pay with any token in the process, the OpenSea SDK checks for the token in which the NFT is listed to generate transaction data. -2. Let's say we found this NFT https://opensea.io/assets/base/0x9e81df5258908dbeef4f841d0ab3816b10850426/2578 -3. We need to replace the `opensea.io/assets` part with `localhost:3000` or `widget.li.fi`, depending on the testing environment, so the final URL should look like this -http://localhost:3000/base/0x9e81df5258908dbeef4f841d0ab3816b10850426/2578 or this https://widget.li.fi/base/0x9e81df5258908dbeef4f841d0ab3816b10850426/2578 -4. Open the URL and make sure the test wallet is switched to the chain the NFT is on so OpenSea SDK can generate transaction data. -5. Select any token on any chain and pay for NFT. +Currently, `@lifi/widget-light` uses a module-level singleton for the event bus and guest bridge. Only one `` instance per page is supported. -### Live Demo +### Why must config be JSON-serializable? -https://github.com/lifinance/widget/assets/18644653/af360181-3856-4276-b309-f923f476f40b +Configuration is sent to the iframe via `postMessage`, which uses the structured clone algorithm. React nodes, functions, class instances, and MUI theme objects cannot be cloned. Use the `WidgetLightConfig` type to ensure compatibility — the type system will catch non-serializable values at compile time. -#### Demo Transactions +### How does auto-resize work? -https://optimistic.etherscan.io/tx/0xa9f4e4304822cfe01808555b66e047761361c9e54b2387f93e23e9ffb92ba151 -https://polygonscan.com/tx/0x370682cbbc544e0ea258da774220b529a086c4b22941b924587cd2e0105579f6 +When `autoResize` is `true` (default), the iframe content uses a `ResizeObserver` to detect height changes and posts them to the host. The host directly mutates `iframe.style.height` for zero-flicker updates. Set `autoResize={false}` if you want to control iframe dimensions yourself via CSS. -### What does it look like? +### Do I need to handle reconnection after page refresh? - +Wallet state is sent from your app to the iframe on every mount via the `INIT` handshake. If your wagmi/wallet-adapter handles reconnection (which most do by default), the widget will automatically receive the reconnected state. -### Questions? +## Support -Please don't hesitate to open an issue or contact us if you have any questions. +- [LI.FI Widget Documentation](https://docs.li.fi/widget/overview) +- [GitHub Issues](https://github.com/lifinance/widget/issues) +- [Widget Playground](https://playground.li.fi) — interactively explore configuration options diff --git a/packages/widget-embedded/package.json b/packages/widget-embedded/package.json index 4ac1795c1..c77b536aa 100644 --- a/packages/widget-embedded/package.json +++ b/packages/widget-embedded/package.json @@ -12,29 +12,32 @@ }, "author": "Eugene Chybisov ", "dependencies": { - "@lifi/sdk": "^4.0.0-alpha.20", + "@lifi/sdk": "4.0.0-beta.0", + "@lifi/sdk-provider-bitcoin": "^4.0.0-beta.0", "@lifi/sdk-provider-solana": "^4.0.0-alpha.20", + "@lifi/sdk-provider-sui": "^4.0.0-beta.0", "@lifi/wallet-management": "workspace:*", - "@lifi/widget-provider": "workspace:*", "@lifi/widget": "workspace:*", "@lifi/widget-light": "workspace:*", + "@lifi/widget-provider": "workspace:*", "@lifi/widget-provider-bitcoin": "workspace:*", "@lifi/widget-provider-ethereum": "workspace:*", "@lifi/widget-provider-solana": "workspace:*", "@lifi/widget-provider-sui": "workspace:*", + "@mysten/sui": "^2.8.0", "@tanstack/react-query": "^5.90.20", + "@wagmi/core": "^3.3.2", "react": "^19.2.4", "react-dom": "^19.2.4", - "@wagmi/core": "^3.3.2", - "viem": "^2.45.1", - "wagmi": "^3.4.2" + "viem": "^2.47.4", + "wagmi": "^3.5.0" }, "devDependencies": { "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react-swc": "^4.2.3", + "@vitejs/plugin-react": "^6.0.1", "typescript": "^5.9.3", - "vite": "^7.3.0", + "vite": "^8.0.0", "vite-plugin-node-polyfills": "^0.25.0", "web-vitals": "^5.1.0" }, diff --git a/packages/widget-embedded/src/App.tsx b/packages/widget-embedded/src/App.tsx index f92dd3a92..6eada1e4d 100644 --- a/packages/widget-embedded/src/App.tsx +++ b/packages/widget-embedded/src/App.tsx @@ -5,6 +5,7 @@ import { BitcoinIframeProviderValues } from './providers/iframe/BitcoinIframePro import { SolanaIframeProviderValues } from './providers/iframe/SolanaIframeProviderValues.js' import { SuiIframeProviderValues } from './providers/iframe/SuiIframeProviderValues.js' import { useEmbeddedWidgetConfig } from './providers/WidgetConfigProvider.js' +import { WidgetEventsBridge } from './providers/WidgetEventsBridge.js' const IFRAME_PROVIDERS = [ EthereumProvider(), @@ -41,10 +42,13 @@ export function App() { } return ( - + <> + + + ) } diff --git a/packages/widget-embedded/src/providers/WalletProvider.tsx b/packages/widget-embedded/src/providers/WalletProvider.tsx index dd12fa9ad..f7a1c3808 100644 --- a/packages/widget-embedded/src/providers/WalletProvider.tsx +++ b/packages/widget-embedded/src/providers/WalletProvider.tsx @@ -7,16 +7,15 @@ import { mainnet } from 'viem/chains' import type { Config, CreateConnectorFn } from 'wagmi' import { createConfig, WagmiProvider } from 'wagmi' import { widgetLightConnector as widgetLightIframe } from './iframe/widgetLightConnector.js' -import { useEmbeddedWidgetConfig } from './WidgetConfigProvider.js' - -const FALLBACK_CONFIG: Partial = { - integrator: 'widget-embedded', -} +import { + EMBEDDED_DEFAULT_CONFIG, + useEmbeddedWidgetConfig, +} from './WidgetConfigProvider.js' export const WalletProvider: FC = ({ children }) => { const widgetConfig = useEmbeddedWidgetConfig() const { chains } = useWidgetChains( - (widgetConfig ?? FALLBACK_CONFIG) as WidgetConfig + (widgetConfig ?? EMBEDDED_DEFAULT_CONFIG) as WidgetConfig ) const iframeConnectorFn = useRef(null) @@ -35,6 +34,11 @@ export const WalletProvider: FC = ({ children }) => { connectors: [iframeConnectorFn.current!], multiInjectedProviderDiscovery: false, ssr: false, + // Disable localStorage persistence. The embedded widget receives wallet + // state from the host via postMessage (INIT/EVENT). Wagmi's Hydrate + // component calls onMount() on every non-SSR render and when + // reconnectOnMount=false it clears connections if storage is present. + storage: null, }) } diff --git a/packages/widget-embedded/src/providers/WidgetConfigProvider.tsx b/packages/widget-embedded/src/providers/WidgetConfigProvider.tsx index b90a98724..9d1ec2a91 100644 --- a/packages/widget-embedded/src/providers/WidgetConfigProvider.tsx +++ b/packages/widget-embedded/src/providers/WidgetConfigProvider.tsx @@ -1,17 +1,20 @@ import type { WidgetConfig } from '@lifi/widget' +import type { WidgetLightConfig } from '@lifi/widget-light' import { GuestBridge } from '@lifi/widget-light' import { createContext, type FC, type PropsWithChildren, + useCallback, useContext, useEffect, + useMemo, useState, } from 'react' const isInsideIframe = window.self !== window.top -const DEFAULT_CONFIG: Partial = { +export const EMBEDDED_DEFAULT_CONFIG: Partial = { integrator: 'widget-embedded', } @@ -20,30 +23,56 @@ const WidgetConfigContext = createContext | null>(null) export const useEmbeddedWidgetConfig = () => useContext(WidgetConfigContext) export const WidgetConfigProvider: FC = ({ children }) => { - const [config, setConfig] = useState | null>(() => { - if (isInsideIframe) { - const bridge = GuestBridge.getInstance() - const existing = bridge.config - if (existing) { - return existing as unknown as Partial + const [lightConfig, setLightConfig] = useState( + () => { + if (isInsideIframe) { + return GuestBridge.getInstance().config } return null } - return DEFAULT_CONFIG - }) + ) useEffect(() => { if (!isInsideIframe) { return } const bridge = GuestBridge.getInstance() - return bridge.onConfig((cfg) => { - setConfig(cfg as unknown as Partial) - }) + return bridge.onConfig(setLightConfig) + }, []) + + // Stable reference so downstream consumers don't re-render on every + // CONFIG_UPDATE just because the onConnect closure was recreated. + const onConnect: WidgetConfig['walletConfig'] extends + | { onConnect?: infer F } + | undefined + ? F + : never = useCallback((args: any) => { + GuestBridge.getInstance().sendConnectWalletRequest( + args ? { chainId: args.chain?.id, chainType: args.chainType } : undefined + ) }, []) + const config = useMemo(() => { + if (!lightConfig) { + return null + } + const base = lightConfig as unknown as Partial + if (!lightConfig.walletConfig?.useExternalWalletManagement) { + return base + } + return { + ...base, + walletConfig: { + ...base.walletConfig, + onConnect, + }, + } + }, [lightConfig, onConnect]) + + const value = isInsideIframe ? config : EMBEDDED_DEFAULT_CONFIG + return ( - + {children} ) diff --git a/packages/widget-embedded/src/providers/WidgetEventsBridge.tsx b/packages/widget-embedded/src/providers/WidgetEventsBridge.tsx new file mode 100644 index 000000000..6d4c4b9c3 --- /dev/null +++ b/packages/widget-embedded/src/providers/WidgetEventsBridge.tsx @@ -0,0 +1,93 @@ +import { widgetEvents as walletMgmtEvents } from '@lifi/wallet-management' +import { widgetEvents } from '@lifi/widget' +import { GuestBridge } from '@lifi/widget-light' +import { useEffect } from 'react' + +/** + * Guest-side (iframe) bridge that forwards widget events to the host. + * + * Listens for WIDGET_EVENT_SUBSCRIBE/UNSUBSCRIBE messages from the host + * and only forwards events that the host has subscribed to. + * + * Renders nothing — mount once, high in the tree. + */ +export function WidgetEventsBridge() { + useEffect(() => { + const bridge = GuestBridge.getInstance() + const noopHandler = () => {} + let listenersAttached = false + + // Events that gate on widgetEvents.all.has() need a specific no-op + // listener so the gate check passes (mitt wildcard doesn't register + // under specific event keys). + const isGatedEvent = (event: string) => event === 'contactSupport' + + const wildcardHandler = (type: string, data: unknown) => { + if (bridge.getSubscribedEvents().has(type)) { + bridge.sendWidgetEvent(type, data) + } + } + + const walletConnectedHandler = (data: unknown) => { + if (bridge.getSubscribedEvents().has('walletConnected')) { + bridge.sendWidgetEvent('walletConnected', data) + } + } + + const walletDisconnectedHandler = (data: unknown) => { + if (bridge.getSubscribedEvents().has('walletDisconnected')) { + bridge.sendWidgetEvent('walletDisconnected', data) + } + } + + const attach = () => { + if (listenersAttached) { + return + } + listenersAttached = true + widgetEvents.on('*', wildcardHandler) + walletMgmtEvents.on('walletConnected', walletConnectedHandler) + walletMgmtEvents.on('walletDisconnected', walletDisconnectedHandler) + } + + const detach = () => { + if (!listenersAttached) { + return + } + listenersAttached = false + widgetEvents.off('*', wildcardHandler) + walletMgmtEvents.off('walletConnected', walletConnectedHandler) + walletMgmtEvents.off('walletDisconnected', walletDisconnectedHandler) + widgetEvents.off('contactSupport', noopHandler) + } + + if (bridge.getSubscribedEvents().size > 0) { + attach() + } + + const unsubBridge = bridge.onWidgetEventSubscriptionChange( + (event, isSubscribed) => { + if (isSubscribed) { + attach() + if (isGatedEvent(event)) { + widgetEvents.on('contactSupport', noopHandler) + } + } else { + if (isGatedEvent(event)) { + widgetEvents.off('contactSupport', noopHandler) + } + if (bridge.getSubscribedEvents().size === 0) { + detach() + } + } + } + ) + + return () => { + unsubBridge() + detach() + } + }, []) + + return null +} diff --git a/packages/widget-embedded/src/providers/iframe/BaseIframeProvider.ts b/packages/widget-embedded/src/providers/iframe/BaseIframeProvider.ts new file mode 100644 index 000000000..d666333c5 --- /dev/null +++ b/packages/widget-embedded/src/providers/iframe/BaseIframeProvider.ts @@ -0,0 +1,165 @@ +import type { WidgetLightChainType } from '@lifi/widget-light' +import { GuestBridge } from '@lifi/widget-light' + +type Listener = (...args: unknown[]) => void + +export interface IframeConnectorInfo { + name?: string + icon?: string +} + +/** + * Base class for guest-side (iframe) providers. Handles bridge registration, + * account/connection state tracking, RPC forwarding, and event emitting. + * + * Chain-specific subclasses only need to call `super(chainType)`. + * Override `_registerBridgeCallbacks` for custom init/event handling. + */ +export class BaseIframeProvider { + protected readonly _listeners = new Map>() + protected readonly bridge = GuestBridge.getInstance() + protected readonly chainType: WidgetLightChainType + + protected _accounts: string[] = [] + protected _connected = false + protected _connector: IframeConnectorInfo = {} + protected _unsubInit?: () => void + protected _unsubEvent?: () => void + + get accounts(): string[] { + return this._accounts + } + + get connected(): boolean { + return this._connected + } + + get address(): string | null { + return this._accounts[0] ?? null + } + + get connector(): IframeConnectorInfo { + return this._connector + } + + constructor(chainType: WidgetLightChainType) { + this.chainType = chainType + this._registerBridgeCallbacks() + } + + protected _registerBridgeCallbacks(): void { + // Unsubscribe existing callbacks first so this method is idempotent. + // This is needed when connect() is called to re-register after destroy(). + this._unsubInit?.() + this._unsubEvent?.() + + this._unsubInit = this.bridge.onInit(this.chainType, (state) => { + const s = state as { + accounts: string[] + connected: boolean + connector?: IframeConnectorInfo + } + this._accounts = s.accounts + this._connected = s.connected + if (s.connector) { + this._connector = s.connector + } + + this.emit('accountsChanged', this._accounts) + if (this._connected && this.address) { + this.emit('connect', { address: this.address }) + } + }) + + this._unsubEvent = this.bridge.onEvent(this.chainType, (event, data) => { + if (event === 'accountsChanged') { + this._accounts = data as string[] + this._connected = this._accounts.length > 0 + } else if (event === 'connect') { + const d = data as { connector?: IframeConnectorInfo } + if (d.connector) { + this._connector = d.connector + } + } else if (event === 'disconnect') { + this._accounts = [] + this._connected = false + } + this.emit(event, data) + }) + } + + /** + * Re-register bridge callbacks. Call this at the start of a React effect + * so that bridge subscriptions are restored after a StrictMode double-mount + * (where the previous effect's cleanup called destroy()). + */ + connect(): void { + this._registerBridgeCallbacks() + } + + destroy(): void { + this._unsubInit?.() + this._unsubEvent?.() + this.removeAllListeners() + } + + waitForInit(): Promise { + return this.bridge.waitForInit() + } + + protected async sendRequest( + method: string, + params?: unknown + ): Promise { + await this.bridge.waitForInit() + + if (method === 'getAccount') { + return { address: this.address } + } + + return this.bridge.sendRpcRequest( + this.chainType, + method, + params as unknown[] + ) + } + + // --------------------------------------------------------------------------- + // Event emitter interface + // --------------------------------------------------------------------------- + + on(event: string, listener: Listener): this { + let set = this._listeners.get(event) + if (!set) { + set = new Set() + this._listeners.set(event, set) + } + set.add(listener) + return this + } + + removeListener(event: string, listener: Listener): this { + this._listeners.get(event)?.delete(listener) + return this + } + + emit(event: string, ...args: unknown[]): boolean { + const set = this._listeners.get(event) + if (!set || set.size === 0) { + return false + } + for (const listener of set) { + listener(...args) + } + return true + } + + removeAllListeners(event?: string): this { + if (event) { + this._listeners.delete(event) + } else { + this._listeners.clear() + } + return this + } +} diff --git a/packages/widget-embedded/src/providers/iframe/BitcoinIframeProvider.ts b/packages/widget-embedded/src/providers/iframe/BitcoinIframeProvider.ts index 258a29ab1..49722ab77 100644 --- a/packages/widget-embedded/src/providers/iframe/BitcoinIframeProvider.ts +++ b/packages/widget-embedded/src/providers/iframe/BitcoinIframeProvider.ts @@ -1,42 +1,40 @@ -import type { WidgetLightConfig } from '@lifi/widget-light' -import { GuestBridge } from '@lifi/widget-light' - -type Listener = (...args: unknown[]) => void +import type { IframeConnectorInfo } from './BaseIframeProvider.js' +import { BaseIframeProvider } from './BaseIframeProvider.js' /** * Guest-side (iframe) provider for Bitcoin. Delegates transport to GuestBridge. * * Registers for UTXO init state and events, forwards method calls via * bridge.sendRpcRequest('UTXO', ...). + * + * Extends the base to also track the public key received from the host, + * which is needed by the Bitcoin SDK for PSBT signing operations. */ -export class BitcoinIframeProvider { - private readonly _listeners = new Map>() - private readonly bridge = GuestBridge.getInstance() - - private _accounts: string[] = [] - private _connected = false +export class BitcoinIframeProvider extends BaseIframeProvider { + private _publicKey: string | null = null - get accounts(): string[] { - return this._accounts + get publicKey(): string | null { + return this._publicKey } - get connected(): boolean { - return this._connected - } - - get address(): string | null { - return this._accounts[0] ?? null - } - - get config(): WidgetLightConfig | null { - return this.bridge.config + constructor() { + super('UTXO') } - constructor() { - this.bridge.onInit('UTXO', (state) => { - const s = state as { accounts: string[]; connected: boolean } + protected override _registerBridgeCallbacks(): void { + this._unsubInit = this.bridge.onInit(this.chainType, (state) => { + const s = state as { + accounts: string[] + connected: boolean + publicKey: string | null + connector?: IframeConnectorInfo + } this._accounts = s.accounts this._connected = s.connected + this._publicKey = s.publicKey ?? null + if (s.connector) { + this._connector = s.connector + } this.emit('accountsChanged', this._accounts) if (this._connected && this.address) { @@ -44,56 +42,25 @@ export class BitcoinIframeProvider { } }) - this.bridge.onEvent('UTXO', (event, data) => { + this._unsubEvent = this.bridge.onEvent(this.chainType, (event, data) => { if (event === 'accountsChanged') { this._accounts = data as string[] this._connected = this._accounts.length > 0 - } - if (event === 'disconnect') { + } else if (event === 'connect') { + const d = data as { connector?: IframeConnectorInfo } + if (d.connector) { + this._connector = d.connector + } + } else if (event === 'disconnect') { this._accounts = [] this._connected = false + this._publicKey = null } this.emit(event, data) }) } - waitForInit(): Promise { - return this.bridge.waitForInit() - } - - async request(method: string, params?: unknown): Promise { - await this.bridge.waitForInit() - - if (method === 'getAccount') { - return { address: this.address } - } - - return this.bridge.sendRpcRequest('UTXO', method, params as unknown[]) - } - - on(event: string, listener: Listener): this { - let set = this._listeners.get(event) - if (!set) { - set = new Set() - this._listeners.set(event, set) - } - set.add(listener) - return this - } - - removeListener(event: string, listener: Listener): this { - this._listeners.get(event)?.delete(listener) - return this - } - - emit(event: string, ...args: unknown[]): boolean { - const set = this._listeners.get(event) - if (!set || set.size === 0) { - return false - } - for (const listener of set) { - listener(...args) - } - return true + request(method: string, params?: unknown): Promise { + return this.sendRequest(method, params) } } diff --git a/packages/widget-embedded/src/providers/iframe/BitcoinIframeProviderValues.tsx b/packages/widget-embedded/src/providers/iframe/BitcoinIframeProviderValues.tsx index baaf3f689..258563b86 100644 --- a/packages/widget-embedded/src/providers/iframe/BitcoinIframeProviderValues.tsx +++ b/packages/widget-embedded/src/providers/iframe/BitcoinIframeProviderValues.tsx @@ -1,17 +1,21 @@ import { ChainId, ChainType } from '@lifi/sdk' +import { BitcoinProvider as BitcoinSDKProvider } from '@lifi/sdk-provider-bitcoin' import { BitcoinContext } from '@lifi/widget-provider' import { type FC, type PropsWithChildren, useEffect, + useMemo, useRef, useState, } from 'react' +import type { IframeConnectorInfo } from './BaseIframeProvider.js' import { BitcoinIframeProvider } from './BitcoinIframeProvider.js' interface IframeWalletState { accounts: string[] connected: boolean + connector: IframeConnectorInfo } /** @@ -19,7 +23,7 @@ interface IframeWalletState { * * Reads wallet state from BitcoinIframeProvider (which receives it from the * host via GuestBridge) and exposes it through BitcoinContext so the widget - * can display the connected account. + * can display the connected account and execute transactions. */ export const BitcoinIframeProviderValues: FC = ({ children, @@ -33,68 +37,126 @@ export const BitcoinIframeProviderValues: FC = ({ const [walletState, setWalletState] = useState({ accounts: provider.accounts, connected: provider.connected, + connector: provider.connector, }) useEffect(() => { const onAccountsChanged = (accounts: unknown) => { const accts = accounts as string[] - setWalletState({ accounts: accts, connected: accts.length > 0 }) + setWalletState((s) => ({ + ...s, + accounts: accts, + connected: accts.length > 0, + })) } const onConnect = () => { setWalletState({ accounts: provider.accounts, connected: true, + connector: provider.connector, }) } const onDisconnect = () => { - setWalletState({ accounts: [], connected: false }) + setWalletState({ accounts: [], connected: false, connector: {} }) } + // Register React listeners BEFORE connect() — onInit may fire + // synchronously if INIT already arrived, and we need to catch it. provider.on('accountsChanged', onAccountsChanged) provider.on('connect', onConnect) provider.on('disconnect', onDisconnect) + provider.connect() return () => { provider.removeListener('accountsChanged', onAccountsChanged) provider.removeListener('connect', onConnect) provider.removeListener('disconnect', onDisconnect) + provider.destroy() } }, [provider]) const address = walletState.accounts[0] ?? null const isConnected = walletState.connected && !!address + const connectorName = walletState.connector.name + const connectorIcon = walletState.connector.icon - const account = { - address: address ?? undefined, - addresses: address ? [address] : [], - chainType: ChainType.UTXO, - chainId: ChainId.BTC, - connector: isConnected ? { name: 'iframe-bridge' } : undefined, - isConnected, - isConnecting: false, - isReconnecting: false, - isDisconnected: !isConnected, - status: (isConnected ? 'connected' : 'disconnected') as - | 'connected' - | 'disconnected', - } + const account = useMemo( + () => ({ + address: address ?? undefined, + addresses: address ? [address] : [], + chainType: ChainType.UTXO, + chainId: ChainId.BTC, + connector: isConnected + ? { + name: connectorName ?? 'Bitcoin Wallet', + icon: connectorIcon, + } + : undefined, + isConnected, + isConnecting: false, + isReconnecting: false, + isDisconnected: !isConnected, + status: (isConnected ? 'connected' : 'disconnected') as + | 'connected' + | 'disconnected', + }), + [address, isConnected, connectorName, connectorIcon] + ) + + const sdkProvider = useMemo( + () => + BitcoinSDKProvider({ + async getWalletClient() { + if (!provider.address) { + throw new Error('Bitcoin wallet not connected') + } + // Cast required: the SDK types expect a full bigmi Client, but the + // executor only uses client.account and client.request() at runtime. + return createIframeBitcoinClient(provider) as any + }, + }), + [provider] + ) + + const contextValue = useMemo( + () => ({ + isEnabled: true, + account, + sdkProvider, + installedWallets: [] as [], + isConnected, + isExternalContext: true, + connect: async () => {}, + disconnect: async () => {}, + }), + [account, sdkProvider, isConnected] + ) return ( - {}, - disconnect: async () => {}, - }} - > + {children} ) } + +/** + * Creates a minimal bigmi-compatible wallet client that delegates all + * operations to the host via the iframe bridge. + * + * The `BitcoinStepExecutor` uses: + * - `client.account.address` / `.publicKey` for PSBT input processing + * - `client.request({ method, params })` via bigmi's `signPsbt()` action + */ +function createIframeBitcoinClient(provider: BitcoinIframeProvider) { + return { + account: { + address: provider.address!, + publicKey: provider.publicKey ?? '', + }, + async request({ method, params }: { method: string; params?: unknown }) { + return provider.request(method, params) + }, + } +} diff --git a/packages/widget-embedded/src/providers/iframe/EthereumIframeProvider.ts b/packages/widget-embedded/src/providers/iframe/EthereumIframeProvider.ts index 37f128c8b..fb9cd3e4e 100644 --- a/packages/widget-embedded/src/providers/iframe/EthereumIframeProvider.ts +++ b/packages/widget-embedded/src/providers/iframe/EthereumIframeProvider.ts @@ -1,7 +1,5 @@ -import type { RpcError, WidgetLightConfig } from '@lifi/widget-light' -import { GuestBridge } from '@lifi/widget-light' - -type Listener = (...args: unknown[]) => void +import type { IframeConnectorInfo } from './BaseIframeProvider.js' +import { BaseIframeProvider } from './BaseIframeProvider.js' /** * EIP-1193 compatible provider that bridges the iframe guest to the parent @@ -15,47 +13,60 @@ type Listener = (...args: unknown[]) => void * 4. EIP-1193 events (accountsChanged, chainChanged, etc.) are emitted * locally for the wagmi connector to consume. */ -export class EthereumIframeProvider { - private readonly _listeners = new Map>() - private readonly bridge = GuestBridge.getInstance() - - private accounts: string[] = [] +export class EthereumIframeProvider extends BaseIframeProvider { private _chainIdHex: `0x${string}` = '0x1' get chainIdHex(): `0x${string}` { return this._chainIdHex } - get config(): WidgetLightConfig | null { - return this.bridge.config + constructor() { + super('EVM') } - constructor() { - this.bridge.onInit('EVM', (state) => { - const s = state as { accounts: string[]; chainId: number } - this.accounts = s.accounts + protected override _registerBridgeCallbacks(): void { + this._unsubInit?.() + this._unsubEvent?.() + + this._unsubInit = this.bridge.onInit('EVM', (state) => { + const s = state as { + accounts: string[] + chainId: number + connector?: IframeConnectorInfo + } + this._accounts = s.accounts + this._connected = s.accounts.length > 0 this._chainIdHex = `0x${s.chainId.toString(16)}` + if (s.connector) { + this._connector = s.connector + this.emit('connectorUpdate', s.connector) + } this.emit('chainChanged', this._chainIdHex) this.emit('connect', { chainId: this._chainIdHex }) - this.emit('accountsChanged', this.accounts) + this.emit('accountsChanged', this._accounts) }) - this.bridge.onEvent('EVM', (event, data) => { + this._unsubEvent = this.bridge.onEvent('EVM', (event, data) => { if (event === 'accountsChanged') { - this.accounts = data as string[] - } - if (event === 'chainChanged') { + this._accounts = data as string[] + this._connected = this._accounts.length > 0 + } else if (event === 'connect') { + const d = data as { connector?: IframeConnectorInfo } + if (d.connector) { + this._connector = d.connector + this.emit('connectorUpdate', d.connector) + } + } else if (event === 'chainChanged') { this._chainIdHex = data as `0x${string}` + } else if (event === 'disconnect') { + this._accounts = [] + this._connected = false } this.emit(event, data) }) } - waitForInit(): Promise { - return this.bridge.waitForInit() - } - async request({ method, params, @@ -68,57 +79,28 @@ export class EthereumIframeProvider { switch (method) { case 'eth_accounts': case 'eth_requestAccounts': - return this.accounts + return this._accounts case 'eth_chainId': return this.chainIdHex case 'net_version': - return String(parseInt(this.chainIdHex, 16)) + return String(Number.parseInt(this.chainIdHex, 16)) + + case 'wallet_switchEthereumChain': { + const result = await this.bridge.sendRpcRequest('EVM', method, params) + // Optimistically update so getChainId() is correct before the + // bridge's async chainChanged EVENT arrives. No emit here — the + // bridge event is the single source of truth for consumers. + const requested = (params as [{ chainId: `0x${string}` }])?.[0]?.chainId + if (requested) { + this._chainIdHex = requested + } + return result + } default: return this.bridge.sendRpcRequest('EVM', method, params) } } - - // --------------------------------------------------------------------------- - // EIP-1193 event emitter interface - // --------------------------------------------------------------------------- - - on(event: string, listener: Listener): this { - let set = this._listeners.get(event) - if (!set) { - set = new Set() - this._listeners.set(event, set) - } - set.add(listener) - return this - } - - removeListener(event: string, listener: Listener): this { - this._listeners.get(event)?.delete(listener) - return this - } - - emit(event: string, ...args: unknown[]): boolean { - const set = this._listeners.get(event) - if (!set || set.size === 0) { - return false - } - for (const listener of set) { - listener(...args) - } - return true - } - - removeAllListeners(event?: string): this { - if (event) { - this._listeners.delete(event) - } else { - this._listeners.clear() - } - return this - } } - -export type { RpcError, WidgetLightConfig } diff --git a/packages/widget-embedded/src/providers/iframe/IframeSuiSigner.ts b/packages/widget-embedded/src/providers/iframe/IframeSuiSigner.ts new file mode 100644 index 000000000..005f2ba4e --- /dev/null +++ b/packages/widget-embedded/src/providers/iframe/IframeSuiSigner.ts @@ -0,0 +1,57 @@ +import type { PublicKey, SignatureScheme } from '@mysten/sui/cryptography' +import { Signer } from '@mysten/sui/cryptography' +import type { SuiIframeProvider } from './SuiIframeProvider.js' + +/** + * Guest-side Signer that delegates all signing operations to the host + * via the iframe bridge (SuiIframeProvider → GuestBridge → postMessage). + * + * The host receives RPC_REQUEST messages and uses the real wallet + * (via dapp-kit-react's CurrentAccountSigner) to sign. + */ +export class IframeSuiSigner extends Signer { + #address: string + #provider: SuiIframeProvider + + constructor(address: string, provider: SuiIframeProvider) { + super() + this.#address = address + this.#provider = provider + } + + override toSuiAddress(): string { + return this.#address + } + + getKeyScheme(): SignatureScheme { + throw new Error('getKeyScheme is not available in iframe context') + } + + getPublicKey(): PublicKey { + throw new Error('getPublicKey is not available in iframe context') as never + } + + async sign(_bytes: Uint8Array): Promise> { + throw new Error('Raw sign is not available in iframe context') + } + + override async signTransaction( + bytes: Uint8Array + ): Promise<{ bytes: string; signature: string }> { + const base64 = btoa(String.fromCharCode(...bytes)) + const result = await this.#provider.request('signTransaction', { + transaction: base64, + }) + return result as { bytes: string; signature: string } + } + + override async signPersonalMessage( + bytes: Uint8Array + ): Promise<{ bytes: string; signature: string }> { + const base64 = btoa(String.fromCharCode(...bytes)) + const result = await this.#provider.request('signPersonalMessage', { + message: base64, + }) + return result as { bytes: string; signature: string } + } +} diff --git a/packages/widget-embedded/src/providers/iframe/SolanaIframeProvider.ts b/packages/widget-embedded/src/providers/iframe/SolanaIframeProvider.ts index 81a85e891..6b432d146 100644 --- a/packages/widget-embedded/src/providers/iframe/SolanaIframeProvider.ts +++ b/packages/widget-embedded/src/providers/iframe/SolanaIframeProvider.ts @@ -1,7 +1,4 @@ -import type { WidgetLightConfig } from '@lifi/widget-light' -import { GuestBridge } from '@lifi/widget-light' - -type Listener = (...args: unknown[]) => void +import { BaseIframeProvider } from './BaseIframeProvider.js' /** * Guest-side (iframe) provider for Solana. Delegates transport to GuestBridge. @@ -9,91 +6,12 @@ type Listener = (...args: unknown[]) => void * Registers for SVM init state and events, forwards method calls via * bridge.sendRpcRequest('SVM', ...). */ -export class SolanaIframeProvider { - private readonly _listeners = new Map>() - private readonly bridge = GuestBridge.getInstance() - - private _accounts: string[] = [] - private _connected = false - - get accounts(): string[] { - return this._accounts - } - - get connected(): boolean { - return this._connected - } - - get address(): string | null { - return this._accounts[0] ?? null - } - - get config(): WidgetLightConfig | null { - return this.bridge.config - } - +export class SolanaIframeProvider extends BaseIframeProvider { constructor() { - this.bridge.onInit('SVM', (state) => { - const s = state as { accounts: string[]; connected: boolean } - this._accounts = s.accounts - this._connected = s.connected - - this.emit('accountsChanged', this._accounts) - if (this._connected && this.address) { - this.emit('connect', { address: this.address }) - } - }) - - this.bridge.onEvent('SVM', (event, data) => { - if (event === 'accountsChanged') { - this._accounts = data as string[] - this._connected = this._accounts.length > 0 - } - if (event === 'disconnect') { - this._accounts = [] - this._connected = false - } - this.emit(event, data) - }) - } - - waitForInit(): Promise { - return this.bridge.waitForInit() - } - - async request(method: string, params?: unknown): Promise { - await this.bridge.waitForInit() - - if (method === 'getAccount') { - return { address: this.address } - } - - return this.bridge.sendRpcRequest('SVM', method, params as unknown[]) - } - - on(event: string, listener: Listener): this { - let set = this._listeners.get(event) - if (!set) { - set = new Set() - this._listeners.set(event, set) - } - set.add(listener) - return this - } - - removeListener(event: string, listener: Listener): this { - this._listeners.get(event)?.delete(listener) - return this + super('SVM') } - emit(event: string, ...args: unknown[]): boolean { - const set = this._listeners.get(event) - if (!set || set.size === 0) { - return false - } - for (const listener of set) { - listener(...args) - } - return true + request(method: string, params?: unknown): Promise { + return this.sendRequest(method, params) } } diff --git a/packages/widget-embedded/src/providers/iframe/SolanaIframeProviderValues.tsx b/packages/widget-embedded/src/providers/iframe/SolanaIframeProviderValues.tsx index ec6220a3b..9b29fd776 100644 --- a/packages/widget-embedded/src/providers/iframe/SolanaIframeProviderValues.tsx +++ b/packages/widget-embedded/src/providers/iframe/SolanaIframeProviderValues.tsx @@ -9,11 +9,13 @@ import { useRef, useState, } from 'react' +import type { IframeConnectorInfo } from './BaseIframeProvider.js' import { SolanaIframeProvider } from './SolanaIframeProvider.js' interface IframeWalletState { accounts: string[] connected: boolean + connector: IframeConnectorInfo } /** @@ -35,59 +37,78 @@ export const SolanaIframeProviderValues: FC = ({ const [walletState, setWalletState] = useState({ accounts: provider.accounts, connected: provider.connected, + connector: provider.connector, }) useEffect(() => { const onAccountsChanged = (accounts: unknown) => { const accts = accounts as string[] - setWalletState({ accounts: accts, connected: accts.length > 0 }) + setWalletState((s) => ({ + ...s, + accounts: accts, + connected: accts.length > 0, + })) } const onConnect = () => { setWalletState({ accounts: provider.accounts, connected: true, + connector: provider.connector, }) } const onDisconnect = () => { - setWalletState({ accounts: [], connected: false }) + setWalletState({ accounts: [], connected: false, connector: {} }) } + // Register React listeners BEFORE connect() — onInit may fire + // synchronously if INIT already arrived, and we need to catch it. provider.on('accountsChanged', onAccountsChanged) provider.on('connect', onConnect) provider.on('disconnect', onDisconnect) + provider.connect() return () => { provider.removeListener('accountsChanged', onAccountsChanged) provider.removeListener('connect', onConnect) provider.removeListener('disconnect', onDisconnect) + provider.destroy() } }, [provider]) const address = walletState.accounts[0] ?? null const isConnected = walletState.connected && !!address + const connectorName = walletState.connector.name + const connectorIcon = walletState.connector.icon - const account = isConnected - ? { - address, - chainId: ChainId.SOL, - chainType: ChainType.SVM, - connector: { name: 'iframe-bridge' }, - isConnected: true as const, - isConnecting: false, - isReconnecting: false, - isDisconnected: false, - status: 'connected' as const, - } - : { - chainType: ChainType.SVM, - isConnected: false as const, - isConnecting: false, - isReconnecting: false, - isDisconnected: true, - status: 'disconnected' as const, - } + const account = useMemo( + () => + isConnected + ? { + address, + chainId: ChainId.SOL, + chainType: ChainType.SVM, + connector: { + name: connectorName ?? 'Solana Wallet', + icon: connectorIcon, + }, + isConnected: true as const, + isConnecting: false, + isReconnecting: false, + isDisconnected: false, + status: 'connected' as const, + } + : { + chainType: ChainType.SVM, + isConnected: false as const, + isConnecting: false, + isReconnecting: false, + isDisconnected: true, + status: 'disconnected' as const, + }, + [address, isConnected, connectorName, connectorIcon] + ) const sdkProvider = useMemo( () => @@ -102,19 +123,22 @@ export const SolanaIframeProviderValues: FC = ({ [provider] ) + const contextValue = useMemo( + () => ({ + isEnabled: true, + account, + sdkProvider, + installedWallets: [] as [], + isConnected, + isExternalContext: true, + connect: async () => {}, + disconnect: async () => {}, + }), + [account, sdkProvider, isConnected] + ) + return ( - {}, - disconnect: async () => {}, - }} - > + {children} ) @@ -140,8 +164,9 @@ function createIframeWallet(provider: SolanaIframeProvider) { } return { - name: 'iframe-bridge', - icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=' as const, + name: provider.connector.name ?? 'Solana Wallet', + icon: (provider.connector.icon ?? + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=') as `data:image/svg+xml;base64,${string}`, version: '1.0.0' as const, chains: ['solana:mainnet'] as const, accounts: [account], diff --git a/packages/widget-embedded/src/providers/iframe/SuiIframeProvider.ts b/packages/widget-embedded/src/providers/iframe/SuiIframeProvider.ts index 6059102a0..431ae557f 100644 --- a/packages/widget-embedded/src/providers/iframe/SuiIframeProvider.ts +++ b/packages/widget-embedded/src/providers/iframe/SuiIframeProvider.ts @@ -1,7 +1,4 @@ -import type { WidgetLightConfig } from '@lifi/widget-light' -import { GuestBridge } from '@lifi/widget-light' - -type Listener = (...args: unknown[]) => void +import { BaseIframeProvider } from './BaseIframeProvider.js' /** * Guest-side (iframe) provider for Sui. Delegates transport to GuestBridge. @@ -9,91 +6,12 @@ type Listener = (...args: unknown[]) => void * Registers for MVM init state and events, forwards method calls via * bridge.sendRpcRequest('MVM', ...). */ -export class SuiIframeProvider { - private readonly _listeners = new Map>() - private readonly bridge = GuestBridge.getInstance() - - private _accounts: string[] = [] - private _connected = false - - get accounts(): string[] { - return this._accounts - } - - get connected(): boolean { - return this._connected - } - - get address(): string | null { - return this._accounts[0] ?? null - } - - get config(): WidgetLightConfig | null { - return this.bridge.config - } - +export class SuiIframeProvider extends BaseIframeProvider { constructor() { - this.bridge.onInit('MVM', (state) => { - const s = state as { accounts: string[]; connected: boolean } - this._accounts = s.accounts - this._connected = s.connected - - this.emit('accountsChanged', this._accounts) - if (this._connected && this.address) { - this.emit('connect', { address: this.address }) - } - }) - - this.bridge.onEvent('MVM', (event, data) => { - if (event === 'accountsChanged') { - this._accounts = data as string[] - this._connected = this._accounts.length > 0 - } - if (event === 'disconnect') { - this._accounts = [] - this._connected = false - } - this.emit(event, data) - }) - } - - waitForInit(): Promise { - return this.bridge.waitForInit() - } - - async request(method: string, params?: unknown): Promise { - await this.bridge.waitForInit() - - if (method === 'getAccount') { - return { address: this.address } - } - - return this.bridge.sendRpcRequest('MVM', method, params as unknown[]) - } - - on(event: string, listener: Listener): this { - let set = this._listeners.get(event) - if (!set) { - set = new Set() - this._listeners.set(event, set) - } - set.add(listener) - return this - } - - removeListener(event: string, listener: Listener): this { - this._listeners.get(event)?.delete(listener) - return this + super('MVM') } - emit(event: string, ...args: unknown[]): boolean { - const set = this._listeners.get(event) - if (!set || set.size === 0) { - return false - } - for (const listener of set) { - listener(...args) - } - return true + request(method: string, params?: unknown): Promise { + return this.sendRequest(method, params) } } diff --git a/packages/widget-embedded/src/providers/iframe/SuiIframeProviderValues.tsx b/packages/widget-embedded/src/providers/iframe/SuiIframeProviderValues.tsx index 124603ef8..3b741d949 100644 --- a/packages/widget-embedded/src/providers/iframe/SuiIframeProviderValues.tsx +++ b/packages/widget-embedded/src/providers/iframe/SuiIframeProviderValues.tsx @@ -1,17 +1,23 @@ import { ChainId, ChainType } from '@lifi/sdk' +import { SuiProvider as SuiSDKProvider } from '@lifi/sdk-provider-sui' import { SuiContext } from '@lifi/widget-provider' +import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc' import { type FC, type PropsWithChildren, useEffect, + useMemo, useRef, useState, } from 'react' +import type { IframeConnectorInfo } from './BaseIframeProvider.js' +import { IframeSuiSigner } from './IframeSuiSigner.js' import { SuiIframeProvider } from './SuiIframeProvider.js' interface IframeWalletState { accounts: string[] connected: boolean + connector: IframeConnectorInfo } /** @@ -33,74 +39,107 @@ export const SuiIframeProviderValues: FC = ({ const [walletState, setWalletState] = useState({ accounts: provider.accounts, connected: provider.connected, + connector: provider.connector, }) useEffect(() => { const onAccountsChanged = (accounts: unknown) => { const accts = accounts as string[] - setWalletState({ accounts: accts, connected: accts.length > 0 }) + setWalletState((s) => ({ + ...s, + accounts: accts, + connected: accts.length > 0, + })) } const onConnect = () => { setWalletState({ accounts: provider.accounts, connected: true, + connector: provider.connector, }) } const onDisconnect = () => { - setWalletState({ accounts: [], connected: false }) + setWalletState({ accounts: [], connected: false, connector: {} }) } + // Register React listeners BEFORE connect() — onInit may fire + // synchronously if INIT already arrived, and we need to catch it. provider.on('accountsChanged', onAccountsChanged) provider.on('connect', onConnect) provider.on('disconnect', onDisconnect) + provider.connect() return () => { provider.removeListener('accountsChanged', onAccountsChanged) provider.removeListener('connect', onConnect) provider.removeListener('disconnect', onDisconnect) + provider.destroy() } }, [provider]) const address = walletState.accounts[0] ?? null const isConnected = walletState.connected && !!address + const connectorName = walletState.connector.name + const connectorIcon = walletState.connector.icon - const account = isConnected - ? { - address, - chainId: ChainId.SUI, - chainType: ChainType.MVM, - connector: { name: 'iframe-bridge' }, - isConnected: true as const, - isConnecting: false, - isReconnecting: false, - isDisconnected: false, - status: 'connected' as const, - } - : { - chainType: ChainType.MVM, - isConnected: false as const, - isConnecting: false, - isReconnecting: false, - isDisconnected: true, - status: 'disconnected' as const, - } + const account = useMemo( + () => + isConnected + ? { + address, + chainId: ChainId.SUI, + chainType: ChainType.MVM, + connector: { + name: connectorName ?? 'Sui Wallet', + icon: connectorIcon, + }, + isConnected: true as const, + isConnecting: false, + isReconnecting: false, + isDisconnected: false, + status: 'connected' as const, + } + : { + chainType: ChainType.MVM, + isConnected: false as const, + isConnecting: false, + isReconnecting: false, + isDisconnected: true, + status: 'disconnected' as const, + }, + [address, isConnected, connectorName, connectorIcon] + ) + + const sdkProvider = useMemo( + () => + SuiSDKProvider({ + getClient: async () => + new SuiJsonRpcClient({ + url: 'https://fullnode.mainnet.sui.io:443', + network: 'mainnet', + }), + getSigner: async () => new IframeSuiSigner(address!, provider), + }), + [address, provider] + ) + + const contextValue = useMemo( + () => ({ + isEnabled: true, + account, + sdkProvider, + installedWallets: [] as [], + isConnected, + isExternalContext: true, + connect: async () => {}, + disconnect: async () => {}, + }), + [account, sdkProvider, isConnected] + ) return ( - {}, - disconnect: async () => {}, - }} - > - {children} - + {children} ) } diff --git a/packages/widget-embedded/src/providers/iframe/widgetLightConnector.ts b/packages/widget-embedded/src/providers/iframe/widgetLightConnector.ts index f77c72386..6d415db31 100644 --- a/packages/widget-embedded/src/providers/iframe/widgetLightConnector.ts +++ b/packages/widget-embedded/src/providers/iframe/widgetLightConnector.ts @@ -22,15 +22,13 @@ type ConnectorEmitter = Parameters[0]['emitter'] * * To keep provider event listeners pointing at the **current** wagmi emitter * after re-creation, we store a mutable `latestEmitter` ref in the outer - * `widgetLightIframe()` closure. It is updated on every factory invocation. - * The provider's listeners read from this ref instead of capturing the - * original `config.emitter`. + * closure. It is updated on every factory invocation. */ -widgetLightConnector.type = 'widget-light-iframe' as const - export function widgetLightConnector() { let provider_: EthereumIframeProvider | undefined let latestEmitter: ConnectorEmitter + // Mutable ref to wagmi's spread connector object, updated in setup(). + let connectorRef: Record | undefined return createConnector((config) => { latestEmitter = config.emitter @@ -41,7 +39,9 @@ export function widgetLightConnector() { type: widgetLightConnector.type, async setup() { - await this.getProvider() + // `this` is wagmi's spread connector object — capture it so we + // can mutate name/icon when connector info arrives from the host. + connectorRef = this as unknown as Record }, async getProvider() { @@ -70,18 +70,28 @@ export function widgetLightConnector() { provider_.on('disconnect', () => { latestEmitter.emit('disconnect') }) + + // Update wagmi connector with the host wallet's name/icon. + provider_.on('connectorUpdate', () => { + const info = provider_?.connector + if (connectorRef && info?.name) { + connectorRef.name = info.name + connectorRef.icon = info.icon + } + }) + + // Apply eagerly if init already fired before this listener existed. + if (connectorRef && provider_.connector?.name) { + connectorRef.name = provider_.connector.name + connectorRef.icon = provider_.connector.icon + } } return provider_ }, async isAuthorized() { - try { - const accounts = await this.getAccounts() - const result = accounts.length > 0 - return result - } catch { - return false - } + const accounts = await this.getAccounts().catch(() => []) + return accounts.length > 0 }, async connect({ withCapabilities } = {}) { @@ -108,6 +118,8 @@ export function widgetLightConnector() { }, async disconnect() { + provider_?.destroy() + provider_ = undefined latestEmitter.emit('disconnect') }, @@ -149,13 +161,13 @@ export function widgetLightConnector() { return fromHex(chainId, 'number') }, - onAccountsChanged(accounts) { + onAccountsChanged(accounts: string[]) { latestEmitter.emit('change', { accounts: accounts.map(getAddress), }) }, - onChainChanged(chainId) { + onChainChanged(chainId: string | number) { latestEmitter.emit('change', { chainId: typeof chainId === 'string' ? Number(chainId) : chainId, }) @@ -167,3 +179,5 @@ export function widgetLightConnector() { } }) } + +widgetLightConnector.type = 'widget-light-iframe' as const diff --git a/packages/widget-embedded/vite.config.ts b/packages/widget-embedded/vite.config.ts index e15efcdd1..f1d66a696 100644 --- a/packages/widget-embedded/vite.config.ts +++ b/packages/widget-embedded/vite.config.ts @@ -1,10 +1,10 @@ -import react from '@vitejs/plugin-react-swc' +import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import { nodePolyfills } from 'vite-plugin-node-polyfills' export default defineConfig({ plugins: [nodePolyfills(), react()], - esbuild: { + oxc: { target: 'esnext', }, build: { diff --git a/packages/widget-light/package.json b/packages/widget-light/package.json index 908571256..628550830 100644 --- a/packages/widget-light/package.json +++ b/packages/widget-light/package.json @@ -1,6 +1,6 @@ { "name": "@lifi/widget-light", - "version": "4.0.0-alpha.2", + "version": "4.0.0-alpha.3", "description": "LI.FI Widget Light - a lightweight version of the LI.FI Widget for cross-chain bridging and swapping.", "type": "module", "main": "./src/index.ts", @@ -58,12 +58,12 @@ "cpy-cli": "^7.0.0", "madge": "^8.0.0", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.1.0" }, "peerDependencies": { "@bigmi/client": "^0.7.x", "@bigmi/react": "^0.7.x", - "@mysten/dapp-kit": "^0.19.x", + "@mysten/dapp-kit-react": "^2.0.0", "@wagmi/core": "^3.x", "@wallet-standard/base": "^1.1.0", "react": ">=18", @@ -86,7 +86,7 @@ "@bigmi/react": { "optional": true }, - "@mysten/dapp-kit": { + "@mysten/dapp-kit-react": { "optional": true }, "@wallet-standard/base": { diff --git a/packages/widget-light/src/config/version.ts b/packages/widget-light/src/config/version.ts index 5bd626aa1..1a8a6629a 100644 --- a/packages/widget-light/src/config/version.ts +++ b/packages/widget-light/src/config/version.ts @@ -1,2 +1,2 @@ export const name = '@lifi/widget-light' -export const version = '4.0.0-alpha.2' +export const version = '4.0.0-alpha.3' diff --git a/packages/widget-light/src/guest/GuestBridge.ts b/packages/widget-light/src/guest/GuestBridge.ts index b079dae11..a6ef3d82d 100644 --- a/packages/widget-light/src/guest/GuestBridge.ts +++ b/packages/widget-light/src/guest/GuestBridge.ts @@ -1,4 +1,5 @@ import type { + ConnectWalletArgs, EcosystemInitState, HostMessage, WidgetLightChainType, @@ -15,6 +16,7 @@ interface PendingRequest { type InitCallback = (state: unknown) => void type EventCallback = (event: string, data: unknown) => void type ConfigCallback = (config: WidgetLightConfig) => void +type SubscriptionChangeCallback = (event: string, subscribed: boolean) => void const RPC_TIMEOUT_MS = 60_000 @@ -52,6 +54,8 @@ export class GuestBridge { Set >() private readonly configCallbacks = new Set() + private readonly subscribedEvents = new Set() + private readonly subscriptionCallbacks = new Set() private readonly initPromise: Promise private initResolve!: () => void @@ -59,6 +63,10 @@ export class GuestBridge { private _config: WidgetLightConfig | null = null private _ecosystems: EcosystemInitState[] = [] private trustedOrigin = '*' + private _resizeObserver: ResizeObserver | null = null + private _rafId = 0 + private _retryInterval: ReturnType | null = null + private _retryTimeout: ReturnType | null = null get config(): WidgetLightConfig | null { return this._config @@ -186,6 +194,73 @@ export class GuestBridge { } } + /** + * Register a callback invoked when the host subscribes or unsubscribes + * from a widget event. Returns an unsubscribe function. + */ + onWidgetEventSubscriptionChange( + callback: SubscriptionChangeCallback + ): () => void { + this.subscriptionCallbacks.add(callback) + return () => { + this.subscriptionCallbacks.delete(callback) + } + } + + /** + * Tear down the bridge: remove the message listener, disconnect the + * ResizeObserver, clear retry timers, and reset the singleton. + */ + destroy(): void { + window.removeEventListener('message', this.handleMessage) + this.clearRetryTimers() + this._resizeObserver?.disconnect() + this._resizeObserver = null + cancelAnimationFrame(this._rafId) + this._rafId = 0 + for (const pending of this.pendingRequests.values()) { + clearTimeout(pending.timer) + pending.reject(new Error('Bridge destroyed')) + } + this.pendingRequests.clear() + this.initCallbacks.clear() + this.eventCallbacks.clear() + this.configCallbacks.clear() + this.subscriptionCallbacks.clear() + GuestBridge.instance = null + } + + /** Returns the set of event names the host is currently subscribed to. */ + getSubscribedEvents(): ReadonlySet { + return this.subscribedEvents + } + + /** Forward a widget event to the host via postMessage. */ + sendWidgetEvent(event: string, data: unknown): void { + if (typeof window === 'undefined' || window.parent === window) { + return + } + window.parent.postMessage( + { source: WIDGET_LIGHT_SOURCE, type: 'WIDGET_EVENT', event, data }, + this.trustedOrigin + ) + } + + /** Request the host to open its external wallet connect modal. */ + sendConnectWalletRequest(args?: ConnectWalletArgs): void { + if (typeof window === 'undefined' || window.parent === window) { + return + } + window.parent.postMessage( + { + source: WIDGET_LIGHT_SOURCE, + type: 'CONNECT_WALLET_REQUEST', + args, + }, + this.trustedOrigin + ) + } + // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- @@ -200,15 +275,26 @@ export class GuestBridge { sendReady() - const interval = setInterval(() => { + this._retryInterval = setInterval(() => { if (this._config !== null) { - clearInterval(interval) + this.clearRetryTimers() return } sendReady() }, 250) - setTimeout(() => clearInterval(interval), 30_000) + this._retryTimeout = setTimeout(() => this.clearRetryTimers(), 30_000) + } + + private clearRetryTimers(): void { + if (this._retryInterval) { + clearInterval(this._retryInterval) + this._retryInterval = null + } + if (this._retryTimeout) { + clearTimeout(this._retryTimeout) + this._retryTimeout = null + } } private readonly handleMessage = (event: MessageEvent): void => { @@ -228,12 +314,36 @@ export class GuestBridge { case 'INIT': this.handleInit(msg, event.origin) break + case 'CONFIG_UPDATE': + this.handleConfigUpdate(msg) + break case 'RPC_RESPONSE': this.handleRpcResponse(msg) break case 'EVENT': this.handleEvent(msg) break + case 'WIDGET_EVENT_SUBSCRIBE': + this.subscribedEvents.add(msg.event) + for (const cb of this.subscriptionCallbacks) { + cb(msg.event, true) + } + break + case 'WIDGET_EVENT_UNSUBSCRIBE': + this.subscribedEvents.delete(msg.event) + for (const cb of this.subscriptionCallbacks) { + cb(msg.event, false) + } + break + } + } + + private handleConfigUpdate( + msg: Extract + ): void { + this._config = msg.config + for (const configCb of this.configCallbacks) { + configCb(this._config) } } @@ -245,8 +355,11 @@ export class GuestBridge { this._config = msg.config this._ecosystems = msg.ecosystems ?? [] + this.clearRetryTimers() this.initResolve() - this.startResizeReporting() + if (msg.autoResize) { + this.startResizeReporting() + } for (const configCb of this.configCallbacks) { configCb(this._config) @@ -296,12 +409,11 @@ export class GuestBridge { } private startResizeReporting(): void { - if (typeof ResizeObserver === 'undefined') { + if (typeof ResizeObserver === 'undefined' || this._resizeObserver) { return } let lastHeight = 0 - let rafId = 0 const report = () => { const height = document.body.offsetHeight @@ -314,12 +426,12 @@ export class GuestBridge { } } - const ro = new ResizeObserver(() => { - cancelAnimationFrame(rafId) - rafId = requestAnimationFrame(report) + this._resizeObserver = new ResizeObserver(() => { + cancelAnimationFrame(this._rafId) + this._rafId = requestAnimationFrame(report) }) - ro.observe(document.body) + this._resizeObserver.observe(document.body) report() } } diff --git a/packages/widget-light/src/handlers/bitcoin/useBitcoinIframeHandler.ts b/packages/widget-light/src/handlers/bitcoin/useBitcoinIframeHandler.ts index dcd83155c..f04db5b28 100644 --- a/packages/widget-light/src/handlers/bitcoin/useBitcoinIframeHandler.ts +++ b/packages/widget-light/src/handlers/bitcoin/useBitcoinIframeHandler.ts @@ -3,7 +3,7 @@ import { getConnectorClient as getBigmiConnectorClient, } from '@bigmi/client' import { useAccount, useConfig } from '@bigmi/react' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import type { IframeEcosystemHandler } from '../../shared/protocol.js' /** @@ -14,20 +14,34 @@ import type { IframeEcosystemHandler } from '../../shared/protocol.js' * * Supported methods: * - `getAccount` → returns current account info - * - `signPsbt` → sign a PSBT (base64) - * - `sendBitcoin` → send bitcoin to an address - * - `getAddresses` → get wallet addresses - * - Generic methods are forwarded to the wallet client + * - All other methods are forwarded to the wallet client */ export function useBitcoinIframeHandler(): IframeEcosystemHandler { const bigmiConfig = useConfig() const currentWallet = useAccount() const address = currentWallet.account?.address + const publicKey = currentWallet.account?.publicKey const isConnected = currentWallet.isConnected + const connectorName = currentWallet.connector?.name + const connectorIcon = currentWallet.connector?.icon - const stateRef = useRef({ address, isConnected, bigmiConfig }) - stateRef.current = { address, isConnected, bigmiConfig } + const stateRef = useRef({ + address, + publicKey, + isConnected, + bigmiConfig, + connectorName, + connectorIcon, + }) + stateRef.current = { + address, + publicKey, + isConnected, + bigmiConfig, + connectorName, + connectorIcon, + } const emitRef = useRef<((event: string, data: unknown) => void) | null>(null) @@ -37,19 +51,29 @@ export function useBitcoinIframeHandler(): IframeEcosystemHandler { useEffect(() => { if (isConnected && address) { - emitRef.current?.('connect', { address }) + emitRef.current?.('connect', { + address, + connector: connectorName + ? { name: connectorName, icon: connectorIcon } + : undefined, + }) } else { emitRef.current?.('disconnect', {}) } - }, [isConnected, address]) + }, [isConnected, address, connectorName, connectorIcon]) const getInitState = useCallback(() => { - const { address, isConnected } = stateRef.current + const { address, publicKey, isConnected, connectorName, connectorIcon } = + stateRef.current return { chainType: 'UTXO' as const, state: { accounts: address ? [address] : [], connected: isConnected, + publicKey: publicKey ?? null, + connector: connectorName + ? { name: connectorName, icon: connectorIcon } + : undefined, }, } }, []) @@ -63,28 +87,15 @@ export function useBitcoinIframeHandler(): IframeEcosystemHandler { throw new Error('Bitcoin wallet not connected') } - switch (method) { - case 'getAccount': - return { address } - - case 'signPsbt': - case 'sendBitcoin': - case 'getAddresses': { - const client = await getBigmiConnectorClient(bigmiConfig) - return (client as any).request({ - method, - params: params as any, - }) - } - - default: { - const client = await getBigmiConnectorClient(bigmiConfig) - return (client as any).request({ - method, - params: params as any, - }) - } + if (method === 'getAccount') { + return { address, publicKey: stateRef.current.publicKey ?? null } } + + const client = await getBigmiConnectorClient(bigmiConfig) + return (client as any).request({ + method, + params: params as any, + }) }, [] ) @@ -99,10 +110,13 @@ export function useBitcoinIframeHandler(): IframeEcosystemHandler { [] ) - return { - chainType: 'UTXO', - getInitState, - handleRequest, - subscribe, - } + return useMemo( + () => ({ + chainType: 'UTXO' as const, + getInitState, + handleRequest, + subscribe, + }), + [getInitState, handleRequest, subscribe] + ) } diff --git a/packages/widget-light/src/handlers/ethereum/useEthereumIframeHandler.ts b/packages/widget-light/src/handlers/ethereum/useEthereumIframeHandler.ts index 29be80c42..30f119089 100644 --- a/packages/widget-light/src/handlers/ethereum/useEthereumIframeHandler.ts +++ b/packages/widget-light/src/handlers/ethereum/useEthereumIframeHandler.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useConnection, usePublicClient, @@ -15,13 +15,30 @@ import { handleRpcRequest } from './rpcHandler.js' * forwards EIP-1193 RPC requests from the guest iframe. */ export function useEthereumIframeHandler(): IframeEcosystemHandler { - const { address, chainId } = useConnection() + const { address, chainId, connector } = useConnection() const { data: walletClient } = useWalletClient() const publicClient = usePublicClient() const { mutateAsync: switchChainAsync } = useSwitchChain() - const stateRef = useRef({ address, chainId, walletClient, publicClient }) - stateRef.current = { address, chainId, walletClient, publicClient } + const connectorName = connector?.name + const connectorIcon = connector?.icon + + const stateRef = useRef({ + address, + chainId, + walletClient, + publicClient, + connectorName, + connectorIcon, + }) + stateRef.current = { + address, + chainId, + walletClient, + publicClient, + connectorName, + connectorIcon, + } const emitRef = useRef<((event: string, data: unknown) => void) | null>(null) @@ -35,16 +52,24 @@ export function useEthereumIframeHandler(): IframeEcosystemHandler { } const hexChainId = `0x${chainId.toString(16)}` as const emitRef.current?.('chainChanged', hexChainId) - emitRef.current?.('connect', { chainId: hexChainId }) - }, [chainId]) + emitRef.current?.('connect', { + chainId: hexChainId, + connector: connectorName + ? { name: connectorName, icon: connectorIcon } + : undefined, + }) + }, [chainId, connectorName, connectorIcon]) const getInitState = useCallback(() => { - const { address, chainId } = stateRef.current + const { address, chainId, connectorName, connectorIcon } = stateRef.current return { chainType: 'EVM' as const, state: { accounts: address ? [address] : [], chainId: chainId ?? 1, + connector: connectorName + ? { name: connectorName, icon: connectorIcon } + : undefined, }, } }, []) @@ -75,10 +100,13 @@ export function useEthereumIframeHandler(): IframeEcosystemHandler { [] ) - return { - chainType: 'EVM', - getInitState, - handleRequest, - subscribe, - } + return useMemo( + () => ({ + chainType: 'EVM' as const, + getInitState, + handleRequest, + subscribe, + }), + [getInitState, handleRequest, subscribe] + ) } diff --git a/packages/widget-light/src/handlers/solana/useSolanaIframeHandler.ts b/packages/widget-light/src/handlers/solana/useSolanaIframeHandler.ts index 05dbe7b89..79dc8dc25 100644 --- a/packages/widget-light/src/handlers/solana/useSolanaIframeHandler.ts +++ b/packages/widget-light/src/handlers/solana/useSolanaIframeHandler.ts @@ -1,5 +1,5 @@ import type { Wallet } from '@wallet-standard/base' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import type { IframeEcosystemHandler } from '../../shared/protocol.js' export interface SolanaIframeHandlerParams { @@ -8,6 +8,23 @@ export interface SolanaIframeHandlerParams { wallet: Wallet | null } +function base64ToBytes(base64: string): Uint8Array { + return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) +} + +function bytesToBase64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) +} + +function getWalletFeature(wallet: Wallet, feature: string): any { + const features = wallet.features as Record + const f = features[feature] + if (!f) { + throw new Error(`Wallet does not support ${feature}`) + } + return f +} + /** * Host-side hook that creates an `IframeEcosystemHandler` for Solana (SVM). * @@ -30,25 +47,36 @@ export function useSolanaIframeHandler( const emitRef = useRef<((event: string, data: unknown) => void) | null>(null) + const connectorName = wallet?.name + const connectorIcon = wallet?.icon + useEffect(() => { emitRef.current?.('accountsChanged', address ? [address] : []) }, [address]) useEffect(() => { if (connected && address) { - emitRef.current?.('connect', { address }) + emitRef.current?.('connect', { + address, + connector: connectorName + ? { name: connectorName, icon: connectorIcon } + : undefined, + }) } else { emitRef.current?.('disconnect', {}) } - }, [connected, address]) + }, [connected, address, connectorName, connectorIcon]) const getInitState = useCallback(() => { - const { address, connected } = stateRef.current + const { address, connected, wallet } = stateRef.current return { chainType: 'SVM' as const, state: { accounts: address ? [address] : [], connected, + connector: wallet + ? { name: wallet.name, icon: wallet.icon } + : undefined, }, } }, []) @@ -60,76 +88,50 @@ export function useSolanaIframeHandler( throw new Error('Solana wallet not connected') } + const account = wallet.accounts[0] + switch (method) { case 'getAccount': return { address } case 'signTransaction': { const { transaction } = params as { transaction: string } - const features = wallet.features as Record - const signFeature = features['solana:signTransaction'] - if (!signFeature) { - throw new Error('Wallet does not support signTransaction') - } - const account = wallet.accounts[0] - const txBytes = Uint8Array.from(atob(transaction), (c) => - c.charCodeAt(0) - ) - const [result] = await signFeature.signTransaction({ + const feature = getWalletFeature(wallet, 'solana:signTransaction') + const [result] = await feature.signTransaction({ account, - transaction: txBytes, + transaction: base64ToBytes(transaction), }) return { - signedTransaction: btoa( - String.fromCharCode(...new Uint8Array(result.signedTransaction)) + signedTransaction: bytesToBase64( + new Uint8Array(result.signedTransaction) ), } } case 'signMessage': { const { message } = params as { message: string } - const features = wallet.features as Record - const signFeature = features['solana:signMessage'] - if (!signFeature) { - throw new Error('Wallet does not support signMessage') - } - const account = wallet.accounts[0] - const msgBytes = Uint8Array.from(atob(message), (c) => - c.charCodeAt(0) - ) - const [result] = await signFeature.signMessage({ + const feature = getWalletFeature(wallet, 'solana:signMessage') + const [result] = await feature.signMessage({ account, - message: msgBytes, + message: base64ToBytes(message), }) return { - signature: btoa( - String.fromCharCode(...new Uint8Array(result.signature)) - ), + signature: bytesToBase64(new Uint8Array(result.signature)), } } case 'signAndSendTransaction': { - const { transaction } = params as { - transaction: string - options?: Record - } - const features = wallet.features as Record - const sendFeature = features['solana:signAndSendTransaction'] - if (!sendFeature) { - throw new Error('Wallet does not support signAndSendTransaction') - } - const account = wallet.accounts[0] - const txBytes = Uint8Array.from(atob(transaction), (c) => - c.charCodeAt(0) + const { transaction } = params as { transaction: string } + const feature = getWalletFeature( + wallet, + 'solana:signAndSendTransaction' ) - const [result] = await sendFeature.signAndSendTransaction({ + const [result] = await feature.signAndSendTransaction({ account, - transaction: txBytes, + transaction: base64ToBytes(transaction), }) return { - signature: btoa( - String.fromCharCode(...new Uint8Array(result.signature)) - ), + signature: bytesToBase64(new Uint8Array(result.signature)), } } @@ -150,10 +152,13 @@ export function useSolanaIframeHandler( [] ) - return { - chainType: 'SVM', - getInitState, - handleRequest, - subscribe, - } + return useMemo( + () => ({ + chainType: 'SVM' as const, + getInitState, + handleRequest, + subscribe, + }), + [getInitState, handleRequest, subscribe] + ) } diff --git a/packages/widget-light/src/handlers/sui/useSuiIframeHandler.ts b/packages/widget-light/src/handlers/sui/useSuiIframeHandler.ts index fed276594..023774890 100644 --- a/packages/widget-light/src/handlers/sui/useSuiIframeHandler.ts +++ b/packages/widget-light/src/handlers/sui/useSuiIframeHandler.ts @@ -1,11 +1,20 @@ -import { useCurrentWallet, useWallets } from '@mysten/dapp-kit' -import { useCallback, useEffect, useRef } from 'react' +import { + CurrentAccountSigner, + useCurrentWallet, + useDAppKit, + useWalletConnection, +} from '@mysten/dapp-kit-react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import type { IframeEcosystemHandler } from '../../shared/protocol.js' +function base64ToBytes(base64: string): Uint8Array { + return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) +} + /** * Host-side hook that creates an `IframeEcosystemHandler` for Sui (MVM). * - * Uses dapp-kit hooks internally to access the connected Sui wallet + * Uses dapp-kit-react hooks internally to access the connected Sui wallet * and forwards method calls from the guest iframe. * * Supported methods: @@ -15,14 +24,29 @@ import type { IframeEcosystemHandler } from '../../shared/protocol.js' * - `signAndExecuteTransaction` → sign and execute a transaction block */ export function useSuiIframeHandler(): IframeEcosystemHandler { - const wallets = useWallets() - const { currentWallet, connectionStatus } = useCurrentWallet() + const dAppKit = useDAppKit() + const currentWallet = useCurrentWallet() + const { status: connectionStatus } = useWalletConnection() const address = currentWallet?.accounts?.[0]?.address const isConnected = connectionStatus === 'connected' - - const stateRef = useRef({ address, isConnected, currentWallet, wallets }) - stateRef.current = { address, isConnected, currentWallet, wallets } + const connectorName = currentWallet?.name + const connectorIcon = currentWallet?.icon + + const stateRef = useRef({ + address, + isConnected, + dAppKit, + connectorName, + connectorIcon, + }) + stateRef.current = { + address, + isConnected, + dAppKit, + connectorName, + connectorIcon, + } const emitRef = useRef<((event: string, data: unknown) => void) | null>(null) @@ -32,31 +56,40 @@ export function useSuiIframeHandler(): IframeEcosystemHandler { useEffect(() => { if (isConnected && address) { - emitRef.current?.('connect', { address }) + emitRef.current?.('connect', { + address, + connector: connectorName + ? { name: connectorName, icon: connectorIcon } + : undefined, + }) } else { emitRef.current?.('disconnect', {}) } - }, [isConnected, address]) + }, [isConnected, address, connectorName, connectorIcon]) const getInitState = useCallback(() => { - const { address, isConnected } = stateRef.current + const { address, isConnected, connectorName, connectorIcon } = + stateRef.current return { chainType: 'MVM' as const, state: { accounts: address ? [address] : [], connected: isConnected, + connector: connectorName + ? { name: connectorName, icon: connectorIcon } + : undefined, }, } }, []) const handleRequest = useCallback( async (_id: string, method: string, params?: unknown) => { - const { currentWallet, address } = stateRef.current - if (!currentWallet || !address) { + const { address, dAppKit } = stateRef.current + if (!address) { throw new Error('Sui wallet not connected') } - const features = currentWallet.features as Record + const signer = new CurrentAccountSigner(dAppKit) switch (method) { case 'getAccount': @@ -64,18 +97,9 @@ export function useSuiIframeHandler(): IframeEcosystemHandler { case 'signTransaction': { const { transaction } = params as { transaction: string } - const signFeature = features['sui:signTransaction'] - if (!signFeature) { - throw new Error('Wallet does not support signTransaction') - } - const account = currentWallet.accounts[0] - const txBytes = Uint8Array.from(atob(transaction), (c) => - c.charCodeAt(0) + const result = await signer.signTransaction( + base64ToBytes(transaction) ) - const result = await signFeature.signTransaction({ - account, - transaction: txBytes, - }) return { signature: result.signature, bytes: result.bytes, @@ -84,18 +108,9 @@ export function useSuiIframeHandler(): IframeEcosystemHandler { case 'signPersonalMessage': { const { message } = params as { message: string } - const signFeature = features['sui:signPersonalMessage'] - if (!signFeature) { - throw new Error('Wallet does not support signPersonalMessage') - } - const account = currentWallet.accounts[0] - const msgBytes = Uint8Array.from(atob(message), (c) => - c.charCodeAt(0) + const result = await signer.signPersonalMessage( + base64ToBytes(message) ) - const result = await signFeature.signPersonalMessage({ - account, - message: msgBytes, - }) return { signature: result.signature, bytes: result.bytes, @@ -103,23 +118,8 @@ export function useSuiIframeHandler(): IframeEcosystemHandler { } case 'signAndExecuteTransaction': { - const { transaction } = params as { - transaction: string - options?: Record - } - const execFeature = features['sui:signAndExecuteTransaction'] - if (!execFeature) { - throw new Error('Wallet does not support signAndExecuteTransaction') - } - const account = currentWallet.accounts[0] - const txBytes = Uint8Array.from(atob(transaction), (c) => - c.charCodeAt(0) - ) - const result = await execFeature.signAndExecuteTransaction({ - account, - transaction: txBytes, - }) - return result + const { transaction } = params as { transaction: string } + return dAppKit.signAndExecuteTransaction({ transaction }) } default: @@ -139,10 +139,13 @@ export function useSuiIframeHandler(): IframeEcosystemHandler { [] ) - return { - chainType: 'MVM', - getInitState, - handleRequest, - subscribe, - } + return useMemo( + () => ({ + chainType: 'MVM' as const, + getInitState, + handleRequest, + subscribe, + }), + [getInitState, handleRequest, subscribe] + ) } diff --git a/packages/widget-light/src/host/WidgetLight.tsx b/packages/widget-light/src/host/WidgetLight.tsx index 34f301c91..55ec34f33 100644 --- a/packages/widget-light/src/host/WidgetLight.tsx +++ b/packages/widget-light/src/host/WidgetLight.tsx @@ -1,13 +1,18 @@ import type { CSSProperties } from 'react' import type { + ConnectWalletArgs, IframeEcosystemHandler, WidgetLightConfig, } from '../shared/protocol.js' +import { DEFAULT_WIDGET_URL } from './constants.js' import { useWidgetLightHost } from './useWidgetLightHost.js' export interface WidgetLightProps { - /** URL of the widget iframe page. */ - src: string + /** + * URL of the widget iframe page. + * Defaults to {@link DEFAULT_WIDGET_URL} (`'https://widget.li.fi'`). + */ + src?: string /** * Widget configuration to pass to the iframe on init. * Must be JSON-serialisable (no React nodes or functions). @@ -20,15 +25,21 @@ export interface WidgetLightProps { handlers?: IframeEcosystemHandler[] /** * Expected origin of the iframe for origin-pinning security. - * Defaults to '*' — always set this in production. + * Derived from `src` when not provided. */ iframeOrigin?: string /** - * When true (default), the iframe height auto-adjusts to match the guest - * content via RESIZE messages. Set to false for fluid layouts where the - * iframe should fill its parent and scroll internally. + * When true, the iframe height auto-adjusts to match the guest content + * via RESIZE messages. Defaults to false (the iframe fills its parent + * and scrolls internally). */ autoResize?: boolean + /** + * Called when the widget requests an external wallet connection. + * When provided, the widget will send a CONNECT_WALLET_REQUEST to the + * host instead of opening its internal wallet menu. + */ + onConnect?(args?: ConnectWalletArgs): void style?: CSSProperties className?: string title?: string @@ -38,6 +49,8 @@ export interface WidgetLightProps { * Drop-in host-side component that renders an `