diff --git a/apps/web-demo/package.json b/apps/web-demo/package.json index a88e634..6ad42f5 100644 --- a/apps/web-demo/package.json +++ b/apps/web-demo/package.json @@ -11,9 +11,11 @@ "@metamask/mobile-wallet-protocol-core": "workspace:^", "@metamask/mobile-wallet-protocol-dapp-client": "workspace:^", "@metamask/mobile-wallet-protocol-wallet-client": "workspace:^", + "@types/pako": "^2.0.4", "@types/qrcode": "^1.5.5", "eciesjs": "^0.4.15", "next": "15.4.1", + "pako": "^2.1.0", "qrcode": "^1.5.4", "react": "19.1.0", "react-dom": "19.1.0" diff --git a/apps/web-demo/src/app/metamask-mobile-demo/page.tsx b/apps/web-demo/src/app/metamask-mobile-demo/page.tsx new file mode 100644 index 0000000..be07d1e --- /dev/null +++ b/apps/web-demo/src/app/metamask-mobile-demo/page.tsx @@ -0,0 +1,5 @@ +import MetaMaskMobileDemo from "@/components/MetaMaskMobileDemo"; + +export default function MetaMaskMobileDemoPage() { + return ; +} diff --git a/apps/web-demo/src/components/MetaMaskMobileDemo.tsx b/apps/web-demo/src/components/MetaMaskMobileDemo.tsx new file mode 100644 index 0000000..2853a06 --- /dev/null +++ b/apps/web-demo/src/components/MetaMaskMobileDemo.tsx @@ -0,0 +1,690 @@ +"use client"; + +import { type SessionRequest, SessionStore, WebSocketTransport } from "@metamask/mobile-wallet-protocol-core"; +import { DappClient } from "@metamask/mobile-wallet-protocol-dapp-client"; +import { useEffect, useRef, useState } from "react"; +import { base64Encode, compareEncodingSizes, compressString } from "@/lib/encoding-utils"; +import { KeyManager } from "@/lib/KeyManager"; +import { LocalStorageKVStore } from "@/lib/localStorage-kvstore"; + +// const RELAY_URL = "ws://localhost:8000/connection/websocket"; +const RELAY_URL = "wss://mm-sdk-relay.api.cx.metamask.io/connection/websocket"; +const HARDCODED_ETH_ACCOUNT = "0x2e404cdebe05098c066f9844aa990722749ed100"; +const HARDCODED_SOL_ACCOUNT = "8VMXFL3MN9z1Eg3WNBk4SECk3cKPn7hr7mUQqjwaYPev"; + +type LogEntry = { + id: string; + type: "sent" | "received" | "notification" | "system"; + content: string; + timestamp: Date; +}; + +export default function MetaMaskMobileDemo() { + // DApp State + const dappClientRef = useRef(null); + const [dappStatus, setDappStatus] = useState("Not connected"); + const [dappConnected, setDappConnected] = useState(false); + const [qrCodeData, setQrCodeData] = useState(""); + const [dappLogs, setDappLogs] = useState([]); + const [sessionTimeLeft, setSessionTimeLeft] = useState(0); + const sessionTimerId = useRef(null); + const [isSessionExpired, setIsSessionExpired] = useState(false); + const [results, setResults] = useState(""); + const [dappSessionStore, setDappSessionStore] = useState(null); + + const requestId = useRef(1); // For generating unique JSON-RPC request IDs + + // Refs for auto-scrolling + const dappLogsRef = useRef(null); + + // Auto-scroll effect + useEffect(() => { + dappLogsRef.current?.scrollTo(0, dappLogsRef.current.scrollHeight); + }, [dappLogs]); + + const startSessionTimer = (expiresAt: number) => { + // Clear any existing timer + if (sessionTimerId.current) { + clearInterval(sessionTimerId.current); + } + setIsSessionExpired(false); + + // Set up interval to update every second + const timerId = setInterval(() => { + const now = Date.now(); + const timeLeft = Math.max(0, Math.floor((expiresAt - now) / 1000)); + setSessionTimeLeft(timeLeft); + + if (timeLeft === 0) { + clearInterval(timerId); + sessionTimerId.current = null; + addDappLog("system", "The connection attempt has expired. Please start over."); + // Fully reset the DApp UI instead of just marking as expired + resetDappConnectionState(); + } + }, 1000); + + sessionTimerId.current = timerId; + + const now = Date.now(); + const timeLeft = Math.max(0, Math.floor((expiresAt - now) / 1000)); + setSessionTimeLeft(timeLeft); + }; + + const clearSessionTimer = () => { + if (sessionTimerId.current) { + clearInterval(sessionTimerId.current); + sessionTimerId.current = null; + } + setSessionTimeLeft(0); + }; + + // Helper function to create session request and connect with initial payload + const connectWithSessionRequest = async (onError: (error: Error) => void) => { + if (!dappClientRef.current) { + onError(new Error("DApp client not initialized")); + return; + } + + try { + // Create the session creation request to send as initial payload + const createSessionRequest = { + jsonrpc: "2.0", + method: "wallet_createSession", + params: { + optionalScopes: { + "eip155:1": { + methods: [], + notifications: [], + }, + "eip155:137": { + methods: [], + notifications: [], + }, + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { + methods: [], + notifications: [], + }, + }, + }, + id: requestId.current++, // Use and increment the request ID + }; + + const messageString = JSON.stringify(createSessionRequest, null, 2); + addDappLog("sent", messageString); + + // Start new connection, which will trigger 'session-request' and start a new timer + await dappClientRef.current.connect({ + mode: "trusted", + initialPayload: createSessionRequest + }); + } catch (error) { + onError(error instanceof Error ? error : new Error("Unknown error")); + } + }; + + const handleGenerateNewQrCode = async () => { + if (!dappClientRef.current) return; + + try { + // Clear existing timer and state + clearSessionTimer(); + setQrCodeData(""); + + // Disconnect and reconnect to generate new session + await dappClientRef.current.disconnect(); + addDappLog("system", "Generating new QR code..."); + + await connectWithSessionRequest((error) => { + console.error("New QR code generation failed:", error); + addDappLog("system", `New QR code generation failed: ${error.message}`); + setDappStatus("Connection failed"); + }); + } catch (error) { + addDappLog("system", `Failed to generate new QR code: ${error instanceof Error ? error.message : "Unknown error"}`); + } + }; + + // Helper functions for logging + const addDappLog = (type: LogEntry["type"], content: string) => { + const newLog: LogEntry = { + id: Date.now().toString() + Math.random(), + type, + content, + timestamp: new Date(), + }; + setDappLogs((prev) => [...prev, newLog]); + console.log(`[${type}] ${content}`); + }; + + // Format time in MM:SS format + const formatTimeLeft = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; + }; + + const resetDappConnectionState = () => { + clearSessionTimer(); + setQrCodeData(""); + setIsSessionExpired(false); + setDappStatus("Ready to connect"); + }; + + // DApp Functions + const initializeDappClient = async () => { + try { + setDappStatus("Initializing..."); + addDappLog("system", "Creating dApp client..."); + + const dappKvStore = new LocalStorageKVStore("metamask-mobile-demo-dapp/"); + const sessionStore = new SessionStore(dappKvStore); + setDappSessionStore(sessionStore); + + const dappTransport = await WebSocketTransport.create({ + url: RELAY_URL, + kvstore: dappKvStore, + websocket: typeof window !== "undefined" ? WebSocket : undefined, + }); + + const dapp = new DappClient({ + transport: dappTransport, + sessionstore: sessionStore, + keymanager: new KeyManager(), + }); + dappClientRef.current = dapp; + + // Set up event listeners + dapp.on("session_request", (request: SessionRequest) => { + addDappLog("system", `Session request generated: ${JSON.stringify(request)}`); + + // 1. Create the higher-level ConnectionRequest object. + const connectionRequest = { + sessionRequest: request, // The original session request from the SDK. + metadata: { + dapp: { + name: "MM Demo", + url: "http://localhost:3000/metamask-mobile-demo", + }, + sdk: { + version: "0.1.0", + platform: "web", + }, + }, + }; + + // 2. Serialize the object to a JSON string. + const jsonPayload = JSON.stringify(connectionRequest); + + // Compare different encoding methods + const encodingSizes = compareEncodingSizes(jsonPayload); + + console.log("=== Payload Size Comparison ==="); + console.log("Original JSON length:", encodingSizes.original); + console.log("URI encoded length:", encodingSizes.uriEncoded); + console.log("Base64 encoded length:", encodingSizes.base64); + console.log("Compressed + Base64 length:", encodingSizes.compressed); + console.log(`Base64 is ${encodingSizes.stats.base64Reduction.toFixed(2)}% smaller than URI encoding`); + console.log(`Compressed + Base64 is ${encodingSizes.stats.compressionReduction.toFixed(2)}% smaller than URI encoding`); + console.log("==============================="); + + // Log the comparison to the UI as well + addDappLog( + "system", + `Payload sizes - JSON: ${encodingSizes.original}, URI: ${encodingSizes.uriEncoded}, Base64: ${encodingSizes.base64}, Compressed: ${encodingSizes.compressed}`, + ); + addDappLog("system", `Size reductions - Base64: ${encodingSizes.stats.base64Reduction.toFixed(1)}%, Compressed: ${encodingSizes.stats.compressionReduction.toFixed(1)}%`); + + // 4. Construct the full deep link URL using base64 encoding + // Using base64 encoded payload instead of URI encoded + const base64Payload = base64Encode(jsonPayload); + const uriEncodedPayload = encodeURIComponent(jsonPayload); + const deepLinkUrl = `metamask://connect/mwp?p=${uriEncodedPayload}`; + + // Also show what the compressed URL would look like + const compressedPayload = compressString(jsonPayload); + const compressedDeepLinkUrl = `metamask://connect/mwp?p=${compressedPayload}&c=1`; // c=1 indicates compressed + + console.log("Standard deep link length:", deepLinkUrl.length); + console.log("Compressed deep link length:", compressedDeepLinkUrl.length); + + setQrCodeData(deepLinkUrl); + addDappLog("system", "QR code generated with base64 encoded deep link. Ready for wallet to scan."); + + // Start session timer + startSessionTimer(request.expiresAt); + }); + + dapp.on("connected", () => { + addDappLog("system", "DApp connected to wallet! Session creation request sent as initial payload. Waiting for wallet approval..."); + setDappConnected(true); + setDappStatus("Connected"); + clearSessionTimer(); + }); + + dapp.on("disconnected", () => { + addDappLog("system", "DApp disconnected from wallet"); + setDappConnected(false); + resetDappConnectionState(); // Use the reset function here + }); + + dapp.on("message", (payload: unknown) => { + const payloadString = JSON.stringify(payload, null, 2); + addDappLog("received", payloadString); + + // TRY TO PARSE AND STORE THE RESULT + try { + let parsed: any; + if (typeof payload === "string") { + parsed = JSON.parse(payload); + } else if (typeof payload === "object" && payload !== null) { + parsed = payload; + } + + // Check if this is the response for our createSession request + if (parsed.result && parsed.result.sessionScopes) { + setResults(JSON.stringify(parsed.result, null, 2)); + addDappLog("system", "Multi-chain session established and details stored."); + } else if (parsed.result) { + setResults(JSON.stringify(parsed, null, 2)); + } + } catch { + // Not a JSON response, do nothing with the results display. + } + }); + + dapp.on("error", (error: Error) => { + addDappLog("system", `DApp error: ${error.message}`); + }); + + // Try to resume existing session + try { + const sessions = await sessionStore.list(); + if (sessions.length > 0) { + const latestSession = sessions[0]; + addDappLog("system", `Found existing session: ${latestSession.id}, attempting to resume...`); + + if (dappClientRef.current) { + await dappClientRef.current.resume(latestSession.id); + setDappConnected(true); + setDappStatus("Resumed existing session"); + addDappLog("system", "Successfully resumed existing session"); + } + } else { + setDappStatus("Ready to connect"); + addDappLog("system", "DApp client initialized successfully"); + } + } catch (error) { + setDappStatus("Ready to connect"); + addDappLog("system", `DApp client initialized successfully (session resume failed: ${error instanceof Error ? error.message : "Unknown error"})`); + } + } catch (error) { + addDappLog("system", `Failed to initialize dApp: ${error instanceof Error ? error.message : "Unknown error"}`); + setDappStatus("Initialization failed"); + } + }; + + const handleDappConnect = async () => { + if (!dappClientRef.current) { + await initializeDappClient(); + return; + } + + try { + setDappStatus("Connecting..."); + addDappLog("system", "Starting connection process..."); + + await connectWithSessionRequest((error) => { + addDappLog("system", `Connection failed: ${error.message}`); + setDappStatus("Connection failed"); + }); + } catch (error) { + addDappLog("system", `Connection error: ${error instanceof Error ? error.message : "Unknown error"}`); + setDappStatus("Connection failed"); + } + }; + + const handleDappDisconnect = async () => { + if (dappClientRef.current) { + try { + await dappClientRef.current.disconnect(); + // All state clearing is now handled by the 'disconnected' event listener + // or by this reset function for a clean UI immediately. + resetDappConnectionState(); + addDappLog("system", "Disconnected from wallet"); + } catch (error) { + addDappLog("system", `Disconnect error: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } + }; + + const getNextId = () => requestId.current++; + + const handleGetEthBalance = async () => { + if (!dappClientRef.current || !dappConnected) return; + try { + setResults(""); + const invokeRequest = { + id: getNextId(), + jsonrpc: "2.0", + method: "wallet_invokeMethod", + params: { + scope: "eip155:1", + request: { + method: "eth_getBalance", + params: [HARDCODED_ETH_ACCOUNT, "latest"], + }, + }, + }; + addDappLog("sent", JSON.stringify(invokeRequest, null, 2)); + await dappClientRef.current.sendRequest(invokeRequest); + } catch (error) { + addDappLog("system", `Send error: ${error instanceof Error ? error.message : "Unknown error"}`); + } + }; + + const handleEvmPersonalSign = async () => { + if (!dappClientRef.current || !dappConnected) return; + try { + setResults(""); + const invokeRequest = { + id: getNextId(), + jsonrpc: "2.0", + method: "wallet_invokeMethod", + params: { + scope: "eip155:1", + request: { + method: "personal_sign", + params: ["0x48656c6c6f20576f726c64", HARDCODED_ETH_ACCOUNT], + }, + }, + }; + addDappLog("sent", JSON.stringify(invokeRequest, null, 2)); + await dappClientRef.current.sendRequest(invokeRequest); + } catch (error) { + addDappLog("system", `Send error: ${error instanceof Error ? error.message : "Unknown error"}`); + } + }; + + const handleEvmTransaction = async () => { + if (!dappClientRef.current || !dappConnected) return; + try { + setResults(""); + const invokeRequest = { + id: getNextId(), + jsonrpc: "2.0", + method: "wallet_invokeMethod", + params: { + scope: "eip155:1", // Target Polygon + request: { + method: "eth_sendTransaction", + params: [ + { + from: HARDCODED_ETH_ACCOUNT, + to: "0x4838B106FCe9647Bdf1E7877BF73cE8B0BAD5f97", + value: "0x0", + }, + ], + }, + }, + }; + addDappLog("sent", JSON.stringify(invokeRequest, null, 2)); + await dappClientRef.current.sendRequest(invokeRequest); + } catch (error) { + addDappLog("system", `Send error: ${error instanceof Error ? error.message : "Unknown error"}`); + } + }; + + const handleSolanaSignMessage = async () => { + if (!dappClientRef.current || !dappConnected) return; + try { + setResults(""); + const invokeRequest = { + id: getNextId(), + jsonrpc: "2.0", + method: "wallet_invokeMethod", + params: { + scope: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + request: { + method: "signMessage", + params: { + account: { address: HARDCODED_SOL_ACCOUNT }, + message: "SGVsbG8sIHdvcmxkIQ==", // "Hello, world!" in Base64 + }, + }, + }, + }; + addDappLog("sent", JSON.stringify(invokeRequest, null, 2)); + await dappClientRef.current.sendRequest(invokeRequest); + } catch (error) { + addDappLog("system", `Send error: ${error instanceof Error ? error.message : "Unknown error"}`); + } + }; + + // Initialize clients and try to resume sessions on mount + useEffect(() => { + const initializeAndResume = async () => { + await initializeDappClient(); + }; + + initializeAndResume(); + + // Cleanup timer on unmount + return () => { + clearSessionTimer(); + }; + }, []); + + return ( +
+
+ {/* Left Column - DApp Client */} +
+
+
+

MetaMask Mobile App Demo

+
+
+ {dappStatus} +
+
+
+ + {/* DApp Connection Panel */} +
+

Connection

+ +
+
+ {!dappConnected ? ( + + ) : ( + + )} +
+ + {qrCodeData && !dappConnected && ( +
+ {" "} + {/* Centering the content */} +
+
Connect with Mobile Wallet
+ {isSessionExpired ? ( + Session expired + ) : ( + sessionTimeLeft > 0 && Expires in {formatTimeLeft(sessionTimeLeft)} + )} +
+
+ +
+
+

+ {isSessionExpired ? "This QR code has expired." : "Scan with your mobile wallet or use the link below."} +

+ {!isSessionExpired ? ( + + Open in MetaMask Mobile + + ) : ( + + )} +
+
+ )} +
+
+ + {/* DApp Actions */} +
+

Multi-Chain Test Actions

+
+ + + + +
+ + {results && ( +
+
Result
+
{results}
+
+ )} +
+ + {/* DApp Activity Log */} +
+

Activity Log

+ +
+ {dappLogs.length === 0 ? ( +

No activity yet

+ ) : ( +
+ {dappLogs.map((log) => ( +
+
+ {log.type} + {log.timestamp.toLocaleTimeString()} +
+
{log.content}
+
+ ))} +
+ )} +
+
+
+
+ +
+

