diff --git a/.changeset/wallet-sdk-initial.md b/.changeset/wallet-sdk-initial.md index cd3b142..c7a4a56 100644 --- a/.changeset/wallet-sdk-initial.md +++ b/.changeset/wallet-sdk-initial.md @@ -10,16 +10,15 @@ for embedding the Abstract Global Wallet on third-party origins via iframe - `iframe()` / `popup()` dialog factories with hardened sandbox + allow attrs - Origin-validated `postMessage` messenger with ready handshake and request-id correlation +- Auth-aware iframe → popup routing: the SDK reads the wallet host's + authenticated status and uses a top-level popup for OAuth login before + replaying the original connect request in the iframe - Iframe → popup fallback driven by HTTPS / IntersectionObserver-v2 / - trusted-host eligibility checks, plus a runtime `__internal { switch }` - channel the wallet host can use for cases the SDK can't predict + trusted-host eligibility checks, plus runtime `__internal` channels the + wallet host can use for cases the SDK can't predict - IntersectionObserver-v2 feature detection used by the parent-side `secure()` eligibility check (the actual visibility wrapper lives in the wallet host application) -AGW does not use WebAuthn for wallet signing or account creation, and -Privy passkey enrollment only happens in the main portal app — so the SDK -deliberately does not pre-emptively force Safari users into popup mode. - Web Components (`/elements`) and React wrappers (`/react`) are planned for follow-up releases. diff --git a/examples/wallet-sdk-nextjs/README.md b/examples/wallet-sdk-nextjs/README.md index bbb56ca..5ef5b73 100644 --- a/examples/wallet-sdk-nextjs/README.md +++ b/examples/wallet-sdk-nextjs/README.md @@ -2,12 +2,14 @@ Minimal Next.js demo of [`@abstract-foundation/wallet-sdk`](../../packages/wallet-sdk). -The SDK opens an iframe pointing at the Abstract wallet-host (the -`apps/web/wallet-host` app in the [mono-ts](https://github.com/Abstract-Foundation/mono-ts) repo) and proxies EIP-1193 -requests to it. This demo walks through the canonical flow: - -1. **Connect** → `wallet_connect` opens the iframe; user authenticates - inside the wallet origin; SDK returns the AGW smart-account address. +The SDK opens a top-level popup for wallet auth, then uses an iframe pointing +at the Abstract wallet-host (the `apps/web/wallet-host` app in the +[mono-ts](https://github.com/Abstract-Foundation/mono-ts) repo) for embedded +wallet confirmations where supported. It proxies EIP-1193 requests to the +wallet host. This demo walks through the canonical flow: + +1. **Connect** → `wallet_connect` opens the popup; user authenticates + inside a top-level wallet origin; SDK returns the AGW smart-account address. 2. **Sign Message** → `personal_sign` opens the iframe with the `@abstract/wallet-ui` signature-review surface; user approves; SDK returns the signature. @@ -15,7 +17,7 @@ requests to it. This demo walks through the canonical flow: transaction-review surface; user approves; SDK returns the tx hash. AGW sponsors gas via paymaster. -Every action's request + result is appended to a log panel so the iframe +Every action's request + result is appended to a log panel so the wallet round-trip is visible. ## Run diff --git a/examples/wallet-sdk-nextjs/package.json b/examples/wallet-sdk-nextjs/package.json index 6ddfbdf..f713f8e 100644 --- a/examples/wallet-sdk-nextjs/package.json +++ b/examples/wallet-sdk-nextjs/package.json @@ -2,7 +2,7 @@ "name": "wallet-sdk-nextjs", "version": "0.0.0", "private": true, - "description": "Minimal Next.js demo of @abstract-foundation/wallet-sdk. Mounts the iframe-embeddable Abstract wallet-host, walks through wallet_connect → personal_sign → eth_sendTransaction.", + "description": "Minimal Next.js demo of @abstract-foundation/wallet-sdk. Uses popup-backed auth and iframe-backed wallet confirmations, walking through wallet_connect → personal_sign → eth_sendTransaction.", "scripts": { "dev": "next dev -p 3004", "build": "next build", diff --git a/examples/wallet-sdk-nextjs/src/components/Demo.tsx b/examples/wallet-sdk-nextjs/src/components/Demo.tsx index d29b461..aa486db 100644 --- a/examples/wallet-sdk-nextjs/src/components/Demo.tsx +++ b/examples/wallet-sdk-nextjs/src/components/Demo.tsx @@ -31,7 +31,7 @@ type LogEntry = { * * 1. Mount the SDK pointing at the wallet-host (defaults to * http://localhost:3003 — override via `NEXT_PUBLIC_WALLET_HOST_URL`). - * 2. Click "Connect" → SDK opens the iframe → user authenticates inside → + * 2. Click "Connect" → SDK opens the popup → user authenticates inside → * SDK returns the AGW smart-account address. * 3. Click "Sign Message" → SDK opens the iframe with a signature review * surface → user approves → SDK returns the EIP-191 signature. @@ -40,7 +40,7 @@ type LogEntry = { * sponsors gas via paymaster). * * Every action's request + result is appended to the log panel so the - * iframe round-trip is visible. + * wallet round-trip is visible. */ export function Demo() { const host = useMemo(() => { @@ -72,7 +72,7 @@ export function Demo() { async function runConnect() { if (!wallet) return; setBusy(true); - append({ kind: "info", label: "wallet_connect → wallet-host iframe" }); + append({ kind: "info", label: "wallet_connect → wallet-host popup" }); try { const result = (await wallet.provider.request({ method: "wallet_connect", diff --git a/packages/wallet-sdk/README.md b/packages/wallet-sdk/README.md index 7e55295..f64c32d 100644 --- a/packages/wallet-sdk/README.md +++ b/packages/wallet-sdk/README.md @@ -1,7 +1,7 @@ # @abstract-foundation/wallet-sdk -Framework-agnostic SDK for embedding the Abstract Global Wallet on third-party -origins via iframe (with popup fallback). +Framework-agnostic SDK for connecting to the Abstract Global Wallet from +third-party origins via popup-backed auth and iframe-backed confirmations. Ports the security-relevant pieces of [Porto](https://github.com/ithacaxyz/porto) to the Abstract stack: origin-validated `postMessage` transport, hardened @@ -39,8 +39,17 @@ const accounts = await wallet.provider.request({ ## Mode selection -`'auto'` (default) starts in iframe mode and transparently falls back to a -popup when: +`'auto'` (default) prewarms the wallet iframe and reads the wallet host's +auth status from the `ready` handshake. If the user is already authenticated, +connection requests (`wallet_connect`, `eth_requestAccounts`, +`wallet_requestPermissions`) stay in the iframe. If the user is not +authenticated or auth state is unknown, the SDK opens a top-level popup for +login, then switches back to the iframe and replays the original connection +request. Social OAuth providers, including Google, reject embedded iframe +user agents, so login cannot reliably happen inside the wallet iframe. + +Other requests start in iframe mode and transparently fall back to a popup +when: - Parent origin is HTTP (HTTPS is required for the iframe transport). - IntersectionObserver v2 (`isVisible`) is unsupported AND the parent host @@ -51,18 +60,6 @@ popup when: credential creation on Safari (which Safari blocks in cross-origin iframes) if a flow ever required it. -### A note on WebAuthn / passkeys - -AGW does not use WebAuthn for wallet signing or account creation. Privy -passkey enrollment happens in the main Abstract portal app under account -management — never inside the iframed wallet — so the SDK does not -pre-emptively force Safari users into popup mode for `wallet_connect` or -`eth_requestAccounts`. - -Returning users with passkeys log in via `navigator.credentials.get()`, -which works in cross-origin iframes when the `publickey-credentials-get` -permission is granted (set automatically by `Dialog.iframe()`). - ## Iframe hardening When the iframe path is chosen, the iframe is mounted inside a top-layer diff --git a/packages/wallet-sdk/package.json b/packages/wallet-sdk/package.json index 7077b4b..813edbc 100644 --- a/packages/wallet-sdk/package.json +++ b/packages/wallet-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@abstract-foundation/wallet-sdk", - "description": "Abstract Wallet SDK — framework-agnostic core for embedding the Abstract Global Wallet on third-party origins via iframe (with popup fallback). Origin-validated postMessage transport, hardened iframe sandboxing, IntersectionObserver-v2-aware visibility checks, WebAuthn-aware popup fallback.", + "description": "Abstract Wallet SDK — framework-agnostic core for connecting to the Abstract Global Wallet on third-party origins via popup-backed auth and iframe-backed confirmations. Origin-validated postMessage transport, hardened iframe sandboxing, IntersectionObserver-v2-aware visibility checks.", "version": "0.1.0", "license": "MIT", "repository": { diff --git a/packages/wallet-sdk/src/core/Dialog.ts b/packages/wallet-sdk/src/core/Dialog.ts index 2471ed1..d228d87 100644 --- a/packages/wallet-sdk/src/core/Dialog.ts +++ b/packages/wallet-sdk/src/core/Dialog.ts @@ -434,11 +434,10 @@ export function popup(options: PopupOptions = {}): DialogFactory { // IMPORTANT: window.open must run synchronously inside a user-gesture // handler or it will be popup-blocked. Callers funnel through here // from a click handler. - win = window.open( - buildHostUrl(host, POPUP_PATH), - "abstract-wallet", - features, - ); + const popupUrl = new URL(buildHostUrl(host, POPUP_PATH)); + popupUrl.searchParams.set("origin", window.location.origin); + + win = window.open(popupUrl.toString(), "abstract-wallet", features); if (!win) throw new Error("Popup blocked by browser"); messenger = Messenger.bridge({ diff --git a/packages/wallet-sdk/src/core/Messenger.ts b/packages/wallet-sdk/src/core/Messenger.ts index e486d28..a6b3ead 100644 --- a/packages/wallet-sdk/src/core/Messenger.ts +++ b/packages/wallet-sdk/src/core/Messenger.ts @@ -39,6 +39,12 @@ export type RpcResponse = export type ReadyOptions = { chainIds: readonly number[]; trustedHosts?: readonly string[] | undefined; + /** + * Whether the wallet host currently has an authenticated user session. + * When omitted, the dApp SDK treats auth state as unknown and uses the + * popup path for auth methods so OAuth providers are never embedded. + */ + authenticated?: boolean | undefined; /** * Method policy table advertised by the wallet host. Methods missing from * the table default to requiring the dialog. @@ -92,6 +98,9 @@ export type InternalPayload = | { type: "set-theme"; theme: "light" | "dark"; + } + | { + type: "authenticated"; }; // Discriminated schema describing every (topic, payload) pair the messenger diff --git a/packages/wallet-sdk/src/core/UserAgent.ts b/packages/wallet-sdk/src/core/UserAgent.ts index 59ccbe3..175d895 100644 --- a/packages/wallet-sdk/src/core/UserAgent.ts +++ b/packages/wallet-sdk/src/core/UserAgent.ts @@ -1,9 +1,8 @@ /** * Browser detection used to gate iframe-vs-popup decisions. * - * - Safari blocks WebAuthn credential creation inside iframes, so any request - * that may invoke `navigator.credentials.create` (e.g. wallet_connect when - * the user has no passkey yet) must route through the popup on Safari. + * - Mobile browsers generally need a full page rather than a small popup + * window for auth flows. * - Firefox lacks IntersectionObserver v2 (`isVisible`), so it always falls * back to the trusted-hosts allowlist for clickjacking protection. * - Chromium/Edge/WebView support IO v2 — happy path. diff --git a/packages/wallet-sdk/src/core/Wallet.ts b/packages/wallet-sdk/src/core/Wallet.ts index 0f82262..fe5e89d 100644 --- a/packages/wallet-sdk/src/core/Wallet.ts +++ b/packages/wallet-sdk/src/core/Wallet.ts @@ -4,23 +4,20 @@ * the appropriate dialog when the request requires user confirmation. * * Mode selection: - * 1. Start with `iframe` unless the caller forced popup mode. - * 2. After the iframe's `ready` arrives, run `secure()`. If the parent is + * 1. Start auth/account-creation requests in popup mode when the wallet + * session is missing or unknown because social OAuth providers + * (including Google) reject embedded iframe user agents. + * 2. If the prewarmed iframe reports an authenticated session, auth methods + * can stay in iframe mode. + * 3. Otherwise start with `iframe` unless the caller forced popup mode. + * 4. After the iframe's `ready` arrives, run `secure()`. If the parent is * on HTTP, or if IntersectionObserver v2 is unavailable AND the parent * origin isn't on the wallet host's trusted-host allowlist, transparently * switch to popup before sending pending requests. - * 3. The wallet host can request a runtime switch at any time by emitting + * 5. The wallet host can request a runtime switch at any time by emitting * `__internal { type: 'switch', mode: 'popup' }` over the messenger — * used for cases the dApp side can't predict (e.g. WebAuthn credential * creation on Safari, which Safari blocks in cross-origin iframes). - * - * Note on WebAuthn: this SDK does NOT pre-emptively force Safari users into - * popup mode for `wallet_connect` / `eth_requestAccounts`. AGW does not use - * WebAuthn for wallet signing or account creation, and Privy passkey - * enrollment happens in the main portal app, never inside the iframed wallet. - * Returning users with passkeys log in via `navigator.credentials.get()`, - * which works in iframes when the `publickey-credentials-get` permission is - * granted (set by `Dialog.iframe()` automatically). */ import * as Dialog from "./Dialog.js"; @@ -35,7 +32,7 @@ export type WalletConfig = { host: string; /** Default chain id. The wallet host may override based on its own chains. */ chainId?: number | undefined; - /** Dialog mode preference. `auto` picks iframe with popup fallback. */ + /** Dialog mode preference. Auth methods still use popup for OAuth support. */ dialog?: WalletMode | undefined; /** Skip the HTTPS protocol gate. Only intended for local dev/testing. */ skipProtocolCheck?: boolean | undefined; @@ -65,6 +62,12 @@ export type Wallet = { // ---------- Implementation ---------- +const TOP_LEVEL_AUTH_METHODS = new Set([ + "wallet_connect", + "eth_requestAccounts", + "wallet_requestPermissions", +]); + export function createWallet(config: WalletConfig): Wallet { const { host, @@ -77,6 +80,7 @@ export function createWallet(config: WalletConfig): Wallet { let active: Dialog.DialogHandle | null = null; let activeMode: Dialog.DialogMode | null = null; let destroyed = false; + let latestReady: Messenger.ReadyOptions | null = null; const iframeFactory = Dialog.iframe({ skipProtocolCheck }); const popupFactory = Dialog.popup(); @@ -113,6 +117,13 @@ export function createWallet(config: WalletConfig): Wallet { }, onInternal(payload) { if (payload.type === "switch") void switchMode(payload.mode); + if (payload.type === "authenticated") { + latestReady = { + ...(latestReady ?? { chainIds: [] }), + authenticated: true, + }; + if (activeMode === "popup") void switchMode("iframe"); + } }, onClose() { active?.close(); @@ -135,6 +146,14 @@ export function createWallet(config: WalletConfig): Wallet { const factory = targetMode === "iframe" ? iframeFactory : popupFactory; active = factory({ host, theme, handlers }); activeMode = targetMode; + void active.waitForReady().then( + (ready) => { + latestReady = ready; + }, + () => { + /* destroyed */ + }, + ); return active; } @@ -150,8 +169,15 @@ export function createWallet(config: WalletConfig): Wallet { previous?.destroy(); } - function pickInitialMode(): Dialog.DialogMode { + function requiresTopLevelAuth(request: Messenger.RpcRequest): boolean { + return TOP_LEVEL_AUTH_METHODS.has(request.method); + } + + function pickInitialMode(request: Messenger.RpcRequest): Dialog.DialogMode { if (mode === "popup") return "popup"; + if (requiresTopLevelAuth(request) && latestReady?.authenticated !== true) { + return "popup"; + } return "iframe"; } @@ -204,7 +230,7 @@ export function createWallet(config: WalletConfig): Wallet { params, }; - const initialMode = pickInitialMode(); + const initialMode = pickInitialMode(rpc); const handle = ensureActive(initialMode); // After the wallet announces ready, validate that iframe mode is actually diff --git a/packages/wallet-sdk/test/src/Dialog.test.ts b/packages/wallet-sdk/test/src/Dialog.test.ts index 91b0eca..dcb41c3 100644 --- a/packages/wallet-sdk/test/src/Dialog.test.ts +++ b/packages/wallet-sdk/test/src/Dialog.test.ts @@ -68,6 +68,9 @@ describe("popup factory", () => { try { handle.open(); + expect(openSpy.mock.calls[0]?.[0]).toBe( + "https://wallet.test/popup?origin=http%3A%2F%2Flocalhost%3A3000", + ); (popupWindow as Window & { closed: boolean }).closed = true; vi.advanceTimersByTime(250); diff --git a/packages/wallet-sdk/test/src/Wallet.test.ts b/packages/wallet-sdk/test/src/Wallet.test.ts new file mode 100644 index 0000000..b29d83b --- /dev/null +++ b/packages/wallet-sdk/test/src/Wallet.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createWallet } from "../../src/core/Wallet.js"; + +function mockPopupWindow(): Window { + return { + closed: false, + close: vi.fn(), + focus: vi.fn(), + postMessage: vi.fn(), + } as unknown as Window; +} + +describe("createWallet", () => { + const realMatchMedia = window.matchMedia; + + beforeEach(() => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockReturnValue({ + addEventListener: vi.fn(), + matches: false, + removeEventListener: vi.fn(), + }), + }); + }); + + afterEach(() => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: realMatchMedia, + }); + vi.restoreAllMocks(); + document.querySelectorAll("dialog[data-abs-wallet]").forEach((node) => { + node.remove(); + }); + }); + + it("opens wallet_connect in a top-level popup in auto mode", async () => { + const openSpy = vi.spyOn(window, "open").mockReturnValue(mockPopupWindow()); + const wallet = createWallet({ + host: "https://wallet.test", + chainId: 1, + }); + + const request = wallet.provider + .request({ + method: "wallet_connect", + params: [{ chainId: 1 }], + }) + .catch((error: unknown) => error); + + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0]?.[0]).toBe( + "https://wallet.test/popup?origin=http%3A%2F%2Flocalhost%3A3000", + ); + + wallet.destroy(); + await expect(request).resolves.toBeInstanceOf(Error); + }); + + it("opens eth_requestAccounts in a top-level popup even when iframe mode was requested", async () => { + const openSpy = vi.spyOn(window, "open").mockReturnValue(mockPopupWindow()); + const wallet = createWallet({ + host: "https://wallet.test", + chainId: 1, + dialog: "iframe", + }); + + const request = wallet.provider + .request({ method: "eth_requestAccounts" }) + .catch((error: unknown) => error); + + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0]?.[0]).toBe( + "https://wallet.test/popup?origin=http%3A%2F%2Flocalhost%3A3000", + ); + + wallet.destroy(); + await expect(request).resolves.toBeInstanceOf(Error); + }); +});