Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions .changeset/wallet-sdk-initial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 9 additions & 7 deletions examples/wallet-sdk-nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@

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.
3. **Send Transaction** → `eth_sendTransaction` opens the iframe with the
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
Expand Down
2 changes: 1 addition & 1 deletion examples/wallet-sdk-nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions examples/wallet-sdk-nextjs/src/components/Demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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",
Expand Down
29 changes: 13 additions & 16 deletions packages/wallet-sdk/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/wallet-sdk/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
9 changes: 4 additions & 5 deletions packages/wallet-sdk/src/core/Dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
9 changes: 9 additions & 0 deletions packages/wallet-sdk/src/core/Messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -92,6 +98,9 @@ export type InternalPayload =
| {
type: "set-theme";
theme: "light" | "dark";
}
| {
type: "authenticated";
};

// Discriminated schema describing every (topic, payload) pair the messenger
Expand Down
5 changes: 2 additions & 3 deletions packages/wallet-sdk/src/core/UserAgent.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
54 changes: 40 additions & 14 deletions packages/wallet-sdk/src/core/Wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}

Expand All @@ -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";
}

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/wallet-sdk/test/src/Dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading