wallet
Connect wallets via QR code scanning to approve blockchain transactions while keeping private keys secure on the user's device. Enables multi-step bridge and swap operations through automated transaction and signature handling.
Instructions
Connect a user's wallet via WalletConnect. The user scans a QR code with their mobile wallet (MetaMask, Rainbow, etc.) to approve transactions — private keys never leave their device.
Actions: pair — Returns a WalletConnect URI. You MUST then display it as a QR code (see below), then IMMEDIATELY call status to wait for connection. status — Waits up to 60s for the wallet to connect. Blocks until connected or timeout. Call this right after rendering the QR. execute — Execute ALL steps from execute_bridge in order. Handles multi-step flows (approval + deposit) and both transaction and signature steps automatically. disconnect — End the wallet session.
IMPORTANT: After calling pair, you must:
Render the returned URI as a QR code using the bash command below.
Immediately call wallet with action 'status' — it will block until the user scans and connects (up to 60s).
Run this bash command to render the QR as an image, substituting the URI:
python3 -c " import qrcode, tempfile, os qr = qrcode.QRCode(border=3, box_size=10, error_correction=qrcode.constants.ERROR_CORRECT_L) qr.add_data('WALLETCONNECT_URI_HERE') qr.make() img = qr.make_image(fill_color='black', back_color='white') p = os.path.join(tempfile.gettempdir(), 'relay-wc-qr.png') img.save(p) os.system(f'open {p}') print(f'QR code opened: {p}') "
If python3 qrcode is not installed, run: pip3 install "qrcode[pil]"
Typical flow: pair → render QR via bash → (user scans) → status → execute_bridge → execute → get_transaction_status
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| action | Yes | The wallet action to perform. | |
| chainIds | No | Chain IDs to request access to. Required for "pair". E.g. [1, 8453] for Ethereum + Base. | |
| steps | No | The steps array from execute_bridge. Required for "execute". Contains all steps (approval, deposit, signatures) to execute in order. |
Implementation Reference
- src/tools/wallet.ts:84-235 (handler)Main handler function that executes the wallet tool logic. Handles four actions: 'pair' (initiates WalletConnect QR), 'status' (waits for connection), 'execute' (runs bridge/swap steps), and 'disconnect' (ends session). Uses switch statement to route actions and integrates with WalletConnect session management.
async ({ action, chainIds, steps }) => { switch (action) { case "pair": { if (!chainIds || chainIds.length === 0) { return { content: [ { type: "text", text: 'Error: chainIds is required for the "pair" action. Provide the chain IDs you need (e.g. [1, 8453]).', }, ], isError: true, }; } const { uri, approval } = await pair(chainIds); pendingApproval = approval; return { content: [ { type: "text", text: `WalletConnect URI:\n${uri}`, }, { type: "text", text: "Display the URI above as a QR code for the user (see tool description for bash command), then IMMEDIATELY call wallet with action 'status' to wait for the connection.", }, ], }; } case "status": { if (pendingApproval) { try { await waitForApproval(pendingApproval, 60_000); pendingApproval = null; } catch { // Timed out — fall through to check state } } const state = getSessionInfo(); if (!state.connected) { return { content: [ { type: "text", text: pendingApproval ? "Wallet not connected yet — the user may still be scanning. Call status again to keep waiting." : 'No wallet connected. Use wallet with action "pair" to start.', }, ], }; } return { content: [ { type: "text", text: `Wallet connected! Address: ${state.address}, chains: ${JSON.stringify(state.chains)}`, }, ], }; } case "execute": { if (!steps || steps.length === 0) { return { content: [ { type: "text", text: 'Error: steps is required for the "execute" action. Pass the steps array from execute_bridge.', }, ], isError: true, }; } // If pairing is still pending, wait for it if (pendingApproval) { try { await waitForApproval(pendingApproval, 120_000); pendingApproval = null; } catch (err) { return { content: [ { type: "text", text: `Error: Wallet pairing not completed. ${err instanceof Error ? err.message : String(err)}`, }, ], isError: true, }; } } const adapter = getAdapter(); const result = await executeSteps(adapter, steps as Step[]); if (!result.success) { return { content: [ { type: "text", text: result.error || "Execution failed." }, { type: "text", text: result.log.join("\n") }, ], isError: true, }; } const trackingUrl = `https://relay.link/transaction/${result.requestId}`; return { content: [ { type: "text", text: `All ${steps.length} step(s) executed successfully! Poll get_transaction_status with requestId "${result.requestId}" every ~5s until "success". The user does NOT need to do anything else — Relay handles the rest.\n\nTracking URL: ${trackingUrl}\nShow this link to the user so they can follow along. Once status is "success", present the link as the final confirmation.`, }, { type: "text", text: result.log.join("\n") }, ], }; } case "disconnect": { const adapter = (() => { try { return getAdapter(); } catch { return null; } })(); if (adapter) { await adapter.disconnect(); } pendingApproval = null; return { content: [{ type: "text", text: "Wallet disconnected." }], }; } default: return { content: [ { type: "text", text: `Unknown action: ${action}. Use one of: pair, status, execute, disconnect.`, }, ], isError: true, }; } } - src/tools/wallet.ts:48-83 (schema)Zod schema defining the wallet tool's input parameters. Accepts 'action' (enum: pair, status, execute, disconnect), optional 'chainIds' array for pair action, and optional 'steps' array for execute action. Steps include id, action, description, kind (transaction/signature), requestId, and items with status and data.
action: z .enum(["pair", "status", "execute", "disconnect"]) .describe("The wallet action to perform."), chainIds: z .array(z.number()) .optional() .describe( 'Chain IDs to request access to. Required for "pair". E.g. [1, 8453] for Ethereum + Base.' ), steps: z .array( z.object({ id: z.string(), action: z.string(), description: z.string(), kind: z.enum(["transaction", "signature"]), requestId: z.string(), items: z.array( z.object({ status: z.string(), data: z.any(), check: z .object({ endpoint: z.string(), method: z.string(), }) .optional(), }) ), }) ) .optional() .describe( 'The steps array from execute_bridge. Required for "execute". Contains all steps (approval, deposit, signatures) to execute in order.' ), }, - src/tools/wallet.ts:15-237 (registration)Registers the 'wallet' tool with the MCP server. Defines comprehensive tool description explaining WalletConnect flow, QR code rendering instructions, and typical usage pattern (pair → render QR → status → execute_bridge → execute). Exports register function for use in main server initialization.
export function register(server: McpServer) { server.tool( "wallet", `Connect a user's wallet via WalletConnect. The user scans a QR code with their mobile wallet (MetaMask, Rainbow, etc.) to approve transactions — private keys never leave their device. Actions: pair — Returns a WalletConnect URI. You MUST then display it as a QR code (see below), then IMMEDIATELY call status to wait for connection. status — Waits up to 60s for the wallet to connect. Blocks until connected or timeout. Call this right after rendering the QR. execute — Execute ALL steps from execute_bridge in order. Handles multi-step flows (approval + deposit) and both transaction and signature steps automatically. disconnect — End the wallet session. IMPORTANT: After calling pair, you must: 1. Render the returned URI as a QR code using the bash command below. 2. Immediately call wallet with action 'status' — it will block until the user scans and connects (up to 60s). Run this bash command to render the QR as an image, substituting the URI: python3 -c " import qrcode, tempfile, os qr = qrcode.QRCode(border=3, box_size=10, error_correction=qrcode.constants.ERROR_CORRECT_L) qr.add_data('WALLETCONNECT_URI_HERE') qr.make() img = qr.make_image(fill_color='black', back_color='white') p = os.path.join(tempfile.gettempdir(), 'relay-wc-qr.png') img.save(p) os.system(f'open {p}') print(f'QR code opened: {p}') " If python3 qrcode is not installed, run: pip3 install "qrcode[pil]" Typical flow: pair → render QR via bash → (user scans) → status → execute_bridge → execute → get_transaction_status`, { action: z .enum(["pair", "status", "execute", "disconnect"]) .describe("The wallet action to perform."), chainIds: z .array(z.number()) .optional() .describe( 'Chain IDs to request access to. Required for "pair". E.g. [1, 8453] for Ethereum + Base.' ), steps: z .array( z.object({ id: z.string(), action: z.string(), description: z.string(), kind: z.enum(["transaction", "signature"]), requestId: z.string(), items: z.array( z.object({ status: z.string(), data: z.any(), check: z .object({ endpoint: z.string(), method: z.string(), }) .optional(), }) ), }) ) .optional() .describe( 'The steps array from execute_bridge. Required for "execute". Contains all steps (approval, deposit, signatures) to execute in order.' ), }, async ({ action, chainIds, steps }) => { switch (action) { case "pair": { if (!chainIds || chainIds.length === 0) { return { content: [ { type: "text", text: 'Error: chainIds is required for the "pair" action. Provide the chain IDs you need (e.g. [1, 8453]).', }, ], isError: true, }; } const { uri, approval } = await pair(chainIds); pendingApproval = approval; return { content: [ { type: "text", text: `WalletConnect URI:\n${uri}`, }, { type: "text", text: "Display the URI above as a QR code for the user (see tool description for bash command), then IMMEDIATELY call wallet with action 'status' to wait for the connection.", }, ], }; } case "status": { if (pendingApproval) { try { await waitForApproval(pendingApproval, 60_000); pendingApproval = null; } catch { // Timed out — fall through to check state } } const state = getSessionInfo(); if (!state.connected) { return { content: [ { type: "text", text: pendingApproval ? "Wallet not connected yet — the user may still be scanning. Call status again to keep waiting." : 'No wallet connected. Use wallet with action "pair" to start.', }, ], }; } return { content: [ { type: "text", text: `Wallet connected! Address: ${state.address}, chains: ${JSON.stringify(state.chains)}`, }, ], }; } case "execute": { if (!steps || steps.length === 0) { return { content: [ { type: "text", text: 'Error: steps is required for the "execute" action. Pass the steps array from execute_bridge.', }, ], isError: true, }; } // If pairing is still pending, wait for it if (pendingApproval) { try { await waitForApproval(pendingApproval, 120_000); pendingApproval = null; } catch (err) { return { content: [ { type: "text", text: `Error: Wallet pairing not completed. ${err instanceof Error ? err.message : String(err)}`, }, ], isError: true, }; } } const adapter = getAdapter(); const result = await executeSteps(adapter, steps as Step[]); if (!result.success) { return { content: [ { type: "text", text: result.error || "Execution failed." }, { type: "text", text: result.log.join("\n") }, ], isError: true, }; } const trackingUrl = `https://relay.link/transaction/${result.requestId}`; return { content: [ { type: "text", text: `All ${steps.length} step(s) executed successfully! Poll get_transaction_status with requestId "${result.requestId}" every ~5s until "success". The user does NOT need to do anything else — Relay handles the rest.\n\nTracking URL: ${trackingUrl}\nShow this link to the user so they can follow along. Once status is "success", present the link as the final confirmation.`, }, { type: "text", text: result.log.join("\n") }, ], }; } case "disconnect": { const adapter = (() => { try { return getAdapter(); } catch { return null; } })(); if (adapter) { await adapter.disconnect(); } pendingApproval = null; return { content: [{ type: "text", text: "Wallet disconnected." }], }; } default: return { content: [ { type: "text", text: `Unknown action: ${action}. Use one of: pair, status, execute, disconnect.`, }, ], isError: true, }; } } ); } - src/index.ts:13-29 (registration)Imports the wallet tool registration from './tools/wallet.js' and calls registerWallet(server) to register it with the MCP server instance. Part of the main server initialization sequence.
import { register as registerWallet } from "./tools/wallet.js"; const server = new McpServer({ name: "relay-mcp", version: "0.1.0", }); registerGetSupportedChains(server); registerGetSupportedTokens(server); registerGetBridgeQuote(server); registerGetSwapQuote(server); registerEstimateFees(server); registerExecuteBridge(server); registerGetTransactionStatus(server); registerGetTransactionHistory(server); registerGetRelayAppUrl(server); registerWallet(server); - Core helper functions used by the wallet tool: 'pair' creates WalletConnect URI and returns approval promise, 'waitForApproval' blocks until connection or timeout, 'isConnected' checks session status, and 'getSessionInfo' returns connected address and chains. Also contains 'getAdapter' that returns a WalletAdapter implementation for transaction signing.
export async function pair( chainIds: number[] ): Promise<{ uri: string; approval: Promise<void> }> { const client = await getClient(); // Tear down existing session if (wcSession) { try { await client.disconnect({ topic: wcSession.topic, reason: { code: 6000, message: "New pairing requested" }, }); } catch { // ignore } wcSession = null; } const { uri, approval } = await client.connect({ requiredNamespaces: { eip155: { chains: chainIds.map((id) => `eip155:${id}`), methods: [ "eth_sendTransaction", "personal_sign", "eth_signTypedData", "eth_signTypedData_v4", ], events: ["chainChanged", "accountsChanged"], }, }, }); if (!uri) { throw new Error("WalletConnect failed to generate pairing URI"); } const approvalPromise = approval().then((approved) => { const eip155 = approved.namespaces.eip155; if (!eip155) throw new Error("Wallet did not approve eip155 namespace"); wcSession = { client, topic: approved.topic, accounts: eip155.accounts || [], chains: (eip155.chains || []).map((c) => Number(c.replace("eip155:", "")) ), }; }); return { uri, approval: approvalPromise }; } /** * Block until the pending approval resolves or times out. */ export async function waitForApproval( approvalPromise: Promise<void>, timeoutMs = 120_000 ): Promise<void> { const timeout = new Promise<never>((_, reject) => setTimeout( () => reject(new Error("WalletConnect pairing timed out")), timeoutMs ) ); await Promise.race([approvalPromise, timeout]); if (!wcSession) { throw new Error("Session was not established after approval"); } } /** * Quick check: is there an active session? */ export function isConnected(): boolean { return wcSession !== null; } /** * Return session metadata (address + chains) without needing a full adapter. * Useful for the MCP tool's `status` action and for `execute_bridge` to * auto-detect the sender address. */ export function getSessionInfo(): { connected: boolean; address: string | null; chains: number[]; } { if (!wcSession) return { connected: false, address: null, chains: [] }; const addr = wcSession.accounts[0]?.split(":")[2] || null; return { connected: true, address: addr, chains: wcSession.chains }; }