diff --git a/src/app/[locale]/@home/(home)/(bottomsheet)/[widgetRoute]/page.tsx b/src/app/[locale]/@home/(home)/(bottomsheet)/[widgetRoute]/page.tsx new file mode 100644 index 0000000..2493448 --- /dev/null +++ b/src/app/[locale]/@home/(home)/(bottomsheet)/[widgetRoute]/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from "next/navigation" + +import { AuthenticatedWidgetRoute } from "@/widgets/AuthenticatedWidgetRoute" +import { getWidgetByRoute, WIDGETS } from "@/widgets/registry" + +export const dynamicParams = false + +export function generateStaticParams() { + return WIDGETS.map((widget) => ({ widgetRoute: widget.routeSlug })) +} + +export default async function WidgetRoutePage({ + params, +}: { + params: Promise<{ widgetRoute: string }> +}) { + const { widgetRoute } = await params + const widget = getWidgetByRoute(widgetRoute) + if (!widget) notFound() + + return +} diff --git a/src/components/Form/RoundButton/RoundButton.tsx b/src/components/Form/RoundButton/RoundButton.tsx index cd10473..7b76f59 100644 --- a/src/components/Form/RoundButton/RoundButton.tsx +++ b/src/components/Form/RoundButton/RoundButton.tsx @@ -1,40 +1,23 @@ "use client" +import type { ReactNode } from "react" import { BsLink45Deg, BsPlusCircle } from "react-icons/bs" import { Icon, type IconName } from "ui" export type RoundButtonProps = { onClick?: () => void - buttonType: RoundButtonType + icon?: IconName + iconElement?: ReactNode text?: string - fill?: boolean -} - -export const enum RoundButtonType { - Fund, - Send, - Receive, - GoodDollar, - Swap, - WalletConnect, - Predictions, -} - -const buttonIcons: Record = { - [RoundButtonType.Fund]: "Cash", - [RoundButtonType.Send]: "ArrowUpAlt", - [RoundButtonType.Receive]: "ArrowDownAlt", - [RoundButtonType.GoodDollar]: "goodDollarLogo", - [RoundButtonType.Swap]: "Swap", - [RoundButtonType.WalletConnect]: "walletConnectLogo", - [RoundButtonType.Predictions]: "Predictions", + indicator?: "connected" | "available" } export const RoundButton = ({ - buttonType, + icon, + iconElement, onClick, text, - fill = false, + indicator, }: RoundButtonProps) => { return (
- + {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") })