diff --git a/apps/integration-tests/src/end-to-end.integration.test.ts b/apps/integration-tests/src/end-to-end.integration.test.ts index a7c59de..ba13c71 100644 --- a/apps/integration-tests/src/end-to-end.integration.test.ts +++ b/apps/integration-tests/src/end-to-end.integration.test.ts @@ -77,6 +77,31 @@ async function connectClients(dappClient: DappClient, walletClient: WalletClient await Promise.all([dappConnectPromise, walletConnectPromise]); } +// Helper for strict untrusted flow (dapp advertises otpDisplayGrant capability). +async function connectClientsStrict(dappClient: DappClient, walletClient: WalletClient) { + const sessionRequestPromise = new Promise((resolve) => { + dappClient.on("session_request", resolve); + }); + const dappConnectPromise = dappClient.connect({ mode: "untrusted", requireOtpDisplayGrant: true }); + + const sessionRequest = await sessionRequestPromise; + t.expect(sessionRequest.capabilities?.otpDisplayGrant).toBe(true); + + const otpPromise = new Promise((resolve) => { + walletClient.once("display_otp", (otp) => resolve(otp)); + }); + + const otpRequiredPromise = new Promise((resolve) => { + dappClient.once("otp_required", resolve); + }); + + const walletConnectPromise = walletClient.connect({ sessionRequest }); + const [otp, otpPayload] = await Promise.all([otpPromise, otpRequiredPromise]); + await otpPayload.submit(otp); + + await Promise.all([dappConnectPromise, walletConnectPromise]); +} + // Helper to assert that a promise does NOT resolve within a given time async function assertPromiseNotResolve(promise: Promise, timeout: number, message: string) { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(message)), timeout)); @@ -241,6 +266,113 @@ t.describe("E2E Integration Test", () => { await resumedDappClient.sendRequest(testPayload); await t.expect(messagePromise).resolves.toEqual(testPayload); }); + + t.describe("otp-display-grant strict flow", () => { + t.test("should complete strict untrusted connection and allow bidirectional messaging", async () => { + await connectClientsStrict(dappClient, walletClient); + + t.expect((dappClient as any).state).toBe("CONNECTED"); + t.expect((walletClient as any).state).toBe("CONNECTED"); + + const requestPayload = { method: "eth_accounts_strict" }; + const messageFromDappPromise = new Promise((resolve) => walletClient.on("message", resolve)); + await dappClient.sendRequest(requestPayload); + await t.expect(messageFromDappPromise).resolves.toEqual(requestPayload); + + const responsePayload = { result: ["0x789..."] }; + const messageFromWalletPromise = new Promise((resolve) => dappClient.on("message", resolve)); + await walletClient.sendResponse(responsePayload); + await t.expect(messageFromWalletPromise).resolves.toEqual(responsePayload); + }); + + t.test("should reject strict dapp when wallet simulates legacy offer (no otpDisplayGrantRequired)", async () => { + const sessionRequestPromise = new Promise((resolve) => { + dappClient.on("session_request", resolve); + }); + const dappConnectPromise = dappClient.connect({ mode: "untrusted", requireOtpDisplayGrant: true }); + const sessionRequest = await sessionRequestPromise; + + // Simulate an older wallet that ignores capabilities.otpDisplayGrant on the session request. + const legacyWalletRequest = { ...sessionRequest, capabilities: undefined }; + const walletConnectPromise = walletClient.connect({ sessionRequest: legacyWalletRequest }); + void walletConnectPromise.catch(() => {}); + + const displayOtpPromise = new Promise((resolve) => { + walletClient.once("display_otp", () => resolve()); + }); + + await t.expect(dappConnectPromise).rejects.toMatchObject({ code: ErrorCode.OTP_DISPLAY_GRANT_REQUIRED }); + await t.expect(displayOtpPromise).resolves.toBeUndefined(); + await assertPromiseNotResolve(walletConnectPromise, 1000, "Wallet should not complete strict-incompatible connection"); + }); + + t.test("should keep legacy untrusted flow when dapp does not require otp display grant", async () => { + let displayOtpFired = false; + let displayOtpBeforeOffer = false; + + const sessionRequestPromise = new Promise((resolve) => { + dappClient.on("session_request", resolve); + }); + const dappConnectPromise = dappClient.connect({ mode: "untrusted" }); + const sessionRequest = await sessionRequestPromise; + t.expect(sessionRequest.capabilities?.otpDisplayGrant).toBeUndefined(); + + (dappClient as any).on("handshake_offer_received", () => { + displayOtpBeforeOffer = displayOtpFired; + }); + walletClient.on("display_otp", () => { + displayOtpFired = true; + }); + + const walletConnectPromise = walletClient.connect({ sessionRequest }); + + const otpPromise = new Promise((resolve) => { + walletClient.on("display_otp", (otp) => resolve(otp)); + }); + const otp = await otpPromise; + const otpPayload = await new Promise((resolve) => { + dappClient.on("otp_required", resolve); + }); + await otpPayload.submit(otp); + await Promise.all([dappConnectPromise, walletConnectPromise]); + + t.expect(displayOtpFired).toBe(true); + t.expect(displayOtpBeforeOffer).toBe(true); + }); + + t.test("should defer wallet display_otp until after dapp receives handshake offer in strict mode", async () => { + let dappReceivedOffer = false; + let displayOtpFired = false; + + const sessionRequestPromise = new Promise((resolve) => { + dappClient.on("session_request", resolve); + }); + const dappConnectPromise = dappClient.connect({ mode: "untrusted", requireOtpDisplayGrant: true }); + const sessionRequest = await sessionRequestPromise; + + (dappClient as any).on("handshake_offer_received", () => { + dappReceivedOffer = true; + }); + const otpPromise = new Promise((resolve) => { + walletClient.once("display_otp", (otp) => { + t.expect(dappReceivedOffer, "display_otp must not fire before dapp receives handshake offer").toBe(true); + displayOtpFired = true; + resolve(otp); + }); + }); + + const otpRequiredPromise = new Promise((resolve) => { + dappClient.once("otp_required", resolve); + }); + + const walletConnectPromise = walletClient.connect({ sessionRequest }); + const [otp, otpPayload] = await Promise.all([otpPromise, otpRequiredPromise]); + t.expect(displayOtpFired).toBe(true); + + await otpPayload.submit(otp); + await Promise.all([dappConnectPromise, walletConnectPromise]); + }); + }); }); t.describe("E2E Integration Test via Proxy", () => { diff --git a/apps/web-demo/src/components/UntrustedDemo.tsx b/apps/web-demo/src/components/UntrustedDemo.tsx index 4630b53..8aa125f 100644 --- a/apps/web-demo/src/components/UntrustedDemo.tsx +++ b/apps/web-demo/src/components/UntrustedDemo.tsx @@ -33,6 +33,7 @@ type PendingRequest = { export default function UntrustedDemo() { // UI State const [showWalletClient, setShowWalletClient] = useState(true); + const [requireOtpDisplayGrant, setRequireOtpDisplayGrant] = useState(false); // DApp State const [dappClient, setDappClient] = useState(null); @@ -123,7 +124,7 @@ export default function UntrustedDemo() { addDappLog("sent", `Queuing initial payload: ${JSON.stringify(initialPayload, null, 2)}`); // Start new connection, which will trigger 'session-request' and start a new timer - dappClient.connect({ initialPayload }).catch((error) => { + dappClient.connect({ mode: "untrusted", initialPayload, requireOtpDisplayGrant }).catch((error) => { console.error("New QR code generation failed:", error); addDappLog("system", `New QR code generation failed: ${error.message}`); }); @@ -268,14 +269,14 @@ export default function UntrustedDemo() { try { setDappStatus("Connecting..."); - addDappLog("system", "Starting connection process with initial payload..."); + addDappLog("system", `Starting ${requireOtpDisplayGrant ? "strict" : "legacy"} untrusted connection process with initial payload...`); // Define the initial message to be sent const initialPayload = "Hello from Untrusted Demo!"; addDappLog("sent", `Queuing initial payload: ${JSON.stringify(initialPayload, null, 2)}`); // This will trigger the session-request event and generate QR code - dappClient.connect({ initialPayload }).catch((error) => { + dappClient.connect({ mode: "untrusted", initialPayload, requireOtpDisplayGrant }).catch((error) => { addDappLog("system", `Connection failed: ${error.message}`); setDappStatus("Connection failed"); }); @@ -587,6 +588,22 @@ export default function UntrustedDemo() {

Connection

+ +
{!dappConnected ? (