diff --git a/.changeset/wallet-authorize-challenge-support.md b/.changeset/wallet-authorize-challenge-support.md new file mode 100644 index 00000000..35be0fc3 --- /dev/null +++ b/.changeset/wallet-authorize-challenge-support.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added `wallet_authorizeChallenge` support. JSON-RPC accounts now delegate Tempo charge and session challenges to wallets that advertise MPP support via `wallet_getCapabilities`, falling back to local signing otherwise. diff --git a/src/tempo/client/Charge.test.ts b/src/tempo/client/Charge.test.ts index cfec7169..c702d1ed 100644 --- a/src/tempo/client/Charge.test.ts +++ b/src/tempo/client/Charge.test.ts @@ -1,5 +1,5 @@ import { Challenge, Credential } from 'mppx' -import { createClient, http } from 'viem' +import { createClient, custom, http } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { tempoLocalnet } from 'viem/chains' import { describe, expect, test, vi } from 'vp/test' @@ -208,4 +208,75 @@ describe('tempo.charge client', () => { expect(credential.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`) }) }) + + describe('wallet_authorizeChallenge', () => { + const walletAddress = '0x1111111111111111111111111111111111111111' as const + const chainId = 42431 + + test('json-rpc accounts use a supporting wallet instead of local signing', async () => { + const challenge = createChallenge({ chainId }) + const authorization = Credential.serialize({ + challenge, + payload: { hash: '0x1234', type: 'hash' }, + }) + const requests: { method: string; params?: unknown }[] = [] + const client = createClient({ + chain: tempoLocalnet, + transport: custom({ + async request({ method, params }: { method: string; params?: unknown }) { + requests.push({ method, params }) + if (method === 'wallet_getCapabilities') + return { '0xa5bf': { mpp: { status: 'supported' } } } + if (method === 'wallet_authorizeChallenge') return { authorization } + throw new Error(`unexpected rpc request: ${method}`) + }, + }), + }) + const method = charge({ + account: walletAddress, + getClient: () => client, + }) + + await expect(method.createCredential({ challenge, context: {} })).resolves.toBe(authorization) + expect(requests.map(({ method }) => method)).toEqual([ + 'wallet_getCapabilities', + 'wallet_authorizeChallenge', + ]) + expect(requests[1]?.params).toEqual([{ challenges: [Challenge.serialize(challenge)] }]) + }) + + test('json-rpc accounts fall back to local signing without wallet mpp support', async () => { + const signature = `0x${'11'.repeat(64)}1b` as const + const requests: { method: string }[] = [] + const client = createClient({ + chain: tempoLocalnet, + transport: custom({ + async request({ method }: { method: string }) { + requests.push({ method }) + if (method === 'wallet_getCapabilities') return {} + if (method === 'eth_signTypedData_v4') return signature + throw new Error(`unexpected rpc request: ${method}`) + }, + }), + }) + const method = charge({ + account: walletAddress, + getClient: () => client, + }) + + const credential = Credential.deserialize( + await method.createCredential({ + challenge: createChallenge({ chainId }), + context: {}, + }), + ) + + expect(requests.map(({ method }) => method)).toEqual([ + 'wallet_getCapabilities', + 'eth_signTypedData_v4', + ]) + expect(credential.payload).toEqual({ signature, type: 'proof' }) + expect(credential.source).toBe(`did:pkh:eip155:${chainId}:${walletAddress}`) + }) + }) }) diff --git a/src/tempo/client/Charge.ts b/src/tempo/client/Charge.ts index 6c8ef589..8160bfc5 100644 --- a/src/tempo/client/Charge.ts +++ b/src/tempo/client/Charge.ts @@ -19,6 +19,7 @@ import * as AutoSwap from '../internal/auto-swap.js' import * as Charge_internal from '../internal/charge.js' import * as defaults from '../internal/defaults.js' import * as Proof from '../internal/proof.js' +import * as Wallet from '../internal/wallet.js' import * as Methods from '../Methods.js' /** @@ -73,6 +74,30 @@ export function charge(parameters: charge.Parameters = {}) { const { request } = challenge const { amount, methodDetails } = request + // Recipient allowlist: validated before any signing path so it also + // covers wallet-handled payments. + if (parameters.expectedRecipients) { + const allowed = new Set(parameters.expectedRecipients.map((a) => a.toLowerCase())) + const splits = methodDetails?.splits as readonly { recipient: string }[] | undefined + if (splits) { + for (const split of splits) { + if (!allowed.has(split.recipient.toLowerCase())) + throw new Error(`Unexpected split recipient: ${split.recipient}`) + } + } + } + + // Wallet-native MPP: ask a JSON-RPC wallet to satisfy the challenge via + // `wallet_authorizeChallenge` before falling back to the local signing path. + if (account.type === 'json-rpc') { + const authorization = await Wallet.authorize(client, { + account: account.address, + chainId, + challenge, + }) + if (authorization) return authorization + } + // Zero-amount: sign EIP-712 typed data instead of creating a transaction. if (BigInt(amount) === 0n) { const signature = await signTypedData(client, { @@ -90,16 +115,6 @@ export function charge(parameters: charge.Parameters = {}) { } const currency = request.currency as Address - if (parameters.expectedRecipients) { - const allowed = new Set(parameters.expectedRecipients.map((a) => a.toLowerCase())) - const splits = methodDetails?.splits as readonly { recipient: string }[] | undefined - if (splits) { - for (const split of splits) { - if (!allowed.has(split.recipient.toLowerCase())) - throw new Error(`Unexpected split recipient: ${split.recipient}`) - } - } - } const supportedModes = (methodDetails?.supportedModes as | readonly Methods.ChargeMode[] | undefined) ?? ['pull', 'push'] diff --git a/src/tempo/internal/wallet.contract.test.ts b/src/tempo/internal/wallet.contract.test.ts new file mode 100644 index 00000000..e85fceb0 --- /dev/null +++ b/src/tempo/internal/wallet.contract.test.ts @@ -0,0 +1,145 @@ +// Pins the `wallet_getCapabilities` / `wallet_authorizeChallenge` wire contract implemented +// by tempoxyz/accounts' provider (accounts/src/core/Provider.ts), so changes on +// either side must update both. +import type { Address } from 'viem' +import { describe, expect, test } from 'vp/test' +import * as z from 'zod/mini' + +import * as Challenge from '../../Challenge.js' +import * as Credential from '../../Credential.js' +import * as Wallet from './wallet.js' + +const account = '0x1111111111111111111111111111111111111111' as Address +const chainId = 42431 +const chainIdHex = '0xa5bf' + +const challenge = Challenge.from({ + id: 'contract-challenge', + realm: 'example.com', + method: 'tempo', + intent: 'charge', + request: { + amount: '1000000', + currency: '0x3333333333333333333333333333333333333333', + methodDetails: { chainId }, + recipient: '0x2222222222222222222222222222222222222222', + }, +}) +const authorization = Credential.serialize({ + challenge, + payload: { hash: '0x1234', type: 'hash' }, +}) + +// Params schemas inline-copied from accounts/src/core/zod/rpc.ts +// (`wallet_getCapabilities`, `wallet_authorizeChallenge`) — the drift tripwire. +const addressSchema = z.templateLiteral(['0x', z.string().check(z.regex(/^[0-9a-fA-F]{40}$/))]) +const hexSchema = z.templateLiteral(['0x', z.string()]) +const capabilitiesParams = z.optional( + z.readonly( + z.union([z.tuple([addressSchema]), z.tuple([addressSchema, z.readonly(z.array(hexSchema))])]), + ), +) +const authorizeParams = z.readonly( + z.tuple([z.object({ challenges: z.readonly(z.array(z.string()).check(z.minLength(1))) })]), +) + +/** Fake EIP-1193 wallet mirroring the accounts provider's dispatch. */ +function createWallet(options: { connected?: readonly Address[]; mpp?: boolean } = {}) { + const { connected = [account] } = options + const chains = [chainIdHex] + const requests: { method: string; params?: unknown }[] = [] + const state = { mpp: options.mpp ?? true } + const error = (message: string, code: number) => Object.assign(new Error(message), { code }) + + async function request({ method, params }: { method: string; params?: unknown }) { + requests.push({ method, params }) + switch (method) { + case 'wallet_getCapabilities': { + const [address, chainIds] = (params ?? []) as [Address?, (readonly string[])?] + // Unconnected addresses are rejected with an `UnauthorizedError`. + if (address && !connected.some((a) => a.toLowerCase() === address.toLowerCase())) + throw error(`Address ${address} is not connected.`, 4100) + // The chain filter compares exact hex strings. + const filtered = chainIds ? chains.filter((c) => chainIds.includes(c)) : chains + return Object.fromEntries( + filtered.map((c) => [ + c, + { + accessKeys: { status: 'supported' }, + atomic: { status: 'supported' }, + ...(state.mpp ? { mpp: { status: 'supported' } } : {}), + }, + ]), + ) + } + case 'wallet_authorizeChallenge': { + // MPP disabled is rejected with an `UnsupportedMethodError`. + if (!state.mpp) + throw error('`wallet_authorizeChallenge` not supported. MPP is disabled.', 4200) + return { authorization } + } + default: + throw error(`Method not found: ${method}`, -32601) + } + } + + return { client: { request } as never, requests, state } +} + +describe('wallet_authorizeChallenge wire contract', () => { + test('supported wallet serves the credential: probe, then authorize', async () => { + const wallet = createWallet() + + await expect(Wallet.authorize(wallet.client, { account, challenge, chainId })).resolves.toBe( + authorization, + ) + expect(wallet.requests).toEqual([ + { method: 'wallet_getCapabilities', params: [account, [chainIdHex]] }, + { + method: 'wallet_authorizeChallenge', + params: [{ challenges: [Challenge.serialize(challenge)] }], + }, + ]) + }) + + test.each([ + ['MPP is disabled (no mpp capability)', { mpp: false }], + ['the address is not connected (4100 on the probe)', { connected: [] }], + ])('falls back to undefined when %s', async (_, options) => { + const wallet = createWallet(options) + + await expect(Wallet.authorize(wallet.client, { account, challenge, chainId })).resolves.toBe( + undefined, + ) + expect(wallet.requests.map(({ method }) => method)).toEqual(['wallet_getCapabilities']) + }) + + test('falls back when a probed wallet disables MPP (4200 on wallet_authorizeChallenge)', async () => { + const wallet = createWallet() + const probeCache = new Map() + const parameters = { account, challenge, chainId, probeCache } + + await expect(Wallet.authorize(wallet.client, parameters)).resolves.toBe(authorization) + wallet.state.mpp = false + await expect(Wallet.authorize(wallet.client, parameters)).resolves.toBe(undefined) + // The probe memo skips re-probing, so the 4200 alone drives the fallback. + expect(wallet.requests.map(({ method }) => method)).toEqual([ + 'wallet_getCapabilities', + 'wallet_authorizeChallenge', + 'wallet_authorizeChallenge', + ]) + }) + + test("sent params validate against accounts' RPC schemas", async () => { + const wallet = createWallet() + await Wallet.authorize(wallet.client, { account, challenge, chainId }) + + const [probe, authorize] = wallet.requests + expect(probe?.method).toBe('wallet_getCapabilities') + expect(authorize?.method).toBe('wallet_authorizeChallenge') + expect(capabilitiesParams.parse(probe?.params)).toEqual([account, [chainIdHex]]) + expect(authorizeParams.parse(authorize?.params)).toEqual([ + { challenges: [Challenge.serialize(challenge)] }, + ]) + }) +}) diff --git a/src/tempo/internal/wallet.test.ts b/src/tempo/internal/wallet.test.ts new file mode 100644 index 00000000..9421a150 --- /dev/null +++ b/src/tempo/internal/wallet.test.ts @@ -0,0 +1,127 @@ +import type { Address } from 'viem' +import { describe, expect, test } from 'vp/test' + +import * as Challenge from '../../Challenge.js' +import * as Credential from '../../Credential.js' +import * as Wallet from './wallet.js' + +const account = '0x1111111111111111111111111111111111111111' as Address +const chainId = 42431 +const chainIdHex = '0xa5bf' +const capabilities = { [chainIdHex]: { mpp: { status: 'supported' } } } + +const challenge = Challenge.from({ + id: 'test-challenge', + realm: 'example.com', + method: 'tempo', + intent: 'charge', + request: { + amount: '1000000', + currency: '0x3333333333333333333333333333333333333333', + methodDetails: { chainId }, + recipient: '0x2222222222222222222222222222222222222222', + }, +}) +const authorization = Credential.serialize({ + challenge, + payload: { hash: '0x1234', type: 'hash' }, +}) + +function makeClient(request: (parameters: { method: string; params?: unknown }) => unknown) { + return { request: async (parameters: never) => request(parameters) } as never +} + +describe('wallet_authorizeChallenge helper', () => { + test('returns the authorization from a supporting wallet', async () => { + const requests: unknown[] = [] + const client = makeClient((parameters) => { + requests.push(parameters) + if (parameters.method === 'wallet_getCapabilities') return capabilities + return { authorization } + }) + + await expect(Wallet.authorize(client, { account, challenge, chainId })).resolves.toBe( + authorization, + ) + expect(requests).toEqual([ + { method: 'wallet_getCapabilities', params: [account, [chainIdHex]] }, + { + method: 'wallet_authorizeChallenge', + params: [{ challenges: [Challenge.serialize(challenge)] }], + }, + ]) + }) + + test('matches capability chain IDs case-insensitively', async () => { + const client = makeClient(({ method }) => + method === 'wallet_getCapabilities' + ? { '0xA5BF': { mpp: { status: 'supported' } } } + : { authorization }, + ) + + await expect(Wallet.authorize(client, { account, challenge, chainId })).resolves.toBe( + authorization, + ) + }) + + test('returns undefined without calling wallet_authorizeChallenge when the capability is absent', async () => { + const requests: { method: string }[] = [] + const client = makeClient((parameters) => { + requests.push(parameters) + return {} + }) + + await expect(Wallet.authorize(client, { account, challenge, chainId })).resolves.toBe(undefined) + expect(requests.map(({ method }) => method)).toEqual(['wallet_getCapabilities']) + }) + + test.each([ + ['wallet_getCapabilities', 4200], + ['wallet_getCapabilities', 4100], + ['wallet_getCapabilities', -32603], + ['wallet_authorizeChallenge', -32601], + ])('returns undefined when %s throws code %i', async (method, code) => { + const client = makeClient((parameters) => { + if (parameters.method === method) throw Object.assign(new Error('unsupported'), { code }) + return capabilities + }) + + await expect(Wallet.authorize(client, { account, challenge, chainId })).resolves.toBe(undefined) + }) + + test('rethrows other wallet errors', async () => { + const client = makeClient(({ method }) => { + if (method === 'wallet_authorizeChallenge') + throw Object.assign(new Error('user rejected'), { code: 4001 }) + return capabilities + }) + + await expect(Wallet.authorize(client, { account, challenge, chainId })).rejects.toThrow( + 'user rejected', + ) + }) + + test('throws on an invalid response shape', async () => { + const client = makeClient(({ method }) => + method === 'wallet_getCapabilities' ? capabilities : {}, + ) + + await expect(Wallet.authorize(client, { account, challenge, chainId })).rejects.toThrow( + 'Invalid `wallet_authorizeChallenge` response.', + ) + }) + + test('throws when the credential answers a different challenge', async () => { + const mismatched = Credential.serialize({ + challenge: { ...challenge, id: 'other-challenge' }, + payload: { hash: '0x1234', type: 'hash' }, + }) + const client = makeClient(({ method }) => + method === 'wallet_getCapabilities' ? capabilities : { authorization: mismatched }, + ) + + await expect(Wallet.authorize(client, { account, challenge, chainId })).rejects.toThrow( + 'wallet_authorizeChallenge returned a credential for a different challenge.', + ) + }) +}) diff --git a/src/tempo/internal/wallet.ts b/src/tempo/internal/wallet.ts new file mode 100644 index 00000000..90e398c4 --- /dev/null +++ b/src/tempo/internal/wallet.ts @@ -0,0 +1,105 @@ +import { numberToHex, type Address, type Client, type Hex } from 'viem' + +import * as Challenge from '../../Challenge.js' +import * as Credential from '../../Credential.js' + +/** `wallet_getCapabilities` result, keyed by hex chain ID. */ +type Capabilities = Record< + string, + { mpp?: { status?: string | undefined } | undefined } | undefined +> + +/** + * Asks the wallet behind a JSON-RPC account to satisfy an MPP challenge via + * the `wallet_authorizeChallenge` RPC method, gated on a `wallet_getCapabilities` probe. + * + * Returns `undefined` when the capability probe fails for any reason, when + * the wallet does not advertise MPP support, or when the wallet rejects + * `wallet_authorizeChallenge` as unsupported (EIP-1193 `4200` or JSON-RPC `-32601`), + * allowing callers to fall back to the local signing path. + * + * Wallet-managed sessions do not populate local `SessionManager` state — the + * channel ID and cumulative getters, the `Payment-Session` header hint, and + * persistence are no-ops, and the server identifies the channel from the + * credential. Wallets may also apply their own approval policy and + * auto-approve without prompting — apps that want app-level consent should + * use the MCP wrapper's `onPaymentRequired` hook or equivalent. + */ +export async function authorize( + client: Client, + parameters: authorize.Parameters, +): Promise { + const { account, challenge, probeCache } = parameters + const chainId = numberToHex(parameters.chainId) + const serializedChallenge = Challenge.serialize(challenge) + + const probeKey = `${account.toLowerCase()}:${chainId}` + if (!probeCache?.has(probeKey)) { + // The capability probe is opportunistic — unauthorized, invalid-params, + // or transport errors keep the legacy local-signing path. + try { + const capabilities = (await client.request({ + method: 'wallet_getCapabilities', + params: [account, [chainId]], + } as never)) as Capabilities | null | undefined + if (!supportsMpp(capabilities, chainId)) return undefined + } catch { + return undefined + } + probeCache?.set(probeKey, true) + } + + let response: { authorization?: string | undefined } | null | undefined + try { + response = (await client.request({ + method: 'wallet_authorizeChallenge', + params: [{ challenges: [serializedChallenge] }], + } as never)) as typeof response + } catch (error) { + // By now the wallet claimed support, so only an unsupported-method + // rejection falls back; anything else (e.g. user rejection) propagates. + if (isUnsupported(error)) return undefined + throw error + } + + if (!response || typeof response.authorization !== 'string') + throw new Error('Invalid `wallet_authorizeChallenge` response.') + + const credential = Credential.deserialize(response.authorization) + if (Challenge.serialize(credential.challenge) !== serializedChallenge) + throw new Error('wallet_authorizeChallenge returned a credential for a different challenge.') + + return response.authorization +} + +export declare namespace authorize { + type Parameters = { + /** Account address the wallet should authorize the payment for. */ + account: Address + /** Chain ID to probe for MPP capability. */ + chainId: number + /** Challenge from the 402 response. */ + challenge: Challenge.Challenge + /** Positive-only memo of `account:chainId` keys that already passed the capability probe. */ + probeCache?: Map | undefined + } + + /** `undefined` when the wallet does not support `wallet_authorizeChallenge`. */ + type ReturnType = string | undefined +} + +/** Whether the capabilities advertise MPP support for the chain. */ +function supportsMpp(capabilities: Capabilities | null | undefined, chainId: Hex) { + if (!capabilities) return false + return Object.entries(capabilities).some( + ([id, capability]) => + id.toLowerCase() === chainId.toLowerCase() && capability?.mpp?.status === 'supported', + ) +} + +/** Whether an error signals the wallet does not implement the requested method. */ +function isUnsupported(error: unknown) { + if (!error || typeof error !== 'object') return false + const { code } = error as { code?: unknown } + return code === 4200 || code === -32601 +} diff --git a/src/tempo/session/client/Session.test.ts b/src/tempo/session/client/Session.test.ts index 0658c4f9..86674042 100644 --- a/src/tempo/session/client/Session.test.ts +++ b/src/tempo/session/client/Session.test.ts @@ -731,4 +731,111 @@ describe('precompile client session', () => { ), ).toBe(true) }) + + describe('wallet_authorizeChallenge', () => { + const walletAddress = '0x1111111111111111111111111111111111111111' as Address + + test('json-rpc accounts use a supporting wallet instead of local planning', async () => { + const challenge = makeChallenge() + const authorization = Credential.serialize({ + challenge, + payload: { hash: '0x1234', type: 'hash' }, + }) + const requests: { method: string }[] = [] + const walletClient = createClient({ + chain: { id: chainId } as never, + transport: custom({ + async request({ method }: { method: string }) { + requests.push({ method }) + if (method === 'wallet_getCapabilities') + return { '0xa5bf': { mpp: { status: 'supported' } } } + if (method === 'wallet_authorizeChallenge') return { authorization } + throw new Error(`unexpected rpc request: ${method}`) + }, + }), + }) + const method = session({ account: walletAddress, getClient: () => walletClient }) + + await expect(method.createCredential({ challenge, context: {} })).resolves.toBe(authorization) + expect(requests.map(({ method }) => method)).toEqual([ + 'wallet_getCapabilities', + 'wallet_authorizeChallenge', + ]) + }) + + test('probes a supporting wallet once per session instance', async () => { + const challenge = makeChallenge() + const authorization = Credential.serialize({ + challenge, + payload: { hash: '0x1234', type: 'hash' }, + }) + const requests: { method: string }[] = [] + const walletClient = createClient({ + chain: { id: chainId } as never, + transport: custom({ + async request({ method }: { method: string }) { + requests.push({ method }) + if (method === 'wallet_getCapabilities') + return { '0xa5bf': { mpp: { status: 'supported' } } } + if (method === 'wallet_authorizeChallenge') return { authorization } + throw new Error(`unexpected rpc request: ${method}`) + }, + }), + }) + const method = session({ account: walletAddress, getClient: () => walletClient }) + + await expect(method.createCredential({ challenge, context: {} })).resolves.toBe(authorization) + await expect(method.createCredential({ challenge, context: {} })).resolves.toBe(authorization) + expect(requests.map(({ method }) => method)).toEqual([ + 'wallet_getCapabilities', + 'wallet_authorizeChallenge', + 'wallet_authorizeChallenge', + ]) + }) + + test('caller-supplied channel context skips the wallet', async () => { + const walletDescriptor = { + ...descriptor, + payer: walletAddress, + authorizedSigner: walletAddress, + } satisfies Channel.ChannelDescriptor + const walletChannelId = Channel.computeId({ + ...walletDescriptor, + chainId, + escrow: tip20ChannelEscrow, + }) + const requests: { method: string }[] = [] + const walletClient = createClient({ + chain: { id: chainId } as never, + transport: custom({ + async request({ method }: { method: string }) { + requests.push({ method }) + if (method === 'eth_call') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannelState', + result: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 }, + }) + if (method === 'eth_signTypedData_v4') return `0x${'11'.repeat(64)}1b` + throw new Error(`unexpected rpc request: ${method}`) + }, + }), + }) + const method = session({ account: walletAddress, getClient: () => walletClient }) + + const payload = deserialize( + await method.createCredential({ + challenge: makeChallenge(), + context: { channelId: walletChannelId, descriptor: walletDescriptor }, + }), + ) + + expect(requests.map(({ method }) => method)).not.toContain('wallet_getCapabilities') + expect(requests.map(({ method }) => method)).not.toContain('wallet_authorizeChallenge') + expect(payload.action).toBe('voucher') + if (payload.action !== 'voucher') throw new Error('expected voucher') + expect(payload.channelId).toBe(walletChannelId) + expect(payload.cumulativeAmount).toBe('100') + }) + }) }) diff --git a/src/tempo/session/client/Session.ts b/src/tempo/session/client/Session.ts index 75bb588e..a7d2d051 100644 --- a/src/tempo/session/client/Session.ts +++ b/src/tempo/session/client/Session.ts @@ -6,6 +6,7 @@ import * as Method from '../../../Method.js' import * as Account from '../../../viem/Account.js' import * as Client from '../../../viem/Client.js' import * as defaults from '../../internal/defaults.js' +import * as Wallet from '../../internal/wallet.js' import * as Methods from '../../Methods.js' import { serializeCredential, type ChannelEntry } from './ChannelOps.js' import { sessionContextSchema } from './CredentialState.js' @@ -44,6 +45,9 @@ export function session(parameters: session.Parameters = {}) { const maxDeposit = maxDepositParameter !== undefined ? parseUnits(maxDepositParameter, decimals) : undefined const cache = createChannelCache(onChannelUpdate) + // Positive-only memo of accounts/chains that already passed the wallet MPP + // capability probe, so a supporting wallet is probed once per instance. + const probeCache = new Map() return Method.toClient(Methods.session, { canHandleChallenge({ challenge }) { @@ -62,6 +66,27 @@ export function session(parameters: session.Parameters = {}) { getClient, }) const account = getAccount(resolved.client, context) + + // Manual actions, caller-supplied channels, and challenges an opened + // local channel can serve stay local (the wallet would strand its deposit). + const manualContext = + context?.action !== undefined || + context?.descriptor !== undefined || + context?.channelId !== undefined + const openedLocally = cache.channels.get(resolved.key)?.opened + + // Wallet-native MPP: ask a JSON-RPC wallet to satisfy automatic-mode + // challenges via `wallet_authorizeChallenge` before falling back to local planning. + if (account.type === 'json-rpc' && !manualContext && !openedLocally) { + const authorization = await Wallet.authorize(resolved.client, { + account: account.address, + chainId: resolved.chainId, + challenge, + probeCache, + }) + if (authorization) return authorization + } + const payload = await executeCredentialPlan( planCredential({ account,