Note: This demo requires a WebSocket relay server running on localhost:8000

+

Run `docker compose -f backend/docker-compose.yml up -d` to start the backend

+

The dApp interface can connect to external mobile wallets via QR code scanning

+
+
+ ); +} + +// QR Code Display Component +function QRCodeDisplay({ data }: { data: string }) { + const [qrUrl, setQrUrl] = useState(""); + + useEffect(() => { + let mounted = true; + + const generateQR = async () => { + try { + const { default: QRCode } = await import("qrcode"); + const url = await QRCode.toDataURL(data, { + width: 200, + margin: 2, + color: { + dark: "#000000", + light: "#FFFFFF", + }, + }); + if (mounted) { + setQrUrl(url); + } + } catch (error) { + console.error("QR Code generation failed:", error); + } + }; + + generateQR(); + + return () => { + mounted = false; + }; + }, [data]); + + if (!qrUrl) { + return ( +
+ Generating QR... +
+ ); + } + + return Connection QR Code; +} diff --git a/apps/web-demo/src/components/Navigation.tsx b/apps/web-demo/src/components/Navigation.tsx index 84d652a..b543974 100644 --- a/apps/web-demo/src/components/Navigation.tsx +++ b/apps/web-demo/src/components/Navigation.tsx @@ -7,6 +7,7 @@ const demos = [ { name: "Basic Demo", href: "/" }, { name: "Trusted Demo", href: "/trusted-demo" }, { name: "Untrusted Demo", href: "/untrusted-demo" }, + { name: "MetaMask Mobile App Demo", href: "/metamask-mobile-demo" }, ]; export default function Navigation() { @@ -27,11 +28,10 @@ export default function Navigation() { {demo.name} diff --git a/apps/web-demo/src/lib/encoding-utils.ts b/apps/web-demo/src/lib/encoding-utils.ts new file mode 100644 index 0000000..c745f9c --- /dev/null +++ b/apps/web-demo/src/lib/encoding-utils.ts @@ -0,0 +1,95 @@ +import * as pako from 'pako'; + +/** + * Cross-platform base64 encoding + * Works in browser, Node.js, and React Native environments + */ +export function base64Encode(str: string): string { + if (typeof btoa !== 'undefined') { + // Browser and React Native with polyfills + return btoa(str); + } else if (typeof Buffer !== 'undefined') { + // Node.js + return Buffer.from(str).toString('base64'); + } else { + throw new Error('No base64 encoding method available'); + } +} + +/** + * Cross-platform base64 decoding + * Works in browser, Node.js, and React Native environments + */ +export function base64Decode(str: string): string { + if (typeof atob !== 'undefined') { + // Browser and React Native with polyfills + return atob(str); + } else if (typeof Buffer !== 'undefined') { + // Node.js + return Buffer.from(str, 'base64').toString(); + } else { + throw new Error('No base64 decoding method available'); + } +} + +/** + * Compress a string using pako (deflate) + * Returns a base64-encoded compressed string + */ +export function compressString(str: string): string { + const compressed = pako.deflate(str); + // Convert Uint8Array to string for base64 encoding + const binaryString = String.fromCharCode.apply(null, Array.from(compressed)); + return base64Encode(binaryString); +} + +/** + * Decompress a base64-encoded compressed string + */ +export function decompressString(compressedBase64: string): string { + const binaryString = base64Decode(compressedBase64); + // Convert string back to Uint8Array + const compressed = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + compressed[i] = binaryString.charCodeAt(i); + } + const decompressed = pako.inflate(compressed); + return new TextDecoder().decode(decompressed); +} + +/** + * Compare different encoding methods and return size information + */ +export function compareEncodingSizes(jsonPayload: string): { + original: number; + uriEncoded: number; + base64: number; + compressed: number; + stats: { + base64Reduction: number; + compressionReduction: number; + }; +} { + const uriEncoded = encodeURIComponent(jsonPayload); + const base64 = base64Encode(jsonPayload); + const compressed = compressString(jsonPayload); + + const originalLength = jsonPayload.length; + const uriEncodedLength = uriEncoded.length; + const base64Length = base64.length; + const compressedLength = compressed.length; + + const base64Reduction = ((uriEncodedLength - base64Length) / uriEncodedLength) * 100; + const compressionReduction = ((uriEncodedLength - compressedLength) / uriEncodedLength) * 100; + + return { + original: originalLength, + uriEncoded: uriEncodedLength, + base64: base64Length, + compressed: compressedLength, + stats: { + base64Reduction, + compressionReduction, + }, + }; +} diff --git a/yarn.lock b/yarn.lock index 1234977..6c6fad1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4232,6 +4232,13 @@ __metadata: languageName: node linkType: hard +"@types/pako@npm:^2.0.4": + version: 2.0.4 + resolution: "@types/pako@npm:2.0.4" + checksum: 7e7b090c27cdbed4a8c21f7dfa466ad1c6b5c29afbcaaa24b35e83803d2b6b6cd552060060d8832058cf3809787baaab47a6a974b69d75a2a2dc59d21b607ee8 + languageName: node + linkType: hard + "@types/qrcode@npm:^1.5.5": version: 1.5.5 resolution: "@types/qrcode@npm:1.5.5" @@ -10927,6 +10934,13 @@ __metadata: languageName: node linkType: hard +"pako@npm:^2.1.0": + version: 2.1.0 + resolution: "pako@npm:2.1.0" + checksum: 71666548644c9a4d056bcaba849ca6fd7242c6cf1af0646d3346f3079a1c7f4a66ffec6f7369ee0dc88f61926c10d6ab05da3e1fca44b83551839e89edd75a3e + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -13996,6 +14010,7 @@ __metadata: "@metamask/mobile-wallet-protocol-wallet-client": "workspace:^" "@tailwindcss/postcss": ^4 "@types/node": ^20 + "@types/pako": ^2.0.4 "@types/qrcode": ^1.5.5 "@types/react": ^19 "@types/react-dom": ^19 @@ -14003,6 +14018,7 @@ __metadata: eslint: ^9 eslint-config-next: 15.4.1 next: 15.4.1 + pako: ^2.1.0 qrcode: ^1.5.4 react: 19.1.0 react-dom: 19.1.0