Skip to content
Draft
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/mpp-authorize-json-rpc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added wallet-native MPP authorization support for Tempo JSON-RPC accounts.
1 change: 1 addition & 0 deletions src/tempo/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type SessionChallenge = Challenge.Challenge<
>

export type ChannelEntry = {
authorizedSigner?: Address | undefined
channelId: Hex
salt: Hex
cumulativeAmount: bigint
Expand Down
2 changes: 2 additions & 0 deletions src/tempo/client/ChannelOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -196,6 +197,7 @@ export async function createOpenPayload(

return {
entry: {
authorizedSigner,
channelId,
salt,
cumulativeAmount: initialAmount,
Expand Down
65 changes: 64 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 } 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'

Expand All @@ -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<typeof Methods.charge.schema.request.parse>

Expand All @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/tempo/client/Charge.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
219 changes: 219 additions & 0 deletions src/tempo/client/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
return Challenge.from({
Expand All @@ -55,6 +56,224 @@ function makeChallenge(overrides?: Record<string, unknown>) {
}

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({
Expand Down
Loading
Loading