-
+ {icon ? : iconElement}
- {fill && buttonType === RoundButtonType.WalletConnect ? (
+ {indicator === "connected" ? (
) : null}
- {fill && buttonType === RoundButtonType.GoodDollar ? (
+ {indicator === "available" ? (
{
}
export const logout = () => {
+ resetWalletConnectDialogs()
setSession(null)
}
diff --git a/src/sections/Home/components/WalletSection.tsx b/src/sections/Home/components/WalletSection.tsx
index a5fa8da..d3eb5fd 100644
--- a/src/sections/Home/components/WalletSection.tsx
+++ b/src/sections/Home/components/WalletSection.tsx
@@ -11,10 +11,7 @@ import { useSnapshot } from "valtio"
import { useTranslation } from "translations"
import { AnalyticsEventTypes } from "@/analytics/types"
import { useAnalytics } from "@/analytics/useAnalytics"
-import {
- RoundButton,
- RoundButtonType,
-} from "@/components/Form/RoundButton/RoundButton"
+import { RoundButton } from "@/components/Form/RoundButton/RoundButton"
import { BottomSheet } from "@/components/Snippet/BottomSheet/BottomSheet"
import { useBottomSheetSnapshot } from "@/components/Snippet/BottomSheet/bottomSheetStore"
import { config } from "@/config"
@@ -31,6 +28,10 @@ import { pwaVersionStore } from "@/stores/versioningStore"
import { isDeltaMobile, isPasskeyEnabled } from "@/utils/getClientEnvironment"
import { postMessageToReactNative } from "@/utils/messageReactNative"
import { isPwa } from "@/utils/pwa"
+import {
+ coreDashboardActions,
+ widgetDashboardActions,
+} from "@/widgets/registry"
import { Menu } from "./Menu"
import { ProfileCard } from "./ProfileCard"
@@ -187,61 +188,15 @@ export default function WalletSection({
}
}, [hasMultipleActionRows])
- const sendLink = (
-
-
-
- )
-
- const receiveLink = (
-
-
-
- )
-
- const swapLink = (
-
-
-
- )
-
- const predictionsLink = (
-
-
-
- )
-
- const goodDollarLink = (
-
-
-
- )
-
- const walletConnectLink = (
-
- 0}
- />
-
- )
+ const actionLabels: Record = {
+ gooddollar: homeTranslations.gooddollar,
+ send: homeTranslations.send,
+ receive: homeTranslations.receive,
+ swap: homeTranslations.swap,
+ predictions: homeTranslations.predictions,
+ walletconnect: homeTranslations.walletConnect,
+ }
+ const dashboardActions = [...coreDashboardActions, ...widgetDashboardActions]
return (
<>
@@ -273,12 +228,34 @@ export default function WalletSection({
}
data-testid="wallet-actions"
>
- {goodDollarLink}
- {sendLink}
- {receiveLink}
- {swapLink}
- {predictionsLink}
- {walletConnectLink}
+ {dashboardActions.map((action) => {
+ const icon =
+ action.icon.kind === "system" ? action.icon.name : undefined
+ const iconElement =
+ action.icon.kind === "local" ? action.icon.render() : undefined
+ const indicator =
+ action.id === "gooddollar" && canClaim
+ ? "available"
+ : action.id === "walletconnect" && sessions.length > 0
+ ? "connected"
+ : undefined
+
+ return (
+
+
+
+ )
+ })}
{hasMultipleActionRows ? (
({
+ type: "generic" as const,
+ title,
+ bodyText: title,
+ acceptBtnText: "Approve",
+})
+
+describe("openWalletConnectDialog", () => {
+ afterEach(() => {
+ resetWalletConnectDialogs()
+ vi.useRealTimers()
+ })
+
+ it("keeps concurrent approvals bound to their own dialogs", async () => {
+ const first = openWalletConnectDialog(dialog("First"))
+ const second = openWalletConnectDialog(dialog("Second"))
+
+ expect(walletConnectDialogStore.dialog).toMatchObject({ title: "First" })
+
+ walletConnectDialogStore.status = "accepted"
+ await expect(first).resolves.toBe("accepted")
+ expect(walletConnectDialogStore.dialog).toMatchObject({ title: "Second" })
+
+ walletConnectDialogStore.status = "rejected"
+ await expect(second).resolves.toBe("rejected")
+ })
+
+ it("rejects active and queued dialogs when reset", async () => {
+ const first = openWalletConnectDialog(dialog("First"))
+ const second = openWalletConnectDialog(dialog("Second"))
+
+ resetWalletConnectDialogs()
+
+ await expect(first).resolves.toBe("rejected")
+ await expect(second).resolves.toBe("rejected")
+
+ const next = openWalletConnectDialog(dialog("Next"))
+ expect(walletConnectDialogStore.dialog).toMatchObject({ title: "Next" })
+ walletConnectDialogStore.status = "rejected"
+ await expect(next).resolves.toBe("rejected")
+ })
+
+ it("ignores a delayed update after reset", async () => {
+ vi.useFakeTimers()
+ const first = openWalletConnectDialog(dialog("First"))
+ const update = updateWalletConnectDialogStatus("accepted")
+
+ resetWalletConnectDialogs()
+ await expect(first).resolves.toBe("rejected")
+
+ const second = openWalletConnectDialog(dialog("Second"))
+ await vi.advanceTimersByTimeAsync(200)
+ await update
+
+ expect(walletConnectDialogStore.status).toBe("pending")
+ resetWalletConnectDialogs()
+ await expect(second).resolves.toBe("rejected")
+ })
+})
diff --git a/src/sections/WalletConnect/store/walletConnectDialogStore.ts b/src/sections/WalletConnect/store/walletConnectDialogStore.ts
index ef6c220..2fa7d3c 100644
--- a/src/sections/WalletConnect/store/walletConnectDialogStore.ts
+++ b/src/sections/WalletConnect/store/walletConnectDialogStore.ts
@@ -44,26 +44,85 @@ export const walletConnectDialogStore = proxy<{
exiting: boolean
}>()
-export const openWalletConnectDialog = async (args: WalletConnectDialog) => {
- walletConnectDialogStore.dialog = args
+type QueuedDialog = {
+ args: WalletConnectDialog
+ resolve: (status: WalletConnectDialogStatus) => void
+}
+
+const dialogQueue: QueuedDialog[] = []
+let activeDialog: QueuedDialog | undefined
+let unsubscribeActive: (() => void) | undefined
+
+const settleActiveDialog = (status: WalletConnectDialogStatus): void => {
+ const settledDialog = activeDialog
+ if (!settledDialog) return
+
+ unsubscribeActive?.()
+ unsubscribeActive = undefined
+ activeDialog = undefined
+ settledDialog.resolve(status)
+ openNextDialog()
+}
+
+const openNextDialog = (): void => {
+ if (activeDialog) return
+ const queuedDialog = dialogQueue.shift()
+ if (!queuedDialog) return
+
+ activeDialog = queuedDialog
+ walletConnectDialogStore.dialog = queuedDialog.args
walletConnectDialogStore.status = "pending"
walletConnectDialogStore.exiting = false
- await new Promise((resolve) => {
- subscribe(walletConnectDialogStore, () => {
- if (walletConnectDialogStore.status !== "pending") {
- resolve(null)
- }
- })
+ unsubscribeActive = subscribe(walletConnectDialogStore, () => {
+ if (walletConnectDialogStore.status === "pending") return
+ settleActiveDialog(walletConnectDialogStore.status)
})
+}
+
+export const openWalletConnectDialog = (
+ args: WalletConnectDialog,
+): Promise
=>
+ new Promise((resolve) => {
+ dialogQueue.push({ args, resolve })
+ openNextDialog()
+ })
+
+export const resetWalletConnectDialogs = (): void => {
+ const pendingDialogs = activeDialog
+ ? [activeDialog, ...dialogQueue.splice(0)]
+ : dialogQueue.splice(0)
- return walletConnectDialogStore.status as WalletConnectDialogStatus
+ unsubscribeActive?.()
+ unsubscribeActive = undefined
+ activeDialog = undefined
+ walletConnectDialogStore.status = "rejected"
+ walletConnectDialogStore.exiting = false
+
+ for (const pendingDialog of pendingDialogs) {
+ pendingDialog.resolve("rejected")
+ }
}
export const updateWalletConnectDialogStatus = async (
status: WalletConnectDialogStatus,
) => {
+ const dialog = activeDialog
+ if (
+ !dialog ||
+ walletConnectDialogStore.status !== "pending" ||
+ walletConnectDialogStore.exiting
+ ) {
+ return
+ }
+
walletConnectDialogStore.exiting = true
await new Promise((resolve) => setTimeout(resolve, 200))
+ if (
+ activeDialog !== dialog ||
+ walletConnectDialogStore.status !== "pending"
+ ) {
+ return
+ }
walletConnectDialogStore.status = status
}
diff --git a/src/widgets/AuthenticatedWidgetRoute.tsx b/src/widgets/AuthenticatedWidgetRoute.tsx
new file mode 100644
index 0000000..583d32e
--- /dev/null
+++ b/src/widgets/AuthenticatedWidgetRoute.tsx
@@ -0,0 +1,73 @@
+"use client"
+
+import { useEffect } from "react"
+
+import { setBottomSheetProps } from "@/components/Snippet/BottomSheet/bottomSheetStore"
+import { LoadingSpinner } from "@/components/Snippet/LoadingSpinner"
+import { useSessionContext } from "@/login/hooks/context"
+
+import type { WidgetHostProps } from "./hostTypes"
+import { useWidgetProvider, WidgetProvider } from "./provider/WidgetProvider"
+import { type RegisteredWidget, widgetRegistry } from "./registry"
+import { WidgetRenderer } from "./WidgetRenderer"
+
+export const AuthenticatedWidgetRoute = ({
+ widgetId,
+ themeOverrides,
+ config,
+}: {
+ widgetId: string
+ themeOverrides?: WidgetHostProps["themeOverrides"]
+ config?: WidgetHostProps["config"]
+}) => {
+ const { signer, isLoading } = useSessionContext()
+ const widget = widgetRegistry.get(widgetId)
+
+ useEffect(() => {
+ if (widget) {
+ setBottomSheetProps({
+ title: widget.displayName,
+ subtitle: widget.description,
+ })
+ }
+ return () => setBottomSheetProps({})
+ }, [widget])
+
+ if (!widget) throw new Error(`Unknown widget route: ${widgetId}`)
+ if (isLoading) return
+ if (!signer?.EVM) return null
+
+ return (
+
+
+
+ )
+}
+
+const MountedWidget = ({
+ widget,
+ themeOverrides,
+ config,
+}: {
+ widget: RegisteredWidget
+ themeOverrides?: WidgetHostProps["themeOverrides"]
+ config?: WidgetHostProps["config"]
+}) => {
+ const provider = useWidgetProvider()
+
+ return (
+
+ )
+}
diff --git a/src/widgets/WidgetRenderer.test.tsx b/src/widgets/WidgetRenderer.test.tsx
new file mode 100644
index 0000000..fa67489
--- /dev/null
+++ b/src/widgets/WidgetRenderer.test.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest"
+
+import { ReactWidgetHost } from "./hosts/ReactWidgetHost"
+import { WebComponentWidgetHost } from "./hosts/WebComponentWidgetHost"
+import type { RestrictedEip1193Provider } from "./provider/RestrictedEip1193Provider"
+import { defineWidget } from "./registry"
+import { WidgetRenderer } from "./WidgetRenderer"
+
+const webComponentWidget = defineWidget({
+ widgetId: "goodwidget.web-component",
+ packageName: "@goodwidget/web-component",
+ packageVersion: "1.0.0",
+ routeSlug: "web-component",
+ displayName: "Web Component",
+ description: "Web Component test widget",
+ icon: { kind: "system", name: "Cash" },
+ entry: {
+ tagName: "gw-test-widget",
+ load: async () => ({
+ goodWidgetMetadata: {
+ packageName: "@goodwidget/web-component",
+ packageVersion: "1.0.0",
+ },
+ register: (tagName) => tagName ?? "gw-test-widget",
+ }),
+ },
+ providerPolicy: {
+ chainIds: [42220],
+ requiredMethods: ["eth_accounts"],
+ },
+})
+
+const reactWidget = defineWidget({
+ widgetId: "goodwidget.react",
+ packageName: "@goodwidget/react",
+ packageVersion: "1.0.0",
+ routeSlug: "react",
+ displayName: "React",
+ description: "React test widget",
+ icon: { kind: "system", name: "Cash" },
+ integrationMode: "react",
+ entry: {
+ exportName: "TestWidget",
+ load: async () => ({
+ goodWidgetMetadata: {
+ packageName: "@goodwidget/react",
+ packageVersion: "1.0.0",
+ },
+ TestWidget: () => null,
+ }),
+ },
+ providerPolicy: {
+ chainIds: [42220],
+ requiredMethods: ["eth_accounts"],
+ },
+})
+
+const provider = {} as RestrictedEip1193Provider
+
+describe("WidgetRenderer", () => {
+ it("uses the Web Component host when integrationMode is omitted", () => {
+ const element = WidgetRenderer({
+ widget: webComponentWidget,
+ provider,
+ })
+
+ expect(element.type).toBe(WebComponentWidgetHost)
+ expect(element.props).toMatchObject({
+ load: webComponentWidget.entry.load,
+ tagName: "gw-test-widget",
+ packageName: "@goodwidget/web-component",
+ packageVersion: "1.0.0",
+ provider,
+ })
+ })
+
+ it("uses the React host only when a widget explicitly selects it", () => {
+ const element = WidgetRenderer({ widget: reactWidget, provider })
+
+ expect(element.type).toBe(ReactWidgetHost)
+ expect(element.props).toMatchObject({
+ load: reactWidget.entry.load,
+ exportName: "TestWidget",
+ packageName: "@goodwidget/react",
+ packageVersion: "1.0.0",
+ provider,
+ })
+ })
+})
diff --git a/src/widgets/WidgetRenderer.tsx b/src/widgets/WidgetRenderer.tsx
new file mode 100644
index 0000000..0b084c9
--- /dev/null
+++ b/src/widgets/WidgetRenderer.tsx
@@ -0,0 +1,35 @@
+"use client"
+
+import { ReactWidgetHost } from "./hosts/ReactWidgetHost"
+import { WebComponentWidgetHost } from "./hosts/WebComponentWidgetHost"
+import type { WidgetHostProps } from "./hostTypes"
+import type { RegisteredWidget } from "./registry"
+
+export const WidgetRenderer = ({
+ widget,
+ ...hostProps
+}: WidgetHostProps & {
+ widget: RegisteredWidget
+}) => {
+ if (widget.integrationMode === "react") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/widgets/fixtures/TestFixtureWidget.tsx b/src/widgets/fixtures/TestFixtureWidget.tsx
new file mode 100644
index 0000000..0fe168c
--- /dev/null
+++ b/src/widgets/fixtures/TestFixtureWidget.tsx
@@ -0,0 +1,16 @@
+"use client"
+
+import type { WidgetHostProps } from "../hostTypes"
+
+export const goodWidgetMetadata = {
+ packageName: "@goodwidget/test-fixture",
+ packageVersion: "0.0.0",
+} as const
+
+export const TestFixtureWidget = (_props: WidgetHostProps) => {
+ return (
+
+ Test Fixture Widget
+
+ )
+}
diff --git a/src/widgets/hostProperties.test.ts b/src/widgets/hostProperties.test.ts
new file mode 100644
index 0000000..c13a3e9
--- /dev/null
+++ b/src/widgets/hostProperties.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest"
+
+import {
+ assignHostedWidgetProperties,
+ clearHostedWidgetProperties,
+} from "./hostProperties"
+import type { HostedWidgetElement } from "./hostTypes"
+import type { RestrictedEip1193Provider } from "./provider/RestrictedEip1193Provider"
+
+describe("Web Component host properties", () => {
+ it("hands objects over by identity and clears Wallet references on cleanup", () => {
+ const provider = {} as RestrictedEip1193Provider
+ const element = {} as HostedWidgetElement
+ const themeOverrides = { color: { primary: "#00AFFE" } }
+ const config = { environment: "production" }
+
+ assignHostedWidgetProperties(element, {
+ provider,
+ themeOverrides,
+ config,
+ })
+
+ expect(element.provider).toBe(provider)
+ expect(element.themeOverrides).toBe(themeOverrides)
+ expect(element.config).toBe(config)
+
+ clearHostedWidgetProperties(element)
+
+ expect(element.provider).toBeNull()
+ expect(element.themeOverrides).toBeUndefined()
+ expect(element.config).toBeUndefined()
+ })
+})
diff --git a/src/widgets/hostProperties.ts b/src/widgets/hostProperties.ts
new file mode 100644
index 0000000..c8542a5
--- /dev/null
+++ b/src/widgets/hostProperties.ts
@@ -0,0 +1,18 @@
+import type { HostedWidgetElement, WidgetHostProps } from "./hostTypes"
+
+export const assignHostedWidgetProperties = (
+ element: HostedWidgetElement,
+ { provider, themeOverrides, config }: WidgetHostProps,
+): void => {
+ element.provider = provider
+ element.themeOverrides = themeOverrides
+ element.config = config
+}
+
+export const clearHostedWidgetProperties = (
+ element: HostedWidgetElement,
+): void => {
+ element.provider = null
+ element.themeOverrides = undefined
+ element.config = undefined
+}
diff --git a/src/widgets/hostTypes.ts b/src/widgets/hostTypes.ts
new file mode 100644
index 0000000..496db4a
--- /dev/null
+++ b/src/widgets/hostTypes.ts
@@ -0,0 +1,53 @@
+import type { ComponentType } from "react"
+
+import type { RestrictedEip1193Provider } from "./provider/RestrictedEip1193Provider"
+
+export type WidgetHostProps = {
+ provider: RestrictedEip1193Provider
+ themeOverrides?: Record
+ config?: Record
+}
+
+export type WidgetModuleMetadata = {
+ packageName: string
+ packageVersion: string
+}
+
+export type WidgetModule = {
+ goodWidgetMetadata: WidgetModuleMetadata
+}
+
+export type ReactWidgetModule = Record & WidgetModule
+
+export type ReactWidgetLoader = () => Promise
+
+export type HostedReactWidget = ComponentType
+
+export type WebComponentWidgetModule = WidgetModule & {
+ register: (tagName?: string) => string | Promise
+}
+
+export type WebComponentWidgetLoader = () => Promise
+
+export const assertWidgetModuleMetadata = (
+ module: WidgetModule,
+ packageName: string,
+ packageVersion: string,
+): void => {
+ const metadata = module.goodWidgetMetadata
+ if (
+ !metadata ||
+ metadata.packageName !== packageName ||
+ metadata.packageVersion !== packageVersion
+ ) {
+ throw new Error(
+ `Widget module identity mismatch: expected ${packageName}@${packageVersion}`,
+ )
+ }
+}
+
+export type HostedWidgetElement = HTMLElement & {
+ provider: RestrictedEip1193Provider | null
+ themeOverrides?: Record
+ config?: Record
+}
diff --git a/src/widgets/hosts/ReactWidgetHost.tsx b/src/widgets/hosts/ReactWidgetHost.tsx
new file mode 100644
index 0000000..18fbe99
--- /dev/null
+++ b/src/widgets/hosts/ReactWidgetHost.tsx
@@ -0,0 +1,60 @@
+"use client"
+
+import { useEffect, useState } from "react"
+
+import { LoadingSpinner } from "@/components/Snippet/LoadingSpinner"
+
+import {
+ assertWidgetModuleMetadata,
+ type HostedReactWidget,
+ type ReactWidgetLoader,
+ type WidgetHostProps,
+} from "../hostTypes"
+import { resolveReactWidget } from "../resolveReactWidget"
+
+export const ReactWidgetHost = ({
+ load,
+ exportName,
+ packageName,
+ packageVersion,
+ ...hostProps
+}: WidgetHostProps & {
+ load: ReactWidgetLoader
+ exportName: string
+ packageName: string
+ packageVersion: string
+}) => {
+ const [Component, setComponent] = useState(null)
+ const [loadError, setLoadError] = useState(null)
+
+ useEffect(() => {
+ let isMounted = true
+ setComponent(null)
+ setLoadError(null)
+
+ load()
+ .then((module) => {
+ if (!isMounted) return
+ assertWidgetModuleMetadata(module, packageName, packageVersion)
+ const exportedComponent = resolveReactWidget(module, exportName)
+ setComponent(() => exportedComponent)
+ })
+ .catch((error: unknown) => {
+ if (!isMounted) return
+ setLoadError(
+ error instanceof Error
+ ? error
+ : new Error("Unable to load the widget React entry"),
+ )
+ })
+
+ return () => {
+ isMounted = false
+ }
+ }, [exportName, load, packageName, packageVersion])
+
+ if (loadError) throw loadError
+ if (!Component) return
+
+ return
+}
diff --git a/src/widgets/hosts/WebComponentWidgetHost.test.ts b/src/widgets/hosts/WebComponentWidgetHost.test.ts
new file mode 100644
index 0000000..744daa2
--- /dev/null
+++ b/src/widgets/hosts/WebComponentWidgetHost.test.ts
@@ -0,0 +1,83 @@
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+import { registerElement } from "./WebComponentWidgetHost"
+
+describe("Custom Element registration", () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+ })
+
+ it("registers a tag once and rejects a different loader for the same tag", async () => {
+ const define = vi.fn()
+ const get = vi.fn<() => CustomElementConstructor | undefined>(
+ () => undefined,
+ )
+ vi.stubGlobal("customElements", { define, get })
+
+ const firstLoad = vi.fn(async () => ({
+ goodWidgetMetadata: {
+ packageName: "@goodwidget/test-widget",
+ packageVersion: "1.0.0",
+ },
+ register: async (tagName?: string) => {
+ get.mockReturnValue(class {} as CustomElementConstructor)
+ return tagName ?? "gw-widget"
+ },
+ }))
+ const secondLoad = vi.fn(async () => ({
+ goodWidgetMetadata: {
+ packageName: "@goodwidget/test-widget",
+ packageVersion: "1.0.0",
+ },
+ register: async (tagName?: string) => tagName ?? "gw-widget",
+ }))
+
+ await expect(
+ registerElement(
+ firstLoad,
+ "gw-widget",
+ "@goodwidget/test-widget",
+ "1.0.0",
+ ),
+ ).resolves.toBe("gw-widget")
+ await expect(
+ registerElement(
+ firstLoad,
+ "gw-widget",
+ "@goodwidget/test-widget",
+ "1.0.0",
+ ),
+ ).resolves.toBe("gw-widget")
+ expect(firstLoad).toHaveBeenCalledTimes(1)
+ await expect(
+ registerElement(
+ secondLoad,
+ "gw-widget",
+ "@goodwidget/test-widget",
+ "1.0.0",
+ ),
+ ).rejects.toThrow("already registered")
+ })
+
+ it("rejects module metadata that does not match the registry", async () => {
+ const get = vi.fn(() => undefined)
+ vi.stubGlobal("customElements", { define: vi.fn(), get })
+ const load = vi.fn(async () => ({
+ goodWidgetMetadata: {
+ packageName: "@goodwidget/test-widget",
+ packageVersion: "2.0.0",
+ },
+ register: async (tagName?: string) => tagName ?? "gw-versioned-widget",
+ }))
+
+ await expect(
+ registerElement(
+ load,
+ "gw-versioned-widget",
+ "@goodwidget/test-widget",
+ "1.0.0",
+ ),
+ ).rejects.toThrow("expected @goodwidget/test-widget@1.0.0")
+ })
+})
diff --git a/src/widgets/hosts/WebComponentWidgetHost.tsx b/src/widgets/hosts/WebComponentWidgetHost.tsx
new file mode 100644
index 0000000..c675e7d
--- /dev/null
+++ b/src/widgets/hosts/WebComponentWidgetHost.tsx
@@ -0,0 +1,148 @@
+"use client"
+
+import { createElement, useEffect, useRef, useState } from "react"
+
+import { LoadingSpinner } from "@/components/Snippet/LoadingSpinner"
+
+import {
+ assignHostedWidgetProperties,
+ clearHostedWidgetProperties,
+} from "../hostProperties"
+import {
+ assertWidgetModuleMetadata,
+ type HostedWidgetElement,
+ type WebComponentWidgetLoader,
+ type WidgetHostProps,
+} from "../hostTypes"
+
+type TagRegistration = {
+ load: WebComponentWidgetLoader
+ packageName: string
+ packageVersion: string
+ promise: Promise
+}
+
+const registrationCache = new Map()
+
+export const registerElement = (
+ load: WebComponentWidgetLoader,
+ tagName: string,
+ packageName: string,
+ packageVersion: string,
+): Promise => {
+ const existingDefinition = customElements.get(tagName)
+ const cached = registrationCache.get(tagName)
+ if (cached) {
+ if (
+ cached.load !== load ||
+ cached.packageName !== packageName ||
+ cached.packageVersion !== packageVersion
+ ) {
+ return Promise.reject(
+ new Error(`Custom Element tag ${tagName} is already registered`),
+ )
+ }
+ return cached.promise
+ }
+ if (existingDefinition) {
+ return Promise.reject(
+ new Error(`Custom Element tag ${tagName} is already registered`),
+ )
+ }
+
+ const promise = load()
+ .then(async (module) => {
+ assertWidgetModuleMetadata(module, packageName, packageVersion)
+ const registered = await module.register(tagName)
+ if (registered !== tagName) {
+ throw new Error(
+ `Widget registered ${registered}; expected declared tag ${tagName}`,
+ )
+ }
+ if (!customElements.get(tagName)) {
+ throw new Error(`Widget did not register Custom Element ${tagName}`)
+ }
+ return registered
+ })
+ .catch((error: unknown) => {
+ registrationCache.delete(tagName)
+ throw error
+ })
+
+ registrationCache.set(tagName, {
+ load,
+ packageName,
+ packageVersion,
+ promise,
+ })
+ return promise
+}
+
+export const WebComponentWidgetHost = ({
+ load,
+ tagName,
+ packageName,
+ packageVersion,
+ provider,
+ themeOverrides,
+ config,
+}: WidgetHostProps & {
+ load: WebComponentWidgetLoader
+ tagName: string
+ packageName: string
+ packageVersion: string
+}) => {
+ const elementRef = useRef(null)
+ const [registeredTagName, setRegisteredTagName] = useState(
+ null,
+ )
+ const [loadError, setLoadError] = useState(null)
+
+ useEffect(() => {
+ let isMounted = true
+ setRegisteredTagName(null)
+ setLoadError(null)
+
+ registerElement(load, tagName, packageName, packageVersion)
+ .then((registered) => {
+ if (!isMounted) return
+ setRegisteredTagName(registered)
+ })
+ .catch((error: unknown) => {
+ if (!isMounted) return
+ setLoadError(
+ error instanceof Error
+ ? error
+ : new Error("Unable to register the widget Custom Element"),
+ )
+ })
+
+ return () => {
+ isMounted = false
+ }
+ }, [load, packageName, packageVersion, tagName])
+
+ useEffect(() => {
+ const element = elementRef.current
+ if (!element || !registeredTagName) return
+
+ assignHostedWidgetProperties(element, {
+ provider,
+ themeOverrides,
+ config,
+ })
+
+ return () => {
+ clearHostedWidgetProperties(element)
+ }
+ }, [config, provider, registeredTagName, themeOverrides])
+
+ if (loadError) throw loadError
+ if (!registeredTagName) return
+
+ return createElement(registeredTagName, {
+ ref: (element: HTMLElement | null) => {
+ elementRef.current = element as HostedWidgetElement | null
+ },
+ })
+}
diff --git a/src/widgets/provider/RestrictedEip1193Provider.test.ts b/src/widgets/provider/RestrictedEip1193Provider.test.ts
new file mode 100644
index 0000000..e52c385
--- /dev/null
+++ b/src/widgets/provider/RestrictedEip1193Provider.test.ts
@@ -0,0 +1,231 @@
+import type { TransactionRequest } from "ethers"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+import type { EVMSigner } from "@/login"
+
+vi.mock("@/chain/provider/EVM/viemClients", () => ({
+ getViemClient: vi.fn(),
+}))
+
+import {
+ RestrictedEip1193Provider,
+ WidgetProviderError,
+} from "./RestrictedEip1193Provider"
+
+const address = "0x1111111111111111111111111111111111111111"
+const signer = {
+ address,
+ signMessage: vi.fn(async () => "0xmessage"),
+ signTypedData: vi.fn(async () => "0xtyped"),
+ signTransaction: vi.fn(async () => "0xraw"),
+}
+
+const allTestMethods = [
+ "eth_accounts",
+ "eth_requestAccounts",
+ "eth_chainId",
+ "wallet_switchEthereumChain",
+ "personal_sign",
+ "eth_sendTransaction",
+]
+
+const createProvider = (
+ options: Partial<
+ ConstructorParameters[0]
+ > = {},
+) => {
+ const rpcRequest = vi.fn(async () => "0xresult")
+ const requestWalletApproval = vi.fn(async () => true)
+ const prepareTransaction = vi.fn(
+ async (
+ _chainId: number,
+ _signer: EVMSigner,
+ request: TransactionRequest,
+ ) => ({
+ ...request,
+ nonce: 7,
+ gasLimit: request.gasLimit ?? BigInt(21000),
+ gasPrice: BigInt(1),
+ }),
+ )
+ return {
+ provider: new RestrictedEip1193Provider({
+ signer,
+ chainIds: [1, 42220],
+ rpcRequest,
+ requestWalletApproval,
+ prepareTransaction,
+ ...options,
+ requiredMethods: options.requiredMethods ?? allTestMethods,
+ }),
+ rpcRequest,
+ requestWalletApproval,
+ prepareTransaction,
+ }
+}
+
+describe("RestrictedEip1193Provider", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("exposes only the active account", async () => {
+ const { provider } = createProvider()
+ await expect(provider.request({ method: "eth_accounts" })).resolves.toEqual(
+ [address],
+ )
+ })
+
+ it("enforces each widget's configured method subset", async () => {
+ const { provider } = createProvider({
+ requiredMethods: ["eth_accounts"],
+ })
+ await expect(
+ provider.request({
+ method: "eth_chainId",
+ }),
+ ).rejects.toMatchObject({ code: 4100 })
+ })
+
+ it("rejects unknown methods and incompatible provider contracts", async () => {
+ const { provider } = createProvider()
+ await expect(
+ provider.request({ method: "wallet_getSeed" }),
+ ).rejects.toMatchObject({ code: 4200 })
+ expect(() =>
+ createProvider({ requiredMethods: ["wallet_getSeed"] }),
+ ).toThrow("unsupported provider methods")
+ })
+
+ it("allows only configured chain switches and emits normalized chain IDs", async () => {
+ const { provider } = createProvider()
+ const listener = vi.fn()
+ provider.on("chainChanged", listener)
+ await provider.request({
+ method: "wallet_switchEthereumChain",
+ params: [{ chainId: "0xa4ec" }],
+ })
+ expect(listener).toHaveBeenCalledWith("0xa4ec")
+ await expect(
+ provider.request({
+ method: "wallet_switchEthereumChain",
+ params: [{ chainId: "0x89" }],
+ }),
+ ).rejects.toBeInstanceOf(WidgetProviderError)
+ })
+
+ it("requires account and chain validation plus Wallet approval for transactions", async () => {
+ const { provider, requestWalletApproval, prepareTransaction, rpcRequest } =
+ createProvider()
+ await expect(
+ provider.request({
+ method: "eth_sendTransaction",
+ params: [
+ {
+ from: address,
+ chainId: 1,
+ to: address,
+ value: BigInt(1),
+ },
+ ],
+ }),
+ ).resolves.toBe("0xresult")
+ expect(requestWalletApproval).toHaveBeenCalledWith(
+ expect.objectContaining({ method: "eth_sendTransaction", chainId: 1 }),
+ )
+ expect(prepareTransaction).toHaveBeenCalledWith(
+ 1,
+ signer,
+ expect.objectContaining({ to: address }),
+ )
+ expect(signer.signTransaction).toHaveBeenCalled()
+ expect(rpcRequest).toHaveBeenCalledWith(1, {
+ method: "eth_sendRawTransaction",
+ params: ["0xraw"],
+ })
+ })
+
+ it("keeps signing disabled until the Wallet explicitly approves it", async () => {
+ const { provider } = createProvider({ requestWalletApproval: undefined })
+ await expect(
+ provider.request({
+ method: "personal_sign",
+ params: ["message", address],
+ }),
+ ).rejects.toMatchObject({ code: 4001 })
+ })
+
+ it("rejects mismatched signing accounts before approval", async () => {
+ const { provider, requestWalletApproval } = createProvider()
+ await expect(
+ provider.request({
+ method: "personal_sign",
+ params: ["message", "0x2222222222222222222222222222222222222222"],
+ }),
+ ).rejects.toMatchObject({ code: 4100 })
+ expect(requestWalletApproval).not.toHaveBeenCalled()
+ })
+
+ it("signs the transaction snapshot that was presented for approval", async () => {
+ let resolveApproval: ((approved: boolean) => void) | undefined
+ const requestWalletApproval = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveApproval = resolve
+ }),
+ )
+ const { provider, prepareTransaction } = createProvider({
+ requestWalletApproval,
+ })
+ const originalRecipient = "0x2222222222222222222222222222222222222222"
+ const changedRecipient = "0x3333333333333333333333333333333333333333"
+ const transaction = {
+ from: address,
+ chainId: 1,
+ to: originalRecipient,
+ value: BigInt(1),
+ }
+ const pending = provider.request({
+ method: "eth_sendTransaction",
+ params: [transaction],
+ })
+ await vi.waitFor(() => expect(requestWalletApproval).toHaveBeenCalled())
+ transaction.to = changedRecipient
+ resolveApproval?.(true)
+ await expect(pending).resolves.toBe("0xresult")
+ expect(prepareTransaction).toHaveBeenCalledWith(
+ 1,
+ signer,
+ expect.objectContaining({ to: originalRecipient }),
+ )
+ expect(signer.signTransaction).toHaveBeenCalledWith(
+ expect.objectContaining({ to: originalRecipient }),
+ )
+ })
+
+ it("revokes retained providers and pending approvals", async () => {
+ let resolveApproval: ((approved: boolean) => void) | undefined
+ const requestWalletApproval = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveApproval = resolve
+ }),
+ )
+ const { provider } = createProvider({ requestWalletApproval })
+ const listener = vi.fn()
+ provider.on("accountsChanged", listener)
+ const pending = provider.request({
+ method: "personal_sign",
+ params: ["message", address],
+ })
+ await vi.waitFor(() => expect(requestWalletApproval).toHaveBeenCalled())
+ provider.dispose()
+ resolveApproval?.(true)
+ await expect(pending).rejects.toMatchObject({ code: 4100 })
+ await expect(
+ provider.request({ method: "eth_accounts" }),
+ ).rejects.toMatchObject({ code: 4100 })
+ expect(listener).toHaveBeenCalledWith([])
+ expect(signer.signMessage).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/widgets/provider/RestrictedEip1193Provider.ts b/src/widgets/provider/RestrictedEip1193Provider.ts
new file mode 100644
index 0000000..af7b3c7
--- /dev/null
+++ b/src/widgets/provider/RestrictedEip1193Provider.ts
@@ -0,0 +1,380 @@
+import type { TransactionRequest } from "ethers"
+import { getBytes } from "ethers"
+import { copyRequest } from "ethers/providers"
+
+import { getViemClient } from "@/chain/provider/EVM/viemClients"
+import { getEthersProvider } from "@/ethers-utils"
+import type { EVMSigner } from "@/login"
+import { WalletWrapper } from "@/sections/WalletConnect/utils/WalletWrapper"
+
+import { WIDGET_EVM_CHAIN_IDS, WIDGET_PROVIDER_METHODS } from "./policy"
+
+export type ProviderRequest = {
+ method: string
+ params?: readonly unknown[] | Record
+}
+
+export type WalletApprovalRequest = {
+ method: string
+ params: readonly unknown[]
+ account: string
+ chainId: number
+}
+
+export class WidgetProviderError extends Error {
+ constructor(
+ public readonly code: 4001 | 4100 | 4200 | 4901,
+ message: string,
+ public readonly data?: unknown,
+ ) {
+ super(message)
+ this.name = "WidgetProviderError"
+ }
+}
+
+export type RestrictedProviderOptions = {
+ signer: EVMSigner
+ chainIds: readonly number[]
+ requiredMethods: readonly string[]
+ initialChainId?: number
+ requestWalletApproval?: (request: WalletApprovalRequest) => Promise
+ prepareTransaction?: (
+ chainId: number,
+ signer: EVMSigner,
+ request: TransactionRequest,
+ ) => Promise
+ rpcRequest?: (chainId: number, request: ProviderRequest) => Promise
+}
+
+const asParams = (params: ProviderRequest["params"]): readonly unknown[] =>
+ Array.isArray(params) ? params : []
+
+const asChainId = (value: unknown): number => {
+ if (typeof value !== "string" || !/^0x[0-9a-f]+$/i.test(value)) {
+ throw new WidgetProviderError(4200, "Invalid hexadecimal chain ID")
+ }
+ return Number.parseInt(value, 16)
+}
+
+const addressesMatch = (left: unknown, right: string): boolean =>
+ typeof left === "string" && left.toLowerCase() === right.toLowerCase()
+
+const approvalTransaction = (
+ request: TransactionRequest,
+): Record =>
+ Object.fromEntries(
+ Object.entries(request).map(([key, value]) => [
+ key,
+ typeof value === "bigint" ? `0x${value.toString(16)}` : value,
+ ]),
+ )
+
+const normalizeError = (error: unknown): WidgetProviderError => {
+ if (error instanceof WidgetProviderError) return error
+ const message =
+ error instanceof Error ? error.message : "Provider request failed"
+ const rejected = /reject|denied|declined|cancel/i.test(message)
+ return new WidgetProviderError(rejected ? 4001 : 4200, message, error)
+}
+
+export class RestrictedEip1193Provider {
+ readonly #allowedChainIds: ReadonlySet
+ readonly #allowedMethods: ReadonlySet
+ readonly #listeners = new Map<
+ "accountsChanged" | "chainChanged",
+ Set<(...args: unknown[]) => void>
+ >()
+ readonly #requestWalletApproval: NonNullable<
+ RestrictedProviderOptions["requestWalletApproval"]
+ >
+ readonly #prepareTransaction: NonNullable<
+ RestrictedProviderOptions["prepareTransaction"]
+ >
+ readonly #rpcRequest: NonNullable
+ #approvalPending = false
+ #chainId: number
+ #revoked = false
+ #signer: EVMSigner
+
+ constructor(options: RestrictedProviderOptions) {
+ if (options.chainIds.length === 0) {
+ throw new Error("At least one widget chain is required")
+ }
+
+ const unsupportedChains = options.chainIds.filter(
+ (chainId) => !WIDGET_EVM_CHAIN_IDS.has(chainId),
+ )
+ if (unsupportedChains.length > 0) {
+ throw new Error(
+ `Widget requested unsupported chains: ${unsupportedChains.join(", ")}`,
+ )
+ }
+
+ const unsupportedMethods = options.requiredMethods.filter(
+ (method) => !WIDGET_PROVIDER_METHODS.has(method),
+ )
+ if (unsupportedMethods.length > 0) {
+ throw new Error(
+ `Widget requires unsupported provider methods: ${unsupportedMethods.join(", ")}`,
+ )
+ }
+
+ this.#allowedChainIds = new Set(options.chainIds)
+ this.#allowedMethods = new Set(options.requiredMethods)
+ this.#chainId = options.initialChainId ?? options.chainIds[0]
+ if (!this.#allowedChainIds.has(this.#chainId)) {
+ throw new Error("The initial chain must be allowed")
+ }
+
+ this.#signer = options.signer
+ this.#requestWalletApproval =
+ options.requestWalletApproval ?? (async () => false)
+ this.#prepareTransaction =
+ options.prepareTransaction ??
+ (async (chainId, signer, request) => {
+ const connectedSigner = new WalletWrapper(
+ signer,
+ getEthersProvider(chainId),
+ )
+ return await connectedSigner.populateTransaction(request)
+ })
+ this.#rpcRequest =
+ options.rpcRequest ??
+ (async (chainId, request) => {
+ const client = getViemClient(chainId)
+ return await (
+ client.request as (request: ProviderRequest) => Promise
+ )(request)
+ })
+ }
+
+ on(
+ event: "accountsChanged" | "chainChanged",
+ listener: (...args: unknown[]) => void,
+ ): this {
+ const listeners = this.#listeners.get(event) ?? new Set()
+ listeners.add(listener)
+ this.#listeners.set(event, listeners)
+ return this
+ }
+
+ removeListener(
+ event: "accountsChanged" | "chainChanged",
+ listener: (...args: unknown[]) => void,
+ ): this {
+ this.#listeners.get(event)?.delete(listener)
+ return this
+ }
+
+ dispose(): void {
+ if (this.#revoked) return
+ this.#revoked = true
+ this.#emit("accountsChanged", [])
+ this.#listeners.clear()
+ }
+
+ async request(request: ProviderRequest): Promise {
+ this.#assertActive()
+ if (!WIDGET_PROVIDER_METHODS.has(request.method)) {
+ throw new WidgetProviderError(
+ 4200,
+ `Method ${request.method} is not permitted for widgets`,
+ )
+ }
+ if (!this.#allowedMethods.has(request.method)) {
+ throw new WidgetProviderError(
+ 4100,
+ `Method ${request.method} is not approved for this widget`,
+ )
+ }
+
+ try {
+ const params = asParams(request.params)
+ switch (request.method) {
+ case "eth_accounts":
+ case "eth_requestAccounts":
+ return [this.#signer.address]
+ case "eth_chainId":
+ return `0x${this.#chainId.toString(16)}`
+ case "wallet_switchEthereumChain":
+ return this.#switchChain(params)
+ case "personal_sign":
+ return await this.#signMessage(params)
+ case "eth_sendTransaction":
+ return await this.#sendTransaction(params)
+ default:
+ throw new WidgetProviderError(4200, "Unsupported widget request")
+ }
+ } catch (error) {
+ throw normalizeError(error)
+ }
+ }
+
+ #emit(event: "accountsChanged" | "chainChanged", value: unknown): void {
+ for (const listener of this.#listeners.get(event) ?? []) listener(value)
+ }
+
+ #assertActive(): void {
+ if (this.#revoked) {
+ throw new WidgetProviderError(4100, "The widget provider was revoked")
+ }
+ }
+
+ #captureSigningContext(account: unknown): {
+ signer: EVMSigner
+ account: string
+ chainId: number
+ } {
+ this.#assertActive()
+ if (!addressesMatch(account, this.#signer.address)) {
+ throw new WidgetProviderError(
+ 4100,
+ "The signing account does not match the active GoodWallet session",
+ )
+ }
+ return {
+ signer: this.#signer,
+ account: this.#signer.address,
+ chainId: this.#chainId,
+ }
+ }
+
+ #assertSigningContext(context: {
+ signer: EVMSigner
+ account: string
+ chainId: number
+ }): void {
+ this.#assertActive()
+ if (
+ this.#signer !== context.signer ||
+ !addressesMatch(this.#signer.address, context.account) ||
+ this.#chainId !== context.chainId
+ ) {
+ throw new WidgetProviderError(
+ 4100,
+ "The active wallet account or chain changed during approval",
+ )
+ }
+ }
+
+ async #requestSigningApproval(
+ method: string,
+ params: readonly unknown[],
+ context: {
+ signer: EVMSigner
+ account: string
+ chainId: number
+ },
+ ): Promise {
+ if (this.#approvalPending) {
+ throw new WidgetProviderError(4200, "Another widget approval is pending")
+ }
+ this.#assertSigningContext(context)
+ this.#approvalPending = true
+ try {
+ const approved = await this.#requestWalletApproval({
+ method,
+ params,
+ account: context.account,
+ chainId: context.chainId,
+ })
+ this.#assertSigningContext(context)
+ if (!approved) {
+ throw new WidgetProviderError(
+ 4001,
+ "GoodWallet did not approve the signing request",
+ )
+ }
+ } finally {
+ this.#approvalPending = false
+ }
+ }
+
+ #switchChain(params: readonly unknown[]): null {
+ const requested = params[0]
+ const rawChainId =
+ requested && typeof requested === "object" && "chainId" in requested
+ ? requested.chainId
+ : undefined
+ const chainId = asChainId(rawChainId)
+ if (!this.#allowedChainIds.has(chainId)) {
+ throw new WidgetProviderError(
+ 4100,
+ `Chain ${chainId} is not allowed for this widget`,
+ )
+ }
+ if (chainId !== this.#chainId) {
+ this.#chainId = chainId
+ this.#emit("chainChanged", `0x${chainId.toString(16)}`)
+ }
+ return null
+ }
+
+ async #signMessage(params: readonly unknown[]): Promise {
+ const [message, account] = params
+ if (typeof message !== "string") {
+ throw new WidgetProviderError(4200, "personal_sign requires a message")
+ }
+ const context = this.#captureSigningContext(account)
+ const approvalParams = [message, context.account] as const
+ await this.#requestSigningApproval("personal_sign", approvalParams, context)
+ const signature = await context.signer.signMessage(
+ /^0x[0-9a-f]*$/i.test(message) ? getBytes(message) : message,
+ )
+ this.#assertSigningContext(context)
+ return signature
+ }
+
+ async #sendTransaction(params: readonly unknown[]): Promise {
+ const transaction = params[0]
+ if (
+ !transaction ||
+ typeof transaction !== "object" ||
+ Array.isArray(transaction)
+ ) {
+ throw new WidgetProviderError(4200, "A transaction object is required")
+ }
+ const rawRequest = transaction as TransactionRequest & {
+ from?: string
+ chainId?: string | number
+ }
+ const context = this.#captureSigningContext(rawRequest.from)
+ if (
+ rawRequest.chainId !== undefined &&
+ Number(rawRequest.chainId) !== context.chainId
+ ) {
+ throw new WidgetProviderError(
+ 4100,
+ "Transaction chain does not match the active widget chain",
+ )
+ }
+ const copiedRequest = copyRequest({
+ ...rawRequest,
+ chainId: context.chainId,
+ })
+ const { from: _from, ...unsignedRequest } = copiedRequest
+ const populatedRequest = copyRequest(
+ await this.#prepareTransaction(
+ context.chainId,
+ context.signer,
+ unsignedRequest,
+ ),
+ )
+ this.#assertSigningContext(context)
+ const approvalParams = [approvalTransaction(populatedRequest)] as const
+ await this.#requestSigningApproval(
+ "eth_sendTransaction",
+ approvalParams,
+ context,
+ )
+ const rawTransaction =
+ await context.signer.signTransaction(populatedRequest)
+ this.#assertSigningContext(context)
+ const result = await this.#rpcRequest(context.chainId, {
+ method: "eth_sendRawTransaction",
+ params: [rawTransaction],
+ })
+ this.#assertSigningContext(context)
+ return result
+ }
+}
diff --git a/src/widgets/provider/WidgetProvider.tsx b/src/widgets/provider/WidgetProvider.tsx
new file mode 100644
index 0000000..4ca5462
--- /dev/null
+++ b/src/widgets/provider/WidgetProvider.tsx
@@ -0,0 +1,100 @@
+"use client"
+
+import {
+ createContext,
+ type ReactNode,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+} from "react"
+
+import { useSessionContext } from "@/login/hooks/context"
+import { openWalletConnectDialog } from "@/sections/WalletConnect/store/walletConnectDialogStore"
+
+import {
+ RestrictedEip1193Provider,
+ type WalletApprovalRequest,
+} from "./RestrictedEip1193Provider"
+
+const WidgetProviderContext = createContext(
+ null,
+)
+
+const requestWalletApproval = async (
+ request: WalletApprovalRequest,
+): Promise => {
+ const status = await openWalletConnectDialog({
+ type: "generic",
+ title: "Approve widget request",
+ bodyText: JSON.stringify(
+ {
+ method: request.method,
+ account: request.account,
+ chainId: request.chainId,
+ params: request.params,
+ },
+ (_key, value: unknown) =>
+ typeof value === "bigint" ? `0x${value.toString(16)}` : value,
+ 2,
+ ),
+ acceptBtnText: "Approve",
+ rejectBtnText: "Reject",
+ })
+ return status === "accepted"
+}
+
+export const WidgetProvider = ({
+ chainIds,
+ requiredMethods,
+ children,
+}: {
+ chainIds: readonly number[]
+ requiredMethods: readonly string[]
+ children: ReactNode
+}) => {
+ const { signer } = useSessionContext()
+ const evmSigner = signer?.EVM
+ const liveProviderRef = useRef(null)
+ const provider = useMemo(
+ () =>
+ evmSigner
+ ? new RestrictedEip1193Provider({
+ signer: evmSigner,
+ chainIds,
+ requiredMethods,
+ requestWalletApproval,
+ })
+ : null,
+ [evmSigner, chainIds, requiredMethods],
+ )
+
+ useEffect(() => {
+ liveProviderRef.current = provider
+ return () => {
+ const disposedProvider = provider
+ if (liveProviderRef.current === disposedProvider) {
+ liveProviderRef.current = null
+ }
+ queueMicrotask(() => {
+ if (liveProviderRef.current === disposedProvider) return
+ disposedProvider?.dispose()
+ })
+ }
+ }, [provider])
+
+ if (!provider || !evmSigner) return null
+ return (
+
+ {children}
+
+ )
+}
+
+export const useWidgetProvider = (): RestrictedEip1193Provider => {
+ const provider = useContext(WidgetProviderContext)
+ if (!provider) {
+ throw new Error("useWidgetProvider must be used inside WidgetProvider")
+ }
+ return provider
+}
diff --git a/src/widgets/provider/policy.ts b/src/widgets/provider/policy.ts
new file mode 100644
index 0000000..bcf4416
--- /dev/null
+++ b/src/widgets/provider/policy.ts
@@ -0,0 +1,19 @@
+import { CELO_CHAIN_ID, ETHEREUM_CHAIN_ID } from "@/chain/chain-ids"
+
+export const WIDGET_SIGNING_METHODS = new Set([
+ "personal_sign",
+ "eth_sendTransaction",
+])
+
+export const WIDGET_PROVIDER_METHODS = new Set([
+ "eth_accounts",
+ "eth_requestAccounts",
+ "eth_chainId",
+ "wallet_switchEthereumChain",
+ ...WIDGET_SIGNING_METHODS,
+])
+
+export const WIDGET_EVM_CHAIN_IDS = new Set([
+ ETHEREUM_CHAIN_ID,
+ CELO_CHAIN_ID,
+])
diff --git a/src/widgets/registry.test.ts b/src/widgets/registry.test.ts
new file mode 100644
index 0000000..34f77df
--- /dev/null
+++ b/src/widgets/registry.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from "vitest"
+
+import {
+ coreDashboardActions,
+ createWidgetRegistry,
+ defineWidget,
+} from "./registry"
+
+const widget = defineWidget({
+ widgetId: "goodwidget.goodreserve",
+ packageName: "@goodwidget/goodreserve-widget",
+ packageVersion: "1.0.0",
+ entry: {
+ tagName: "gw-goodreserve-widget",
+ load: async () => ({
+ goodWidgetMetadata: {
+ packageName: "@goodwidget/goodreserve-widget",
+ packageVersion: "1.0.0",
+ },
+ register: (tagName) => tagName ?? "unused",
+ }),
+ },
+ routeSlug: "goodreserve",
+ displayName: "GoodReserve",
+ description: "Reserve",
+ icon: { kind: "system", name: "Cash" },
+ providerPolicy: {
+ chainIds: [42220],
+ requiredMethods: ["eth_accounts", "eth_chainId"],
+ },
+})
+
+const reactWidget = defineWidget({
+ ...widget,
+ widgetId: "goodwidget.react",
+ routeSlug: "react",
+ integrationMode: "react",
+ entry: {
+ exportName: "GoodReserveWidget",
+ load: async () => ({
+ goodWidgetMetadata: {
+ packageName: "@goodwidget/goodreserve-widget",
+ packageVersion: "1.0.0",
+ },
+ GoodReserveWidget: () => null,
+ }),
+ },
+})
+
+describe("widget registry", () => {
+ it("indexes typed widgets by immutable ID", () => {
+ expect(createWidgetRegistry([widget]).get(widget.widgetId)).toBe(widget)
+ })
+
+ it("accepts web-component and react integration modes", () => {
+ expect("integrationMode" in widget).toBe(false)
+ expect(reactWidget.integrationMode).toBe("react")
+ expect(createWidgetRegistry([widget, reactWidget]).size).toBe(2)
+ })
+
+ it("rejects duplicate IDs and routes", () => {
+ expect(() => createWidgetRegistry([widget, widget])).toThrow(
+ "Duplicate widget ID",
+ )
+ expect(() =>
+ createWidgetRegistry([
+ widget,
+ { ...widget, widgetId: "goodwidget.other" },
+ ]),
+ ).toThrow("Duplicate widget route")
+ })
+
+ it("rejects non-exact package versions and unsupported policy entries", () => {
+ expect(() =>
+ createWidgetRegistry([{ ...widget, packageVersion: "^1.0.0" } as never]),
+ ).toThrow("exact package version")
+ expect(() =>
+ createWidgetRegistry([
+ {
+ ...widget,
+ providerPolicy: { ...widget.providerPolicy, chainIds: [999] },
+ } as never,
+ ]),
+ ).toThrow("unsupported chains")
+ expect(() =>
+ createWidgetRegistry([
+ {
+ ...widget,
+ providerPolicy: {
+ ...widget.providerPolicy,
+ requiredMethods: ["wallet_getSeed"],
+ },
+ } as never,
+ ]),
+ ).toThrow("unsupported methods")
+ })
+
+ it("rejects reserved routes, empty chains, and duplicate Custom Element tags", () => {
+ expect(() =>
+ createWidgetRegistry([{ ...widget, routeSlug: "send" }]),
+ ).toThrow("reserved route")
+ expect(() =>
+ createWidgetRegistry([
+ {
+ ...widget,
+ providerPolicy: { ...widget.providerPolicy, chainIds: [] },
+ },
+ ]),
+ ).toThrow("at least one chain")
+ expect(() =>
+ createWidgetRegistry([
+ widget,
+ {
+ ...widget,
+ widgetId: "goodwidget.other",
+ routeSlug: "other",
+ },
+ ]),
+ ).toThrow("Duplicate widget Custom Element tag")
+ })
+
+ it("keeps the six reviewed core actions ahead of widget actions", () => {
+ expect(coreDashboardActions.map(({ id }) => id)).toEqual([
+ "gooddollar",
+ "send",
+ "receive",
+ "swap",
+ "predictions",
+ "walletconnect",
+ ])
+ })
+})
diff --git a/src/widgets/registry.ts b/src/widgets/registry.ts
new file mode 100644
index 0000000..51046d9
--- /dev/null
+++ b/src/widgets/registry.ts
@@ -0,0 +1,218 @@
+import type { ReactNode } from "react"
+import type { IconName } from "ui"
+
+import { CELO_CHAIN_ID } from "@/chain/chain-ids"
+
+import type { ReactWidgetLoader, WebComponentWidgetLoader } from "./hostTypes"
+import {
+ WIDGET_EVM_CHAIN_IDS,
+ WIDGET_PROVIDER_METHODS,
+} from "./provider/policy"
+
+export type WidgetIntegrationMode = "web-component" | "react"
+
+export type DashboardIcon =
+ | { kind: "system"; name: IconName }
+ | { kind: "local"; render: () => ReactNode }
+
+export type DashboardAction = {
+ id: string
+ routeSlug: string
+ label: string
+ icon: DashboardIcon
+ widgetId?: string
+}
+
+type RegisteredWidgetBase = {
+ widgetId: `goodwidget.${string}`
+ packageName: `@goodwidget/${string}`
+ packageVersion: `${number}.${number}.${number}`
+ routeSlug: string
+ displayName: string
+ description: string
+ icon: DashboardIcon
+ providerPolicy: {
+ chainIds: readonly number[]
+ requiredMethods: readonly string[]
+ }
+}
+
+export type RegisteredWidget = RegisteredWidgetBase &
+ (
+ | {
+ integrationMode?: "web-component"
+ entry: {
+ tagName: `${string}-${string}`
+ load: WebComponentWidgetLoader
+ }
+ }
+ | {
+ integrationMode: "react"
+ entry: { exportName: string; load: ReactWidgetLoader }
+ }
+ )
+
+export const defineWidget = (widget: T): T =>
+ widget
+
+const EXACT_PACKAGE_VERSION = /^\d+\.\d+\.\d+$/
+const ROUTE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
+const RESERVED_WIDGET_ROUTES = new Set([
+ "gooddollar",
+ "send",
+ "receive",
+ "swap",
+ "predictions",
+ "walletconnect",
+ "options",
+ "promo",
+ "qr",
+])
+
+const validateWidget = (widget: RegisteredWidget): void => {
+ if (!EXACT_PACKAGE_VERSION.test(widget.packageVersion)) {
+ throw new Error(
+ `Widget ${widget.widgetId} must use an exact package version`,
+ )
+ }
+ if (!ROUTE_SLUG.test(widget.routeSlug)) {
+ throw new Error(`Widget ${widget.widgetId} has an invalid route slug`)
+ }
+ if (RESERVED_WIDGET_ROUTES.has(widget.routeSlug)) {
+ throw new Error(
+ `Widget ${widget.widgetId} uses a reserved route: ${widget.routeSlug}`,
+ )
+ }
+ if (widget.providerPolicy.chainIds.length === 0) {
+ throw new Error(`Widget ${widget.widgetId} requires at least one chain`)
+ }
+ const unsupportedChains = widget.providerPolicy.chainIds.filter(
+ (chainId) => !WIDGET_EVM_CHAIN_IDS.has(chainId),
+ )
+ if (unsupportedChains.length > 0) {
+ throw new Error(
+ `Widget ${widget.widgetId} requests unsupported chains: ${unsupportedChains.join(", ")}`,
+ )
+ }
+ if (
+ new Set(widget.providerPolicy.chainIds).size !==
+ widget.providerPolicy.chainIds.length
+ ) {
+ throw new Error(`Widget ${widget.widgetId} repeats a provider chain`)
+ }
+ const unsupportedMethods = widget.providerPolicy.requiredMethods.filter(
+ (method) => !WIDGET_PROVIDER_METHODS.has(method),
+ )
+ if (unsupportedMethods.length > 0) {
+ throw new Error(
+ `Widget ${widget.widgetId} requests unsupported methods: ${unsupportedMethods.join(", ")}`,
+ )
+ }
+}
+
+export const createWidgetRegistry = (
+ widgets: readonly RegisteredWidget[],
+): ReadonlyMap => {
+ const registry = new Map()
+ const routes = new Set()
+ const tags = new Set()
+
+ for (const widget of widgets) {
+ validateWidget(widget)
+ if (registry.has(widget.widgetId)) {
+ throw new Error(`Duplicate widget ID: ${widget.widgetId}`)
+ }
+ if (routes.has(widget.routeSlug)) {
+ throw new Error(`Duplicate widget route: ${widget.routeSlug}`)
+ }
+ if (widget.integrationMode !== "react" && tags.has(widget.entry.tagName)) {
+ throw new Error(
+ `Duplicate widget Custom Element tag: ${widget.entry.tagName}`,
+ )
+ }
+ registry.set(widget.widgetId, widget)
+ routes.add(widget.routeSlug)
+ if (widget.integrationMode !== "react") tags.add(widget.entry.tagName)
+ }
+
+ return registry
+}
+
+const testFixtureWidget = defineWidget({
+ widgetId: "goodwidget.test-fixture",
+ packageName: "@goodwidget/test-fixture",
+ packageVersion: "0.0.0",
+ routeSlug: "test-fixture",
+ displayName: "Test Fixture",
+ description: "Playwright widget host fixture",
+ icon: { kind: "system", name: "Cash" },
+ integrationMode: "react",
+ entry: {
+ exportName: "TestFixtureWidget",
+ load: () => import("./fixtures/TestFixtureWidget"),
+ },
+ providerPolicy: {
+ chainIds: [CELO_CHAIN_ID],
+ requiredMethods: ["eth_accounts", "eth_chainId"],
+ },
+})
+
+export const WIDGETS: readonly RegisteredWidget[] =
+ process.env.NEXT_PUBLIC_PLAYWRIGHT_TEST_MODE === "true"
+ ? [testFixtureWidget]
+ : []
+export const widgetRegistry = createWidgetRegistry(WIDGETS)
+
+export const getWidgetByRoute = (
+ routeSlug: string,
+): RegisteredWidget | undefined =>
+ WIDGETS.find((widget) => widget.routeSlug === routeSlug)
+
+export const coreDashboardActions = [
+ {
+ id: "gooddollar",
+ routeSlug: "gooddollar",
+ label: "GoodDollar",
+ icon: { kind: "system", name: "goodDollarLogo" },
+ },
+ {
+ id: "send",
+ routeSlug: "send",
+ label: "Send",
+ icon: { kind: "system", name: "ArrowUpAlt" },
+ },
+ {
+ id: "receive",
+ routeSlug: "receive",
+ label: "Receive",
+ icon: { kind: "system", name: "ArrowDownAlt" },
+ },
+ {
+ id: "swap",
+ routeSlug: "swap",
+ label: "Swap",
+ icon: { kind: "system", name: "Swap" },
+ },
+ {
+ id: "predictions",
+ routeSlug: "predictions",
+ label: "Predictions",
+ icon: { kind: "system", name: "Predictions" },
+ },
+ {
+ id: "walletconnect",
+ routeSlug: "walletconnect",
+ label: "WalletConnect",
+ icon: { kind: "system", name: "walletConnectLogo" },
+ },
+] as const satisfies readonly DashboardAction[]
+
+export const widgetDashboardActions = WIDGETS.map(
+ (widget): DashboardAction => ({
+ id: widget.widgetId,
+ widgetId: widget.widgetId,
+ routeSlug: widget.routeSlug,
+ label: widget.displayName,
+ icon: widget.icon,
+ }),
+)
diff --git a/src/widgets/resolveReactWidget.test.ts b/src/widgets/resolveReactWidget.test.ts
new file mode 100644
index 0000000..48f456e
--- /dev/null
+++ b/src/widgets/resolveReactWidget.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest"
+
+import { assertWidgetModuleMetadata } from "./hostTypes"
+import { resolveReactWidget } from "./resolveReactWidget"
+
+describe("React widget export resolution", () => {
+ it("returns the exact declared component export", () => {
+ const GoodReserveWidget = () => null
+
+ expect(resolveReactWidget({ GoodReserveWidget }, "GoodReserveWidget")).toBe(
+ GoodReserveWidget,
+ )
+ })
+
+ it("accepts memoized and forwarded React component objects", () => {
+ const MemoWidget = {
+ $$typeof: Symbol.for("react.memo"),
+ type: () => null,
+ }
+ const ForwardWidget = {
+ $$typeof: Symbol.for("react.forward_ref"),
+ render: () => null,
+ }
+
+ expect(resolveReactWidget({ MemoWidget }, "MemoWidget")).toBe(MemoWidget)
+ expect(resolveReactWidget({ ForwardWidget }, "ForwardWidget")).toBe(
+ ForwardWidget,
+ )
+ })
+
+ it("rejects a missing or non-component export", () => {
+ expect(() => resolveReactWidget({}, "MissingWidget")).toThrow(
+ "does not export React component MissingWidget",
+ )
+ expect(() =>
+ resolveReactWidget(
+ { GoodReserveWidget: "not-a-component" },
+ "GoodReserveWidget",
+ ),
+ ).toThrow("does not export React component GoodReserveWidget")
+ })
+
+ it("rejects module metadata that does not match the registry", () => {
+ expect(() =>
+ assertWidgetModuleMetadata(
+ {
+ goodWidgetMetadata: {
+ packageName: "@goodwidget/react-widget",
+ packageVersion: "2.0.0",
+ },
+ },
+ "@goodwidget/react-widget",
+ "1.0.0",
+ ),
+ ).toThrow("expected @goodwidget/react-widget@1.0.0")
+ })
+})
diff --git a/src/widgets/resolveReactWidget.ts b/src/widgets/resolveReactWidget.ts
new file mode 100644
index 0000000..4c1c66b
--- /dev/null
+++ b/src/widgets/resolveReactWidget.ts
@@ -0,0 +1,20 @@
+import type { HostedReactWidget } from "./hostTypes"
+
+const isReactComponent = (value: unknown): value is HostedReactWidget => {
+ if (typeof value === "function") return true
+ if (!value || typeof value !== "object") return false
+ return typeof (value as { $$typeof?: unknown }).$$typeof === "symbol"
+}
+
+export const resolveReactWidget = (
+ module: Record,
+ exportName: string,
+): HostedReactWidget => {
+ const exportedComponent = module[exportName]
+ if (!isReactComponent(exportedComponent)) {
+ throw new Error(
+ `Widget module does not export React component ${exportName}`,
+ )
+ }
+ return exportedComponent
+}
diff --git a/tests-playwright/screenshots/desktop/home-balances-overflow-en.png b/tests-playwright/screenshots/desktop/home-balances-overflow-en.png
index 4834f2d..493df30 100644
Binary files a/tests-playwright/screenshots/desktop/home-balances-overflow-en.png and b/tests-playwright/screenshots/desktop/home-balances-overflow-en.png differ
diff --git a/tests-playwright/screenshots/desktop/login-onboarding-da.png b/tests-playwright/screenshots/desktop/login-onboarding-da.png
index 805c6b5..8805469 100644
Binary files a/tests-playwright/screenshots/desktop/login-onboarding-da.png and b/tests-playwright/screenshots/desktop/login-onboarding-da.png differ
diff --git a/tests-playwright/screenshots/mobile/login-onboarding-da.png b/tests-playwright/screenshots/mobile/login-onboarding-da.png
index 0eaf297..e744f49 100644
Binary files a/tests-playwright/screenshots/mobile/login-onboarding-da.png and b/tests-playwright/screenshots/mobile/login-onboarding-da.png differ
diff --git a/tests-playwright/wallet-flows.e2e.ts b/tests-playwright/wallet-flows.e2e.ts
index 1838649..15fbc6e 100644
--- a/tests-playwright/wallet-flows.e2e.ts
+++ b/tests-playwright/wallet-flows.e2e.ts
@@ -47,7 +47,29 @@ const preparePage = async (page: Page, showOnboarding: boolean) => {
const login = async (page: Page) => {
await page.goto("/en?login=master_seed")
await page.getByRole("button", { name: "Playwright test wallet" }).click()
- await expect(page.getByText("Playwright test wallet")).toBeVisible()
+ await expect(page.getByTestId("wallet-actions")).toBeVisible()
+}
+
+const openTestFixtureWidget = async (page: Page, testInfo: TestInfo) => {
+ const walletActions = page.getByTestId("wallet-actions")
+ const fixtureLink = walletActions.getByRole("link", { name: "Test Fixture" })
+
+ if (testInfo.project.name === "mobile") {
+ const actionsToggle = page.getByTestId("wallet-actions-toggle")
+ await expect(actionsToggle).toBeVisible()
+ if (!(await fixtureLink.isVisible())) {
+ await actionsToggle.getByRole("button").click()
+ }
+ }
+
+ await expect(fixtureLink).toBeVisible()
+ await expect(fixtureLink).toHaveAttribute("href", "/en/test-fixture")
+ await fixtureLink.click()
+ await page.waitForURL(/\/en\/test-fixture/, { timeout: 60_000 })
+ await expect(page.getByTestId("test-fixture-widget")).toBeVisible({
+ timeout: 30_000,
+ })
+ await expect(page.getByText("Test Fixture Widget")).toBeVisible()
}
test("captures the locale-aware onboarding and login entry", async ({
@@ -76,6 +98,14 @@ test("captures the authenticated home balance and responsive action grid", async
await expect(page.getByText("$124.68")).toBeVisible()
const walletActions = page.getByTestId("wallet-actions")
await expect(walletActions).toBeVisible()
+ await expect(walletActions.getByRole("link")).toHaveCount(7)
+ await expect(walletActions.getByRole("link").first()).toHaveAttribute(
+ "href",
+ "/en/gooddollar",
+ )
+ await expect(
+ walletActions.getByRole("link", { name: "Test Fixture" }),
+ ).toHaveAttribute("href", "/en/test-fixture")
if (testInfo.project.name === "mobile") {
const actionsToggle = page.getByTestId("wallet-actions-toggle")
@@ -86,22 +116,40 @@ test("captures the authenticated home balance and responsive action grid", async
await captureScreenshot(page, testInfo, "home-balances-overflow-en.png")
})
+test("opens the registered test fixture widget from the dashboard", async ({
+ page,
+}, testInfo) => {
+ await preparePage(page, false)
+ await login(page)
+ await openTestFixtureWidget(page, testInfo)
+})
+
+test("rejects unregistered widget routes", async ({ page }) => {
+ await preparePage(page, false)
+ await login(page)
+ await page.goto("/en/not-a-registered-widget")
+ await expect(page.getByRole("heading", { name: "404" })).toBeVisible()
+ await expect(
+ page.getByRole("heading", { name: "This page could not be found." }),
+ ).toBeVisible()
+ await expect(page.getByTestId("test-fixture-widget")).toHaveCount(0)
+})
+
test("captures the claim verification requirement", async ({
page,
}, testInfo) => {
await preparePage(page, false)
await login(page)
- await expect(page.getByRole("link", { name: "GoodDollar" })).toHaveAttribute(
- "href",
- "/en/gooddollar",
- )
- await page.goto("/en/gooddollar")
+ const goodDollarLink = page.getByRole("link", { name: "GoodDollar" })
+ await expect(goodDollarLink).toHaveAttribute("href", "/en/gooddollar")
+ await goodDollarLink.click()
+ await page.waitForURL(/\/en\/gooddollar/, { timeout: 60_000 })
await expect(
page.getByText(
"Before you can start to claim your GoodDollars you first need to pass face verification to whitelist your account.",
),
- ).toBeVisible()
+ ).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole("button", { name: "Verify" })).toBeVisible()
await captureScreenshot(page, testInfo, "claim-requires-verification-en.png")
})