Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wallet-authorize-challenge-support.md
Original file line number Diff line number Diff line change
@@ -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.
73 changes: 72 additions & 1 deletion src/tempo/client/Charge.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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}`)
})
})
})
35 changes: 25 additions & 10 deletions src/tempo/client/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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, {
Expand All @@ -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']
Expand Down
145 changes: 145 additions & 0 deletions src/tempo/internal/wallet.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, true>()
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)] },
])
})
})
Loading
Loading