Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/offramp-main-frame-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@crossmint/client-sdk-react-native-ui": patch
---

The React Native embedded offramp now loads the page with a per-mount random nonce in the URL fragment (`#mainFrameNonce=<hex>`). Once the page sends an event carrying that nonce, the host drops every event that lacks it, so cross-origin frames embedded in the offramp page can no longer send wallet requests (`crypto:send-transaction`, `crypto:sign-message`) or completion events. Pages that do not stamp their events yet keep working, with one warning per mount. A pending wallet request is no longer dropped when the page updates its URL on Android.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useCrossmint } from "@crossmint/client-sdk-react-base";
import { RNWebView, WebViewParent } from "@crossmint/client-sdk-rn-window";
import { useEffect, useRef, useState } from "react";
import { Linking } from "react-native";
import "react-native-get-random-values";
import type { WebView } from "react-native-webview";

import { createCrossmintApiClient } from "@/utils/createCrossmintApiClient";
Expand All @@ -20,6 +21,12 @@ type EmbeddedOfframpWebViewParent = WebViewParent<
typeof embeddedOfframpOutgoingEvents
>;

function createNonce() {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

export function CrossmintEmbeddedOfframp(props: CrossmintEmbeddedOfframpProps) {
const { crossmint } = useCrossmint();
const service = createEmbeddedOfframpService({
Expand All @@ -40,6 +47,9 @@ function EmbeddedOfframpDocument({
const [client, setClient] = useState<EmbeddedOfframpWebViewParent | null>(null);
const [height, setHeight] = useState(0);
const webViewRef = useRef<WebView>(null);
const [nonce] = useState(createNonce);
const pageStamps = useRef(false);
const warnedUnstamped = useRef(false);
const sendPending = useRef<object | null>(null);
const signPending = useRef<object | null>(null);
const latestProps = useRef(props);
Expand All @@ -65,7 +75,12 @@ function EmbeddedOfframpDocument({
}
const ids = [
client.on("ui:height.changed", (data) => setHeight(data.height)),
client.on("offramp:ready", () => latestProps.current.onReady?.()),
client.on("offramp:ready", () => {
// Each offramp document sends ready once, before any wallet request, so it marks a reload.
sendPending.current = null;
signPending.current = null;
latestProps.current.onReady?.();
}),
client.on("offramp:status", (data) => latestProps.current.onStatusChange?.(data)),
client.on("offramp:completed", (data) => latestProps.current.onComplete?.(data)),
client.on("offramp:cancelled", () => latestProps.current.onCancel?.()),
Expand Down Expand Up @@ -121,6 +136,38 @@ function EmbeddedOfframpDocument({
};
}, [client]);

// Cross-origin subframes reach the same native bridge but cannot read the page's fragment, so they cannot stamp.
const acceptsFromMainFrame = (raw: string) => {
let message: { event?: unknown; data?: unknown } | null;
try {
message = JSON.parse(raw);
} catch {
return true;
}
const name = message?.event;
if (typeof name !== "string") {
return true;
}
const data = message?.data;
if (
data != null &&
typeof data === "object" &&
(data as { __mainFrameNonce?: unknown }).__mainFrameNonce === nonce
) {
pageStamps.current = true;
return true;
}
if (pageStamps.current) {
console.warn(`[CrossmintEmbeddedOfframp] Dropped ${name} not stamped by the offramp main frame`);
return false;
}
if (!warnedUnstamped.current) {
warnedUnstamped.current = true;
console.warn("[CrossmintEmbeddedOfframp] The offramp page does not stamp its events");
}
return true;
};

const routeUrl = url.split("?")[0] ?? url;
// Any other page the WebView ends up showing also receives the bridge, so only the offramp route may talk to it.
// iOS reports the sender's full URL, Android only its origin.
Expand All @@ -141,13 +188,9 @@ function EmbeddedOfframpDocument({
return (
<RNWebView
ref={webViewRef}
source={{ uri: url }}
onLoadStart={() => {
sendPending.current = null;
signPending.current = null;
}}
source={{ uri: `${url}#mainFrameNonce=${nonce}` }}
onMessage={(event) => {
if (isOfframpPage(event.nativeEvent.url)) {
if (isOfframpPage(event.nativeEvent.url) && acceptsFromMainFrame(event.nativeEvent.data)) {
client?.handleMessage(event);
}
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,25 @@ function lastWebViewProps() {

const ROUTE = "https://staging.crossmint.com/sdk/unstable/embedded-offramp";

function emit(event: string, data: Record<string, unknown> = {}, url = `${ROUTE}?credentials=x`) {
function mountNonce() {
return /#mainFrameNonce=([0-9a-f]{32})$/.exec(lastWebViewProps().source.uri as string)?.[1] ?? "";
}

// By default every event carries the mount's nonce, like a page that stamps; null sends it unstamped.
function emit(
event: string,
data: Record<string, unknown> = {},
url = `${ROUTE}?credentials=x`,
nonce: string | null | undefined = undefined
) {
const stamp = nonce === undefined ? mountNonce() : nonce;
return emitRaw(JSON.stringify({ event, data: stamp == null ? data : { ...data, __mainFrameNonce: stamp } }), url);
}

function emitRaw(data: string, url = `${ROUTE}?credentials=x`) {
const { onMessage } = lastWebViewProps();
return act(async () => {
onMessage({ nativeEvent: { data: JSON.stringify({ event, data }), url } });
onMessage({ nativeEvent: { data, url } });
});
}

Expand Down Expand Up @@ -198,6 +213,172 @@ describe("<CrossmintEmbeddedOfframp /> (React Native)", () => {
);
});

describe("when events must come from the main frame", () => {
const SIGN = { message: "crossmint.com wants you to sign in", chain: "solana", payerAddress: "9Wz" };
const WRONG = "00".repeat(16);
const renderPayer = (props: Partial<React.ComponentProps<typeof CrossmintEmbeddedOfframp>> = {}) => {
const payer = {
signAndSendTransaction: vi.fn().mockResolvedValue("0xtx"),
signMessage: vi.fn().mockResolvedValue("c2ln"),
};
render(<CrossmintEmbeddedOfframp credentials={CREDENTIALS} payer={payer} {...props} />);
return payer;
};
const spyWarn = () => vi.spyOn(console, "warn").mockImplementation(() => undefined);

test("loads the URL with a 32 char lowercase hex nonce in the fragment and the query unchanged", () => {
renderPayer();
const url = new URL(lastWebViewProps().source.uri as string);
expect(url.hash).toMatch(/^#mainFrameNonce=[0-9a-f]{32}$/);
expect(JSON.parse(url.searchParams.get("credentials") ?? "{}")).toEqual(CREDENTIALS);
expect(url.searchParams.has("mainFrameNonce")).toBe(false);
});

test("uses a new nonce for a new mount and the same one across rerenders", () => {
const { unmount, rerender } = render(
<CrossmintEmbeddedOfframp credentials={CREDENTIALS} payer={{ signAndSendTransaction: vi.fn() }} />
);
const first = mountNonce();
rerender(
<CrossmintEmbeddedOfframp
credentials={CREDENTIALS}
payer={{ signAndSendTransaction: vi.fn() }}
onReady={vi.fn()}
/>
);
expect(mountNonce()).toBe(first);
unmount();
render(<CrossmintEmbeddedOfframp credentials={CREDENTIALS} payer={{ signAndSendTransaction: vi.fn() }} />);
expect(mountNonce()).not.toBe(first);
});

test("accepts stamped events and answers stamped wallet requests", async () => {
const payer = renderPayer();
const warn = spyWarn();
await emit("crypto:send-transaction", TX);
await emit("crypto:sign-message", SIGN);
expect(payer.signAndSendTransaction).toHaveBeenCalledTimes(1);
expect(payer.signMessage).toHaveBeenCalledTimes(1);
expect(injectJavaScript).toHaveBeenCalledTimes(2);
expect(warn).not.toHaveBeenCalled();
});

test.each([
["crypto:send-transaction", TX],
["crypto:sign-message", SIGN],
])("after a stamped event, drops an unstamped %s with a warning and no reply", async (event, data) => {
const payer = renderPayer();
await emit("offramp:status", { status: "x" });
const warn = spyWarn();
await emit(event, data, undefined, null);
expect(payer.signAndSendTransaction).not.toHaveBeenCalled();
expect(payer.signMessage).not.toHaveBeenCalled();
expect(injectJavaScript).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining(event));
expect(JSON.stringify(warn.mock.calls)).not.toContain("9Wz");
});

test("after a stamped event, drops an unstamped offramp:completed without calling back", async () => {
const onComplete = vi.fn();
renderPayer({ onComplete });
await emit("offramp:ready");
const warn = spyWarn();
await emit("offramp:completed", { orderId: "o1", txId: "0xtx" }, undefined, null);
expect(onComplete).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("offramp:completed"));
await emit("offramp:completed", { orderId: "o1", txId: "0xtx" });
expect(onComplete).toHaveBeenCalledTimes(1);
});

test("after a stamped event, drops an event with a wrong nonce", async () => {
const onComplete = vi.fn();
renderPayer({ onComplete });
await emit("offramp:ready");
spyWarn();
await emit("offramp:completed", { orderId: "o1", txId: "0xtx" }, undefined, WRONG);
expect(onComplete).not.toHaveBeenCalled();
});

test("before any stamped event, accepts unstamped events with a single warning", async () => {
const onComplete = vi.fn();
const payer = renderPayer({ onComplete });
const warn = spyWarn();
await emit("offramp:completed", { orderId: "o1", txId: "0xtx" }, undefined, null);
await emit("crypto:send-transaction", TX, undefined, null);
expect(onComplete).toHaveBeenCalledTimes(1);
expect(payer.signAndSendTransaction).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledTimes(1);
});

test("does not count a wrong nonce as a stamp", async () => {
const onComplete = vi.fn();
renderPayer({ onComplete });
spyWarn();
await emit("offramp:ready", {}, undefined, WRONG);
await emit("offramp:completed", { orderId: "o1", txId: "0xtx" }, undefined, null);
expect(onComplete).toHaveBeenCalledTimes(1);
});

test("applies the gate after the page check, so a foreign page's unstamped event logs nothing", async () => {
renderPayer();
await emit("offramp:ready");
const warn = spyWarn();
await emit("offramp:status", {}, "https://evil.example/page", null);
expect(warn).not.toHaveBeenCalled();
});

test("passes messages without a string event, before and after the latch", async () => {
const onComplete = vi.fn();
renderPayer({ onComplete });
const warn = spyWarn();
await emitRaw(JSON.stringify({ type: "console.info", data: ["hello"] }));
await emitRaw(JSON.stringify({ event: 7, data: {} }));
expect(warn).not.toHaveBeenCalled();
await emit("offramp:ready");
await emitRaw(JSON.stringify({ type: "console.info", data: ["hello"] }));
await emitRaw("not json");
expect(warn).not.toHaveBeenCalled();
});

test.each([
["null", null],
["a string", "x"],
["a number", 3],
])("does not throw when data is %s, and treats it as unstamped", async (_name, data) => {
const onStatusChange = vi.fn();
renderPayer({ onStatusChange });
const warn = spyWarn();
await emitRaw(JSON.stringify({ event: "offramp:status", data }));
expect(warn).toHaveBeenCalledTimes(1);
await emit("offramp:ready");
await emitRaw(JSON.stringify({ event: "offramp:status", data }));
expect(warn).toHaveBeenCalledTimes(2);
expect(onStatusChange).not.toHaveBeenCalled();
});

test("keeps the latch across onLoadStart and onLoad", async () => {
const payer = renderPayer();
await emit("offramp:ready");
const url = lastWebViewProps().source.uri as string;
act(() => lastWebViewProps().onLoadStart?.({ nativeEvent: { url: ROUTE } }));
act(() => lastWebViewProps().onLoad?.({ nativeEvent: { url: ROUTE } }));
act(() => lastWebViewProps().onLoadStart?.({ nativeEvent: { url } }));
spyWarn();
await emit("crypto:send-transaction", TX, undefined, null);
expect(payer.signAndSendTransaction).not.toHaveBeenCalled();
await emit("crypto:send-transaction", TX);
expect(payer.signAndSendTransaction).toHaveBeenCalledTimes(1);
});

test("does not inject any script into the page", async () => {
renderPayer();
await emit("offramp:ready");
act(() => lastWebViewProps().onLoad?.({ nativeEvent: { url: ROUTE } }));
expect(injectJavaScript).not.toHaveBeenCalled();
});
});

describe("when the page opens a new window", () => {
test("opens http(s) links in the system browser", () => {
render(<CrossmintEmbeddedOfframp credentials={CREDENTIALS} payer={{ signAndSendTransaction: vi.fn() }} />);
Expand Down Expand Up @@ -351,15 +532,15 @@ describe("<CrossmintEmbeddedOfframp /> (React Native)", () => {
expect(injectJavaScript).toHaveBeenCalledTimes(1);
});

test("ignores a pending reply after the page starts loading again", async () => {
test("ignores a pending reply after a new document sends ready, then serves new requests", async () => {
const oldSignature = deferredTransaction();
const newSignature = deferredTransaction();
const payer = payerSigning(
vi.fn().mockReturnValueOnce(oldSignature.promise).mockReturnValueOnce(newSignature.promise)
);
render(<CrossmintEmbeddedOfframp credentials={CREDENTIALS} payer={payer} />);
await emit("crypto:sign-message", SIGN);
act(() => lastWebViewProps().onLoadStart());
await emit("offramp:ready");
await emit("crypto:sign-message", SIGN);
expect(payer.signMessage).toHaveBeenCalledTimes(2);
await act(async () => oldSignature.resolve("old"));
Expand Down Expand Up @@ -438,22 +619,42 @@ describe("signing session isolation", () => {
expect(injectJavaScript).toHaveBeenCalledTimes(1);
});

test("ignores a pending reply after the document reloads", async () => {
test("keeps pending send and sign replies when the page only updates its URL (onLoadStart, same document)", async () => {
const send = deferredTransaction();
const sign = deferredTransaction();
const payer = {
signAndSendTransaction: vi.fn().mockReturnValue(send.promise),
signMessage: vi.fn().mockReturnValue(sign.promise),
};
render(<CrossmintEmbeddedOfframp credentials={CREDENTIALS} payer={payer} />);
await emit("crypto:send-transaction", TX);
await emit("crypto:sign-message", { message: "m", chain: "solana", payerAddress: "9Wz" });
act(() => lastWebViewProps().onLoadStart?.());
await act(async () => {
send.resolve("0xtx");
sign.resolve("c2ln");
});
expect(replies("crypto:send-transaction:success")).toHaveLength(1);
expect(replies("crypto:sign-message:success")).toHaveLength(1);
});

test("ignores a pending reply after a new document sends ready, then serves new requests", async () => {
const oldTransaction = deferredTransaction();
const newTransaction = deferredTransaction();
const signer = vi.fn().mockReturnValueOnce(oldTransaction.promise).mockReturnValueOnce(newTransaction.promise);
render(<CrossmintEmbeddedOfframp credentials={CREDENTIALS} payer={{ signAndSendTransaction: signer }} />);
act(() => lastWebViewProps().onLoadStart());
await emit("crypto:send-transaction", TX);
act(() => lastWebViewProps().onLoadStart());
await emit("offramp:ready");
await emit("crypto:send-transaction", TX);
expect(signer).toHaveBeenCalledTimes(2);
await act(async () => oldTransaction.resolve("old-tx"));
expect(injectJavaScript).not.toHaveBeenCalled();
await emit("crypto:send-transaction", TX);
expect(signer).toHaveBeenCalledTimes(2);
await act(async () => newTransaction.resolve("new-tx"));
expect(injectJavaScript).toHaveBeenCalledTimes(1);
expect(replies("crypto:send-transaction:success")).toEqual([
{ event: "crypto:send-transaction:success", data: { txId: "new-tx" } },
]);
});

test("does not reply after unmount", async () => {
Expand Down
Loading