From ad01478334a64e2b9f7490583cf3eac0948bde72 Mon Sep 17 00:00:00 2001 From: Tony D'Addeo Date: Thu, 28 May 2026 20:43:17 -0500 Subject: [PATCH 1/3] feat(tempo): add mpp authorize wallet fallback --- .changeset/mpp-authorize-json-rpc.md | 5 + src/tempo/Session.ts | 1 + src/tempo/client/ChannelOps.ts | 2 + src/tempo/client/Charge.test.ts | 65 +++++- src/tempo/client/Charge.ts | 10 + src/tempo/client/Session.test.ts | 219 ++++++++++++++++++ src/tempo/client/Session.ts | 205 +++++++++++++++- src/tempo/client/SessionManager.ts | 6 + .../client/internal/MppAuthorize.test.ts | 102 ++++++++ src/tempo/client/internal/MppAuthorize.ts | 89 +++++++ 10 files changed, 702 insertions(+), 2 deletions(-) create mode 100644 .changeset/mpp-authorize-json-rpc.md create mode 100644 src/tempo/client/internal/MppAuthorize.test.ts create mode 100644 src/tempo/client/internal/MppAuthorize.ts diff --git a/.changeset/mpp-authorize-json-rpc.md b/.changeset/mpp-authorize-json-rpc.md new file mode 100644 index 00000000..e2fbb60c --- /dev/null +++ b/.changeset/mpp-authorize-json-rpc.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added wallet-native MPP authorization support for Tempo JSON-RPC accounts. diff --git a/src/tempo/Session.ts b/src/tempo/Session.ts index 62c328cf..a65967a2 100644 --- a/src/tempo/Session.ts +++ b/src/tempo/Session.ts @@ -31,6 +31,7 @@ export type SessionChallenge = Challenge.Challenge< > export type ChannelEntry = { + authorizedSigner?: Address | undefined channelId: Hex salt: Hex cumulativeAmount: bigint diff --git a/src/tempo/client/ChannelOps.ts b/src/tempo/client/ChannelOps.ts index 7bad4e5c..355d98d0 100644 --- a/src/tempo/client/ChannelOps.ts +++ b/src/tempo/client/ChannelOps.ts @@ -26,6 +26,7 @@ import type { SessionCredentialPayload } from '../session/Types.js' import { signVoucher } from '../session/Voucher.js' export type ChannelEntry = { + authorizedSigner?: Address | undefined channelId: Hex.Hex salt: Hex.Hex cumulativeAmount: bigint @@ -196,6 +197,7 @@ export async function createOpenPayload( return { entry: { + authorizedSigner, channelId, salt, cumulativeAmount: initialAmount, diff --git a/src/tempo/client/Charge.test.ts b/src/tempo/client/Charge.test.ts index 2d845841..aec90107 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 } from 'viem' +import { createClient, type Address } from 'viem' import { describe, expect, test } from 'vp/test' import { accounts, asset, chain, http } from '~test/tempo/viem.js' @@ -11,6 +11,8 @@ const otherAccount = accounts[2] const chainId = chain.id const currency = asset const recipient = '0x2222222222222222222222222222222222222222' +const jsonRpcAccount = '0x1111111111111111111111111111111111111111' as Address +const mppCapabilities = { '0xa5bf': { mpp: { status: 'supported' } } } type ChargeRequest = ReturnType @@ -34,6 +36,67 @@ function createChallenge( } describe('tempo.charge client', () => { + test('delegates JSON-RPC accounts to mpp_authorize when supported', async () => { + const challenge = createChallenge({ amount: '1', chainId: 42431 }) + const authorization = Credential.serialize({ + challenge, + payload: { hash: '0x1234', type: 'hash' }, + source: `did:pkh:eip155:42431:${jsonRpcAccount}`, + }) + const requests: unknown[] = [] + const method = charge({ + account: jsonRpcAccount, + getClient: () => + ({ + async request(parameters: { method: string }) { + requests.push(parameters) + if (parameters.method === 'wallet_getCapabilities') return mppCapabilities + return { authorization } + }, + }) as never, + }) + + const result = await method.createCredential({ challenge, context: {} }) + + expect(result).toBe(authorization) + expect(requests).toEqual([ + { + method: 'wallet_getCapabilities', + params: [jsonRpcAccount, ['0xa5bf']], + }, + { + method: 'mpp_authorize', + params: [{ challenges: [Challenge.serialize(challenge)] }], + }, + ]) + }) + + test('checks expected recipients before calling mpp_authorize', async () => { + const unexpected = '0x9999999999999999999999999999999999999999' as Address + const challenge = createChallenge({ + amount: '2', + chainId: 42431, + splits: [{ amount: '1', recipient: unexpected }], + }) + const requests: unknown[] = [] + const method = charge({ + account: jsonRpcAccount, + expectedRecipients: [recipient], + getClient: () => + ({ + async request(parameters: unknown) { + requests.push(parameters) + return { authorization: 'Payment invalid' } + }, + }) as never, + }) + + await expect(method.createCredential({ challenge, context: {} })).rejects.toThrow( + `Unexpected split recipient: ${unexpected}`, + ) + expect(requests).toEqual([]) + }) + test('behavior: uses client chain ID when challenge omits chainId', async () => { const client = createClient({ account, diff --git a/src/tempo/client/Charge.ts b/src/tempo/client/Charge.ts index 6c07e9c1..471d4488 100644 --- a/src/tempo/client/Charge.ts +++ b/src/tempo/client/Charge.ts @@ -1,6 +1,7 @@ import type { Address } from 'viem' import { tempo as tempo_chain } from 'viem/tempo/chains' +import type * as Challenge from '../../Challenge.js' import * as Method from '../../Method.js' import * as Account from '../../viem/Account.js' import * as Client from '../../viem/Client.js' @@ -9,6 +10,7 @@ import * as Charge from '../Charge.js' import * as AutoSwap from '../internal/auto-swap.js' import * as defaults from '../internal/defaults.js' import * as Methods from '../Methods.js' +import * as MppAuthorize from './internal/MppAuthorize.js' /** * Creates a Tempo charge method intent for usage on the client. @@ -49,6 +51,14 @@ export function charge(parameters: charge.Parameters = {}) { expectedRecipients: parameters.expectedRecipients, payer: account.address, }) + if (account.type === 'json-rpc') { + const authorization = await MppAuthorize.authorize(client, { + account: account.address, + challenge: challenge as Challenge.Challenge, + chainId: filled.chainId, + }) + if (authorization) return authorization + } return Charge.createCredential(client, { filled, mode: context?.mode ?? parameters.mode, diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index c4fbdbda..e251fbec 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -33,6 +33,7 @@ const pureClient = createClient({ const escrowAddress = escrowContractDefaults[chainId.testnet] as Address const recipient = '0x2222222222222222222222222222222222222222' as Address const currency = '0x3333333333333333333333333333333333333333' as Address +const capabilities = { '0xa5bf': { mpp: { status: 'supported' } } } function makeChallenge(overrides?: Record) { return Challenge.from({ @@ -55,6 +56,224 @@ function makeChallenge(overrides?: Record) { } describe('session (pure)', () => { + describe('mpp_authorize JSON-RPC account support', () => { + const channelId = '0x00000000000000000000000000000000000000000000000000000000000000aa' as Hex + const authorizedSigner = '0x4444444444444444444444444444444444444444' as Address + + test('opens through mpp_authorize without local deposit configuration', async () => { + const challenge = makeChallenge() + const authorization = Credential.serialize({ + challenge, + payload: { + action: 'open', + type: 'transaction', + channelId, + transaction: '0xdeadbeef', + authorizedSigner, + cumulativeAmount: '1000000', + signature: '0x1234', + }, + source: `did:pkh:eip155:42431:${pureAccount.address}`, + }) + const requests: unknown[] = [] + const method = session({ + account: pureAccount.address, + getClient: () => + ({ + async request(parameters: { method: string }) { + requests.push(parameters) + if (parameters.method === 'wallet_getCapabilities') return capabilities + return { authorization } + }, + }) as never, + }) + + const result = await method.createCredential({ challenge, context: {} }) + const credential = deserializePayload(result) + + expect(result).toBe(authorization) + expect(credential.payload.action).toBe('open') + expect(requests).toEqual([ + { + method: 'wallet_getCapabilities', + params: [pureAccount.address, ['0xa5bf']], + }, + { + method: 'mpp_authorize', + params: [{ challenges: [Challenge.serialize(challenge)] }], + }, + ]) + }) + + test('uses the authorized signer from a wallet-opened session for the next voucher', async () => { + const challenge = makeChallenge() + const openAuthorization = Credential.serialize({ + challenge, + payload: { + action: 'open', + type: 'transaction', + channelId, + transaction: '0xdeadbeef', + authorizedSigner, + cumulativeAmount: '1000000', + signature: '0x1234', + }, + source: `did:pkh:eip155:42431:${pureAccount.address}`, + }) + const voucherAuthorization = Credential.serialize({ + challenge, + payload: { + action: 'voucher', + channelId, + cumulativeAmount: '2000000', + signature: '0x5678', + }, + source: `did:pkh:eip155:42431:${pureAccount.address}`, + }) + const requests: { params: [{ session?: unknown }] }[] = [] + let authorizeRequests = 0 + const method = session({ + account: pureAccount.address, + getClient: () => + ({ + async request(parameters: { method: string; params: [{ session?: unknown }] }) { + requests.push(parameters) + if (parameters.method === 'wallet_getCapabilities') return capabilities + authorizeRequests++ + return authorizeRequests === 1 + ? { authorization: openAuthorization } + : { authorization: voucherAuthorization } + }, + }) as never, + }) + + await method.createCredential({ challenge, context: {} }) + const result = await method.createCredential({ challenge, context: {} }) + const credential = deserializePayload(result) + + expect(credential.payload.action).toBe('voucher') + expect(requests[3]!.params[0]!.session).toEqual({ + action: 'voucher', + authorizedSigner, + channelId, + cumulativeAmount: '2000000', + }) + }) + + test('passes additionalDeposit for top-up requests', async () => { + const challenge = makeChallenge() + const authorization = Credential.serialize({ + challenge, + payload: { + action: 'topUp', + type: 'transaction', + channelId, + transaction: '0xdeadbeef', + additionalDeposit: '3000000', + }, + source: `did:pkh:eip155:42431:${pureAccount.address}`, + }) + const requests: { params: [{ session?: unknown }] }[] = [] + const method = session({ + account: pureAccount.address, + getClient: () => + ({ + async request(parameters: { method: string; params: [{ session?: unknown }] }) { + requests.push(parameters) + if (parameters.method === 'wallet_getCapabilities') return capabilities + return { authorization } + }, + }) as never, + }) + + const result = await method.createCredential({ + challenge, + context: { + action: 'topUp', + additionalDepositRaw: '3000000', + authorizedSigner, + channelId, + }, + }) + + expect(result).toBe(authorization) + expect(requests[1]!.params[0]!.session).toEqual({ + action: 'topUp', + additionalDeposit: '3000000', + authorizedSigner, + channelId, + }) + }) + + test('rejects wallet-opened sessions without an authorized signer', async () => { + const challenge = makeChallenge() + const authorization = Credential.serialize({ + challenge, + payload: { + action: 'open', + type: 'transaction', + channelId, + transaction: '0xdeadbeef', + cumulativeAmount: '1000000', + signature: '0x1234', + }, + source: `did:pkh:eip155:42431:${pureAccount.address}`, + }) + const method = session({ + account: pureAccount.address, + getClient: () => + ({ + async request(parameters: { method: string }) { + if (parameters.method === 'wallet_getCapabilities') return capabilities + return { authorization } + }, + }) as never, + }) + + await expect(method.createCredential({ challenge, context: {} })).rejects.toThrow( + 'mpp_authorize returned an open credential without authorizedSigner.', + ) + }) + + test('rejects explicit session credentials for a different channel', async () => { + const challenge = makeChallenge() + const otherChannelId = + '0x00000000000000000000000000000000000000000000000000000000000000bb' as Hex + const authorization = Credential.serialize({ + challenge, + payload: { + action: 'voucher', + channelId: otherChannelId, + cumulativeAmount: '2000000', + signature: '0x5678', + }, + source: `did:pkh:eip155:42431:${pureAccount.address}`, + }) + const method = session({ + account: pureAccount.address, + getClient: () => + ({ + async request(parameters: { method: string }) { + if (parameters.method === 'wallet_getCapabilities') return capabilities + return { authorization } + }, + }) as never, + }) + + await expect( + method.createCredential({ + challenge, + context: { + action: 'voucher', + authorizedSigner, + channelId, + cumulativeAmountRaw: '2000000', + }, + }), + ).rejects.toThrow('mpp_authorize returned a credential for a different session channel.') + }) + }) + describe('error: no action and no deposit/maxDeposit', () => { test('throws when neither configured', async () => { const method = session({ diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index f1349f0d..d9a84742 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -1,5 +1,10 @@ import type { Hex } from 'ox' -import { type Address, parseUnits, type Account as viem_Account } from 'viem' +import { + type Address, + type Client as viem_Client, + parseUnits, + type Account as viem_Account, +} from 'viem' import { tempo as tempo_chain } from 'viem/tempo/chains' import type * as Challenge from '../../Challenge.js' @@ -15,10 +20,12 @@ import * as SessionActions from '../Session.js' import type { SessionCredentialPayload } from '../session/Types.js' import { signVoucher } from '../session/Voucher.js' import { resolveEscrow, tryRecoverChannel } from './ChannelOps.js' +import * as MppAuthorize from './internal/MppAuthorize.js' export const sessionContextSchema = z.object({ account: z.optional(z.custom()), action: z.optional(z.enum(['open', 'topUp', 'voucher', 'close'])), + authorizedSigner: z.optional(z.string()), channelId: z.optional(z.string()), cumulativeAmount: z.optional(z.amount()), cumulativeAmountRaw: z.optional(z.string()), @@ -119,6 +126,194 @@ export function session(parameters: session.Parameters = {}) { }) } + /** + * Mirrors wallet-created session credentials into the local channel cache. + * Only wallet-opened sessions create cache entries; continuation credentials + * update known entries but never reconstruct unknown channel state. + */ + function applyMppAuthorizedCredential( + challenge: Challenge.Challenge, + authorization: string, + chainId: number, + expectedSession?: MppAuthorize.Session | undefined, + ) { + const credential = Credential.deserialize(authorization) + if (credential.challenge.id !== challenge.id) { + throw new Error('mpp_authorize returned a credential for a different challenge.') + } + + const payload = credential.payload + if (!payload || typeof payload !== 'object' || !('action' in payload)) { + throw new Error('mpp_authorize returned a non-session credential.') + } + + if (expectedSession) { + if (payload.action !== expectedSession.action) { + throw new Error('mpp_authorize returned a credential for a different session action.') + } + if (payload.channelId.toLowerCase() !== expectedSession.channelId.toLowerCase()) { + throw new Error('mpp_authorize returned a credential for a different session channel.') + } + + switch (expectedSession.action) { + case 'voucher': + case 'close': + if (payload.action !== 'voucher' && payload.action !== 'close') return + if (payload.cumulativeAmount !== expectedSession.cumulativeAmount) { + throw new Error( + 'mpp_authorize returned a credential for a different cumulative amount.', + ) + } + break + + case 'topUp': + if (payload.action !== 'topUp') return + if (payload.additionalDeposit !== expectedSession.additionalDeposit) { + throw new Error('mpp_authorize returned a credential for a different top-up amount.') + } + } + } + + if (payload.action === 'topUp') return + + const channelId = payload.channelId + const escrowContract = resolveEscrowCached(challenge, chainId, channelId) + const key = channelKey( + challenge.request.recipient as Address, + challenge.request.currency as Address, + escrowContract, + ) + + if (payload.action === 'open') { + if (!payload.authorizedSigner) { + throw new Error('mpp_authorize returned an open credential without authorizedSigner.') + } + + const entry = { + authorizedSigner: payload.authorizedSigner, + channelId, + salt: '0x' as Hex.Hex, + cumulativeAmount: BigInt(payload.cumulativeAmount), + escrowContract, + chainId, + opened: true, + } satisfies SessionActions.ChannelEntry + channels.set(key, entry) + channelIdToKey.set(channelId, key) + escrowContractMap.set(channelId, escrowContract) + notifyUpdate(entry) + return + } + + if (payload.action === 'voucher' || payload.action === 'close') { + const entry = channels.get(key) + if (!entry) return + entry.cumulativeAmount = + entry.cumulativeAmount > BigInt(payload.cumulativeAmount) + ? entry.cumulativeAmount + : BigInt(payload.cumulativeAmount) + entry.opened = payload.action !== 'close' + channels.set(key, entry) + channelIdToKey.set(channelId, key) + escrowContractMap.set(channelId, escrowContract) + notifyUpdate(entry) + } + } + + /** + * Gives JSON-RPC wallets the first chance to create Tempo session credentials. + * Initial requests omit `session` so the wallet can open the channel; follow-up + * requests include the channel and signer discovered from the wallet response. + */ + async function tryMppAuthorizeCredential( + challenge: Challenge.Challenge, + client: viem_Client, + account: viem_Account, + context?: SessionContext, + ): Promise { + if (account.type !== 'json-rpc') return undefined + + const md = challenge.request.methodDetails as + | { chainId?: number; escrowContract?: string; channelId?: string } + | undefined + const chainId = md?.chainId ?? 0 + const resolveKnownAuthorizedSigner = (channelId: string) => { + const key = channelIdToKey.get(channelId) + const entry = key ? channels.get(key) : undefined + return (context?.authorizedSigner as Address | undefined) ?? entry?.authorizedSigner + } + + let session: MppAuthorize.Session | undefined + + if (context?.action === 'voucher' || context?.action === 'close') { + if (!context.channelId) return undefined + const cumulativeAmount = context.cumulativeAmountRaw + ? BigInt(context.cumulativeAmountRaw) + : context.cumulativeAmount + ? parseUnits(context.cumulativeAmount, decimals) + : undefined + if (cumulativeAmount === undefined) return undefined + + const authorizedSigner = resolveKnownAuthorizedSigner(context.channelId) + if (!authorizedSigner) return undefined + + session = { + action: context.action, + authorizedSigner, + channelId: context.channelId as Hex.Hex, + cumulativeAmount: cumulativeAmount.toString(), + } + } else if (context?.action === 'topUp') { + if (!context.channelId) return undefined + const additionalDeposit = context.additionalDepositRaw + ? BigInt(context.additionalDepositRaw) + : context.additionalDeposit + ? parseUnits(context.additionalDeposit, decimals) + : undefined + if (additionalDeposit === undefined) return undefined + + const authorizedSigner = resolveKnownAuthorizedSigner(context.channelId) + if (!authorizedSigner) return undefined + + session = { + action: 'topUp', + additionalDeposit: additionalDeposit.toString(), + authorizedSigner, + channelId: context.channelId as Hex.Hex, + } + } else if (!context?.action) { + const escrowContract = resolveEscrowCached(challenge, chainId) + const key = channelKey( + challenge.request.recipient as Address, + challenge.request.currency as Address, + escrowContract, + ) + const entry = channels.get(key) + if (entry?.opened && entry.authorizedSigner) { + session = { + action: 'voucher', + authorizedSigner: entry.authorizedSigner, + channelId: entry.channelId, + cumulativeAmount: ( + entry.cumulativeAmount + BigInt(challenge.request.amount as string) + ).toString(), + } + } + } + + if (context?.action && !session) return undefined + + const authorization = await MppAuthorize.authorize(client, { + account: account.address, + challenge, + chainId, + ...(session ? { session } : {}), + }) + if (!authorization) return undefined + applyMppAuthorizedCredential(challenge, authorization, chainId, session) + return authorization + } + async function autoManageCredential( challenge: Challenge.Challenge, account: viem_Account, @@ -358,6 +553,14 @@ export function session(parameters: session.Parameters = {}) { const client = await getClient({ chainId }) const account = getAccount(client, context) + const authorization = await tryMppAuthorizeCredential( + challenge as Challenge.Challenge, + client, + account, + context, + ) + if (authorization) return authorization + if (!context?.action && (parameters.deposit !== undefined || maxDeposit !== undefined)) return autoManageCredential(challenge, account, context) diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index f12ff2c4..dc473a53 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -498,6 +498,9 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa challenge: sseChallenge as never, context: { action: 'voucher', + ...(channel.authorizedSigner && { + authorizedSigner: channel.authorizedSigner, + }), channelId: channel.channelId, cumulativeAmountRaw: channel.cumulativeAmount.toString(), }, @@ -690,6 +693,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa challenge: challenge as never, context: { action: 'voucher', + ...(channel?.authorizedSigner && { authorizedSigner: channel.authorizedSigner }), channelId: activeSocketChannelId, cumulativeAmountRaw: nextCumulative.toString(), }, @@ -769,6 +773,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa challenge: closeChallenge as never, context: { action: 'close', + ...(channel.authorizedSigner && { authorizedSigner: channel.authorizedSigner }), channelId: closeChannelId, cumulativeAmountRaw: readySpent.toString(), }, @@ -799,6 +804,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa challenge: closeChallenge as never, context: { action: 'close', + ...(channel.authorizedSigner && { authorizedSigner: channel.authorizedSigner }), channelId: closeChannelId, cumulativeAmountRaw: (() => { const closeAmount = BigInt(getFallbackCloseAmount(closeChallenge, closeChannelId)) diff --git a/src/tempo/client/internal/MppAuthorize.test.ts b/src/tempo/client/internal/MppAuthorize.test.ts new file mode 100644 index 00000000..db81eaf7 --- /dev/null +++ b/src/tempo/client/internal/MppAuthorize.test.ts @@ -0,0 +1,102 @@ +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 MppAuthorize from './MppAuthorize.js' + +const account = '0x1111111111111111111111111111111111111111' as Address +const chainId = 42431 +const capabilities = { '0xa5bf': { mpp: { status: 'supported' } } } + +function makeChallenge() { + return Challenge.from({ + id: 'test-challenge', + realm: 'example.com', + method: 'tempo', + intent: 'charge', + request: { + amount: '1000000', + currency: '0x3333333333333333333333333333333333333333', + methodDetails: { chainId }, + recipient: '0x2222222222222222222222222222222222222222', + }, + }) +} + +describe('mpp_authorize helper', () => { + test('returns authorization from a supported wallet RPC', async () => { + const challenge = makeChallenge() + const authorization = Credential.serialize({ + challenge, + payload: { hash: '0x1234', type: 'hash' }, + }) + const requests: unknown[] = [] + const client = { + async request(parameters: { method: string }) { + requests.push(parameters) + if (parameters.method === 'wallet_getCapabilities') return capabilities + return { authorization } + }, + } as never + + const result = await MppAuthorize.authorize(client, { account, challenge, chainId }) + + expect(result).toBe(authorization) + expect(requests).toEqual([ + { + method: 'wallet_getCapabilities', + params: [account, ['0xa5bf']], + }, + { + method: 'mpp_authorize', + params: [{ challenges: [Challenge.serialize(challenge)] }], + }, + ]) + }) + + test('returns undefined when mpp is not advertised', async () => { + const requests: unknown[] = [] + const client = { + async request(parameters: unknown) { + requests.push(parameters) + return { '0xa5bf': { mpp: { status: 'unsupported' } } } + }, + } as never + + await expect( + MppAuthorize.authorize(client, { account, challenge: makeChallenge(), chainId }), + ).resolves.toBe(undefined) + expect(requests).toEqual([ + { + method: 'wallet_getCapabilities', + params: [account, ['0xa5bf']], + }, + ]) + }) + + test('returns undefined when wallet_getCapabilities is unsupported', async () => { + const client = { + async request() { + throw Object.assign(new Error('unsupported'), { code: 4200 }) + }, + } as never + + await expect( + MppAuthorize.authorize(client, { account, challenge: makeChallenge(), chainId }), + ).resolves.toBe(undefined) + }) + + test('returns undefined when mpp_authorize is unsupported', async () => { + const client = { + async request(parameters: { method: string }) { + if (parameters.method === 'wallet_getCapabilities') return capabilities + throw Object.assign(new Error('unsupported'), { code: 4200 }) + }, + } as never + + await expect( + MppAuthorize.authorize(client, { account, challenge: makeChallenge(), chainId }), + ).resolves.toBe(undefined) + }) +}) diff --git a/src/tempo/client/internal/MppAuthorize.ts b/src/tempo/client/internal/MppAuthorize.ts new file mode 100644 index 00000000..e4129843 --- /dev/null +++ b/src/tempo/client/internal/MppAuthorize.ts @@ -0,0 +1,89 @@ +import { type Address, type Client, type Hex, numberToHex } from 'viem' + +import * as Challenge from '../../../Challenge.js' +import * as Credential from '../../../Credential.js' + +export type Session = + | { + action: 'close' | 'voucher' + authorizedSigner: Address + channelId: Hex + cumulativeAmount: string + } + | { + action: 'topUp' + additionalDeposit: string + authorizedSigner: Address + channelId: Hex + } + +export type Response = { + authorization: string +} + +/** + * Requests a wallet-native MPP authorization credential from a JSON-RPC client. + * + * Returns `undefined` when the wallet does not support `mpp_authorize`, allowing + * callers to fall back to the existing lower-level signing path. + */ +export async function authorize( + client: Client, + parameters: { + account: Address + challenge: Challenge.Challenge + chainId?: number | undefined + session?: Session | undefined + }, +): Promise { + const chainId = parameters.chainId ? numberToHex(parameters.chainId) : undefined + + try { + const capabilities = (await client.request({ + method: 'wallet_getCapabilities', + params: chainId ? [parameters.account, [chainId]] : [parameters.account], + } as never)) as Record + if (!capabilities || typeof capabilities !== 'object') return undefined + + const supported = chainId + ? Object.entries(capabilities).find(([id]) => id.toLowerCase() === chainId.toLowerCase())?.[1] + ?.mpp?.status === 'supported' + : Object.values(capabilities).some((entry) => entry.mpp?.status === 'supported') + if (!supported) return undefined + } catch (error) { + if (isUnsupported(error)) return undefined + throw error + } + + try { + const result = (await client.request({ + method: 'mpp_authorize', + params: [ + { + challenges: [Challenge.serialize(parameters.challenge)], + ...(parameters.session ? { session: parameters.session } : {}), + }, + ], + } as never)) as Response + + if (!result || typeof result.authorization !== 'string') { + throw new Error('Invalid mpp_authorize response.') + } + + const credential = Credential.deserialize(result.authorization) + if (Challenge.serialize(credential.challenge) !== Challenge.serialize(parameters.challenge)) { + throw new Error('mpp_authorize returned a credential for a different challenge.') + } + + return result.authorization + } catch (error) { + if (isUnsupported(error)) return undefined + throw error + } +} + +function isUnsupported(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const code = (error as { code?: unknown }).code + return code === 4200 || code === -32601 +} From 04fb20d7a5ccc810a05726e5a37cd4ceb8ed68fc Mon Sep 17 00:00:00 2001 From: Tony D'Addeo Date: Fri, 29 May 2026 13:56:56 -0500 Subject: [PATCH 2/3] feat(tempo): shim subscription access key authorization --- src/tempo/client/Subscription.test.ts | 51 +++++++++++++ src/tempo/client/Subscription.ts | 18 ++++- .../client/internal/MppAuthorize.test.ts | 47 ++++++++++++ src/tempo/client/internal/MppAuthorize.ts | 74 +++++++++++++++++++ 4 files changed, 186 insertions(+), 4 deletions(-) diff --git a/src/tempo/client/Subscription.test.ts b/src/tempo/client/Subscription.test.ts index 0f9f39b7..36cf5938 100644 --- a/src/tempo/client/Subscription.test.ts +++ b/src/tempo/client/Subscription.test.ts @@ -23,6 +23,7 @@ const accessAccount = privateKeyToAccount( const otherRootAccount = privateKeyToAccount( '0x0000000000000000000000000000000000000000000000000000000000000003', ) +const capabilities = { '0x1079': { mpp: { status: 'supported' } } } const accessKey = { accessKeyAddress: accessAccount.address, keyType: 'secp256k1', @@ -107,6 +108,56 @@ describe('tempo.subscription client', () => { ) }) + test('can shim subscription access key selection through mpp_authorize', async () => { + const challenge = createChallenge({ accessKey: undefined }) + const keyAuthorization = await signSubscriptionKeyAuthorization({ + accessKey, + account: selectedAccount, + chainId, + request: challenge.request, + }) + if (!keyAuthorization) throw new Error('expected key authorization') + + const requests: unknown[] = [] + const method = subscription({ + account: selectedAccount.address, + getClient: async () => + ({ + request: async (request: { method: string }) => { + requests.push(request) + if (request.method === 'wallet_getCapabilities') return capabilities + if (request.method === 'mpp_authorize') return { subscriptionAccessKey: accessKey } + return { keyAuthorization: KeyAuthorization.toRpc(keyAuthorization) } + }, + }) as never, + }) + + const credential = Credential.deserialize( + await method.createCredential({ challenge, context: {} }), + ) + const payload = Methods.subscription.schema.credential.payload.parse(credential.payload) + + expect(payload.type).toBe('keyAuthorization') + expect(requests).toMatchObject([ + { + method: 'wallet_getCapabilities', + params: [selectedAccount.address, ['0x1079']], + }, + { + method: 'mpp_authorize', + params: [ + { + challenges: [Challenge.serialize(challenge)], + intent: 'subscriptionAccessKey', + }, + ], + }, + { + method: 'wallet_authorizeAccessKey', + }, + ]) + }) + test('passes hex-encoded `scopes` and `limits` to wallet_authorizeAccessKey', async () => { const challenge = createChallenge() const keyAuthorization = await signSubscriptionKeyAuthorization({ diff --git a/src/tempo/client/Subscription.ts b/src/tempo/client/Subscription.ts index d82b0a55..53f353dd 100644 --- a/src/tempo/client/Subscription.ts +++ b/src/tempo/client/Subscription.ts @@ -20,6 +20,7 @@ import { verifySubscriptionKeyAuthorization, } from '../subscription/KeyAuthorization.js' import type { SubscriptionAccessKey } from '../subscription/Types.js' +import * as MppAuthorize from './internal/MppAuthorize.js' type SubscriptionRequest = ReturnType @@ -48,17 +49,26 @@ export function subscription(parameters: subscription.Parameters = {}) { const chainId = challenge.request.methodDetails?.chainId ?? defaults.chainId.testnet const client = await getClient({ chainId }) const account = getAccount(client, context) - const accessKey = + let accessKey = context?.accessKey ?? parameters.accessKey ?? challenge.request.methodDetails?.accessKey + + assertSubscriptionRequestRepresentable(challenge.request) + await parameters.validateRequest?.(challenge.request) + + if (!accessKey && account.type === 'json-rpc') { + accessKey = await MppAuthorize.authorizeSubscriptionAccessKey(client, { + account: account.address, + challenge, + chainId, + }) + } + if (!accessKey) { throw new Error( 'No `accessKey` provided. The subscription challenge must include `accessKey`, or the client must pass one to parameters/context.', ) } - assertSubscriptionRequestRepresentable(challenge.request) - await parameters.validateRequest?.(challenge.request) - const keyAuthorization = await authorizeAccessKey(client, { accessKey, account, diff --git a/src/tempo/client/internal/MppAuthorize.test.ts b/src/tempo/client/internal/MppAuthorize.test.ts index db81eaf7..80486d14 100644 --- a/src/tempo/client/internal/MppAuthorize.test.ts +++ b/src/tempo/client/internal/MppAuthorize.test.ts @@ -6,6 +6,7 @@ import * as Credential from '../../../Credential.js' import * as MppAuthorize from './MppAuthorize.js' const account = '0x1111111111111111111111111111111111111111' as Address +const accessKeyAddress = '0x4444444444444444444444444444444444444444' as Address const chainId = 42431 const capabilities = { '0xa5bf': { mpp: { status: 'supported' } } } @@ -99,4 +100,50 @@ describe('mpp_authorize helper', () => { MppAuthorize.authorize(client, { account, challenge: makeChallenge(), chainId }), ).resolves.toBe(undefined) }) + + test('returns subscription access key from demo shim response', async () => { + const challenge = Challenge.from({ + ...makeChallenge(), + intent: 'subscription', + }) + const requests: unknown[] = [] + const client = { + async request(parameters: { method: string }) { + requests.push(parameters) + if (parameters.method === 'wallet_getCapabilities') return capabilities + return { + subscriptionAccessKey: { + accessKeyAddress, + keyType: 'secp256k1', + }, + } + }, + } as never + + const result = await MppAuthorize.authorizeSubscriptionAccessKey(client, { + account, + challenge, + chainId, + }) + + expect(result).toEqual({ + accessKeyAddress, + keyType: 'secp256k1', + }) + expect(requests).toEqual([ + { + method: 'wallet_getCapabilities', + params: [account, ['0xa5bf']], + }, + { + method: 'mpp_authorize', + params: [ + { + challenges: [Challenge.serialize(challenge)], + intent: 'subscriptionAccessKey', + }, + ], + }, + ]) + }) }) diff --git a/src/tempo/client/internal/MppAuthorize.ts b/src/tempo/client/internal/MppAuthorize.ts index e4129843..33e83461 100644 --- a/src/tempo/client/internal/MppAuthorize.ts +++ b/src/tempo/client/internal/MppAuthorize.ts @@ -2,6 +2,7 @@ import { type Address, type Client, type Hex, numberToHex } from 'viem' import * as Challenge from '../../../Challenge.js' import * as Credential from '../../../Credential.js' +import type { SubscriptionAccessKey } from '../../subscription/Types.js' export type Session = | { @@ -21,6 +22,10 @@ export type Response = { authorization: string } +export type SubscriptionAccessKeyResponse = { + subscriptionAccessKey: SubscriptionAccessKey +} + /** * Requests a wallet-native MPP authorization credential from a JSON-RPC client. * @@ -82,6 +87,75 @@ export async function authorize( } } +/** + * Demo shim for wallets that choose the Tempo subscription access key. + * + * This intentionally does not create the subscription credential. It only lets + * a JSON-RPC wallet map an MPP subscription challenge to the access key the + * existing subscription client should authorize. + */ +export async function authorizeSubscriptionAccessKey( + client: Client, + parameters: { + account: Address + challenge: Challenge.Challenge + chainId?: number | undefined + }, +): Promise { + const chainId = parameters.chainId ? numberToHex(parameters.chainId) : undefined + + try { + const capabilities = (await client.request({ + method: 'wallet_getCapabilities', + params: chainId ? [parameters.account, [chainId]] : [parameters.account], + } as never)) as Record + if (!supportsMpp(capabilities, chainId)) return undefined + } catch (error) { + if (isUnsupported(error)) return undefined + throw error + } + + try { + const result = (await client.request({ + method: 'mpp_authorize', + params: [ + { + challenges: [Challenge.serialize(parameters.challenge)], + intent: 'subscriptionAccessKey', + }, + ], + } as never)) as SubscriptionAccessKeyResponse + + const accessKey = result?.subscriptionAccessKey + if ( + !accessKey || + typeof accessKey.accessKeyAddress !== 'string' || + !['p256', 'secp256k1', 'webAuthn'].includes(accessKey.keyType) + ) { + throw new Error('Invalid mpp_authorize subscription access key response.') + } + + return accessKey + } catch (error) { + if (isUnsupported(error)) return undefined + throw error + } +} + +function supportsMpp( + capabilities: Record | undefined, + chainId: Hex | undefined, +): boolean { + if (!capabilities || typeof capabilities !== 'object') return false + if (chainId) { + return ( + Object.entries(capabilities).find(([id]) => id.toLowerCase() === chainId.toLowerCase())?.[1] + ?.mpp?.status === 'supported' + ) + } + return Object.values(capabilities).some((entry) => entry.mpp?.status === 'supported') +} + function isUnsupported(error: unknown): boolean { if (!error || typeof error !== 'object') return false const code = (error as { code?: unknown }).code From b1de7b8adb67a0e738787679168b2fb371cb6e67 Mon Sep 17 00:00:00 2001 From: Tony D'Addeo Date: Fri, 29 May 2026 16:49:03 -0500 Subject: [PATCH 3/3] revert: remove subscription access key shim --- src/tempo/client/Subscription.test.ts | 51 ------------- src/tempo/client/Subscription.ts | 18 +---- .../client/internal/MppAuthorize.test.ts | 47 ------------ src/tempo/client/internal/MppAuthorize.ts | 74 ------------------- 4 files changed, 4 insertions(+), 186 deletions(-) diff --git a/src/tempo/client/Subscription.test.ts b/src/tempo/client/Subscription.test.ts index 36cf5938..0f9f39b7 100644 --- a/src/tempo/client/Subscription.test.ts +++ b/src/tempo/client/Subscription.test.ts @@ -23,7 +23,6 @@ const accessAccount = privateKeyToAccount( const otherRootAccount = privateKeyToAccount( '0x0000000000000000000000000000000000000000000000000000000000000003', ) -const capabilities = { '0x1079': { mpp: { status: 'supported' } } } const accessKey = { accessKeyAddress: accessAccount.address, keyType: 'secp256k1', @@ -108,56 +107,6 @@ describe('tempo.subscription client', () => { ) }) - test('can shim subscription access key selection through mpp_authorize', async () => { - const challenge = createChallenge({ accessKey: undefined }) - const keyAuthorization = await signSubscriptionKeyAuthorization({ - accessKey, - account: selectedAccount, - chainId, - request: challenge.request, - }) - if (!keyAuthorization) throw new Error('expected key authorization') - - const requests: unknown[] = [] - const method = subscription({ - account: selectedAccount.address, - getClient: async () => - ({ - request: async (request: { method: string }) => { - requests.push(request) - if (request.method === 'wallet_getCapabilities') return capabilities - if (request.method === 'mpp_authorize') return { subscriptionAccessKey: accessKey } - return { keyAuthorization: KeyAuthorization.toRpc(keyAuthorization) } - }, - }) as never, - }) - - const credential = Credential.deserialize( - await method.createCredential({ challenge, context: {} }), - ) - const payload = Methods.subscription.schema.credential.payload.parse(credential.payload) - - expect(payload.type).toBe('keyAuthorization') - expect(requests).toMatchObject([ - { - method: 'wallet_getCapabilities', - params: [selectedAccount.address, ['0x1079']], - }, - { - method: 'mpp_authorize', - params: [ - { - challenges: [Challenge.serialize(challenge)], - intent: 'subscriptionAccessKey', - }, - ], - }, - { - method: 'wallet_authorizeAccessKey', - }, - ]) - }) - test('passes hex-encoded `scopes` and `limits` to wallet_authorizeAccessKey', async () => { const challenge = createChallenge() const keyAuthorization = await signSubscriptionKeyAuthorization({ diff --git a/src/tempo/client/Subscription.ts b/src/tempo/client/Subscription.ts index 53f353dd..d82b0a55 100644 --- a/src/tempo/client/Subscription.ts +++ b/src/tempo/client/Subscription.ts @@ -20,7 +20,6 @@ import { verifySubscriptionKeyAuthorization, } from '../subscription/KeyAuthorization.js' import type { SubscriptionAccessKey } from '../subscription/Types.js' -import * as MppAuthorize from './internal/MppAuthorize.js' type SubscriptionRequest = ReturnType @@ -49,26 +48,17 @@ export function subscription(parameters: subscription.Parameters = {}) { const chainId = challenge.request.methodDetails?.chainId ?? defaults.chainId.testnet const client = await getClient({ chainId }) const account = getAccount(client, context) - let accessKey = + const accessKey = context?.accessKey ?? parameters.accessKey ?? challenge.request.methodDetails?.accessKey - - assertSubscriptionRequestRepresentable(challenge.request) - await parameters.validateRequest?.(challenge.request) - - if (!accessKey && account.type === 'json-rpc') { - accessKey = await MppAuthorize.authorizeSubscriptionAccessKey(client, { - account: account.address, - challenge, - chainId, - }) - } - if (!accessKey) { throw new Error( 'No `accessKey` provided. The subscription challenge must include `accessKey`, or the client must pass one to parameters/context.', ) } + assertSubscriptionRequestRepresentable(challenge.request) + await parameters.validateRequest?.(challenge.request) + const keyAuthorization = await authorizeAccessKey(client, { accessKey, account, diff --git a/src/tempo/client/internal/MppAuthorize.test.ts b/src/tempo/client/internal/MppAuthorize.test.ts index 80486d14..db81eaf7 100644 --- a/src/tempo/client/internal/MppAuthorize.test.ts +++ b/src/tempo/client/internal/MppAuthorize.test.ts @@ -6,7 +6,6 @@ import * as Credential from '../../../Credential.js' import * as MppAuthorize from './MppAuthorize.js' const account = '0x1111111111111111111111111111111111111111' as Address -const accessKeyAddress = '0x4444444444444444444444444444444444444444' as Address const chainId = 42431 const capabilities = { '0xa5bf': { mpp: { status: 'supported' } } } @@ -100,50 +99,4 @@ describe('mpp_authorize helper', () => { MppAuthorize.authorize(client, { account, challenge: makeChallenge(), chainId }), ).resolves.toBe(undefined) }) - - test('returns subscription access key from demo shim response', async () => { - const challenge = Challenge.from({ - ...makeChallenge(), - intent: 'subscription', - }) - const requests: unknown[] = [] - const client = { - async request(parameters: { method: string }) { - requests.push(parameters) - if (parameters.method === 'wallet_getCapabilities') return capabilities - return { - subscriptionAccessKey: { - accessKeyAddress, - keyType: 'secp256k1', - }, - } - }, - } as never - - const result = await MppAuthorize.authorizeSubscriptionAccessKey(client, { - account, - challenge, - chainId, - }) - - expect(result).toEqual({ - accessKeyAddress, - keyType: 'secp256k1', - }) - expect(requests).toEqual([ - { - method: 'wallet_getCapabilities', - params: [account, ['0xa5bf']], - }, - { - method: 'mpp_authorize', - params: [ - { - challenges: [Challenge.serialize(challenge)], - intent: 'subscriptionAccessKey', - }, - ], - }, - ]) - }) }) diff --git a/src/tempo/client/internal/MppAuthorize.ts b/src/tempo/client/internal/MppAuthorize.ts index 33e83461..e4129843 100644 --- a/src/tempo/client/internal/MppAuthorize.ts +++ b/src/tempo/client/internal/MppAuthorize.ts @@ -2,7 +2,6 @@ import { type Address, type Client, type Hex, numberToHex } from 'viem' import * as Challenge from '../../../Challenge.js' import * as Credential from '../../../Credential.js' -import type { SubscriptionAccessKey } from '../../subscription/Types.js' export type Session = | { @@ -22,10 +21,6 @@ export type Response = { authorization: string } -export type SubscriptionAccessKeyResponse = { - subscriptionAccessKey: SubscriptionAccessKey -} - /** * Requests a wallet-native MPP authorization credential from a JSON-RPC client. * @@ -87,75 +82,6 @@ export async function authorize( } } -/** - * Demo shim for wallets that choose the Tempo subscription access key. - * - * This intentionally does not create the subscription credential. It only lets - * a JSON-RPC wallet map an MPP subscription challenge to the access key the - * existing subscription client should authorize. - */ -export async function authorizeSubscriptionAccessKey( - client: Client, - parameters: { - account: Address - challenge: Challenge.Challenge - chainId?: number | undefined - }, -): Promise { - const chainId = parameters.chainId ? numberToHex(parameters.chainId) : undefined - - try { - const capabilities = (await client.request({ - method: 'wallet_getCapabilities', - params: chainId ? [parameters.account, [chainId]] : [parameters.account], - } as never)) as Record - if (!supportsMpp(capabilities, chainId)) return undefined - } catch (error) { - if (isUnsupported(error)) return undefined - throw error - } - - try { - const result = (await client.request({ - method: 'mpp_authorize', - params: [ - { - challenges: [Challenge.serialize(parameters.challenge)], - intent: 'subscriptionAccessKey', - }, - ], - } as never)) as SubscriptionAccessKeyResponse - - const accessKey = result?.subscriptionAccessKey - if ( - !accessKey || - typeof accessKey.accessKeyAddress !== 'string' || - !['p256', 'secp256k1', 'webAuthn'].includes(accessKey.keyType) - ) { - throw new Error('Invalid mpp_authorize subscription access key response.') - } - - return accessKey - } catch (error) { - if (isUnsupported(error)) return undefined - throw error - } -} - -function supportsMpp( - capabilities: Record | undefined, - chainId: Hex | undefined, -): boolean { - if (!capabilities || typeof capabilities !== 'object') return false - if (chainId) { - return ( - Object.entries(capabilities).find(([id]) => id.toLowerCase() === chainId.toLowerCase())?.[1] - ?.mpp?.status === 'supported' - ) - } - return Object.values(capabilities).some((entry) => entry.mpp?.status === 'supported') -} - function isUnsupported(error: unknown): boolean { if (!error || typeof error !== 'object') return false const code = (error as { code?: unknown }).code