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/tempo-resolve-account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added generic Tempo account resolution for charge/session credentials and primitive session voucher signatures.
22 changes: 21 additions & 1 deletion src/client/Mppx.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Account } from 'viem'
import type { Account, Address } from 'viem'
import { describe, expectTypeOf, test } from 'vp/test'

import * as Challenge from '../Challenge.js'
Expand All @@ -9,6 +9,7 @@ import { tempo } from '../tempo/client/Methods.js'
import type * as AutoSwap from '../tempo/internal/auto-swap.js'
import * as Methods from '../tempo/Methods.js'
import * as z from '../zod.js'
import type { ResolveAccount } from './index.js'
import * as Fetch from './internal/Fetch.js'
import { session, sessionManager, sessionMethod } from './Methods.js'
import * as Mppx from './Mppx.js'
Expand Down Expand Up @@ -78,6 +79,25 @@ describe('create.Config', () => {
expectTypeOf(mppx.fetch).toBeFunction()
})

test('tempo common accepts one resolveAccount hook for charge and session', () => {
const resolveAccount: ResolveAccount = (info) => {
expectTypeOf(info).toHaveProperty('account')
expectTypeOf(info.chainId).toEqualTypeOf<number>()
if (info.operation.kind === 'executeCalls') {
expectTypeOf(info.operation.calls).toMatchTypeOf<
readonly { data: `0x${string}`; to: Address }[] | undefined
>()
}
if (info.operation.kind === 'authorizePaymentChannel') {
expectTypeOf(info.operation.authority).toEqualTypeOf<Address | undefined>()
}
return info.account
}

const methods = tempo({ account: {} as Account, resolveAccount })
expectTypeOf(methods).toMatchTypeOf<readonly Method.AnyClient[]>()
})

test('orderChallenges receives supported challenge candidates', () => {
const mppx = Mppx.create({
methods: [tempo({ account: {} as Account })],
Expand Down
7 changes: 7 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,12 @@ export {
type ChannelStore,
type JsonChannelKv,
} from '../tempo/session/client/ChannelStore.js'
export type { ChargeContext } from '../tempo/client/Charge.js'
export type {
ResolveAccount,
ResolveAccountCall,
ResolveAccountInfo,
ResolveAccountOperation,
} from '../tempo/client/ResolveAccount.js'
export * as Mppx from './Mppx.js'
export * as Transport from './Transport.js'
119 changes: 119 additions & 0 deletions src/tempo/client/Charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,122 @@ describe('tempo.charge client', () => {
expect(credential.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`)
})

test('resolveAccount selects the transaction account from executable calls', async () => {
vi.resetModules()
const selectedAccount = privateKeyToAccount(
'0x0000000000000000000000000000000000000000000000000000000000000002',
)
const chainId = 42431
const calls: charge.ResolveAccountInfo[] = []
const prepareTransactionRequest = vi.fn(async () => ({}))
const signTransaction = vi.fn(async () => '0xdeadbeef')
vi.doMock('viem/actions', () => ({
prepareTransactionRequest,
sendCallsSync: vi.fn(),
signTransaction,
signTypedData: vi.fn(),
}))

try {
const { charge: chargeWithMockedActions } = await import('./Charge.js')
const client = createClient({
account,
chain: tempoLocalnet,
transport: http('http://127.0.0.1'),
})
const method = chargeWithMockedActions({
account,
getClient: () => client,
resolveAccount(info) {
calls.push(info)
return selectedAccount
},
})

const credential = Credential.deserialize(
await method.createCredential({
challenge: createChallenge({ amount: '1', chainId, supportedModes: ['pull'] }),
context: {},
}),
)

expect(calls).toHaveLength(1)
expect(calls[0]!.account.address).toBe(account.address)
expect(calls[0]!.chainId).toBe(chainId)
expect(calls[0]!.operation.kind).toBe('executeCalls')
if (calls[0]!.operation.kind !== 'executeCalls') throw new Error('expected executeCalls')
expect(calls[0]!.operation.calls).toHaveLength(1)
expect(calls[0]!.operation.calls?.[0]?.to.toLowerCase()).toBe(currency.toLowerCase())
expect(prepareTransactionRequest).toHaveBeenCalledOnce()
expect(signTransaction).toHaveBeenCalledOnce()
expect(credential.payload).toEqual({ signature: '0xdeadbeef', type: 'transaction' })
expect(credential.source).toBe(`did:pkh:eip155:${chainId}:${selectedAccount.address}`)
} finally {
vi.doUnmock('viem/actions')
vi.resetModules()
}
})

test('resolveAccount omits executable calls when auto-swap routing is account-dependent', async () => {
vi.resetModules()
const selectedAccount = privateKeyToAccount(
'0x0000000000000000000000000000000000000000000000000000000000000002',
)
const chainId = 42431
const calls: charge.ResolveAccountInfo[] = []
const prepareTransactionRequest = vi.fn(async () => ({}))
const signTransaction = vi.fn(async () => '0xdeadbeef')
const findCalls = vi.fn(async (_client: unknown, _parameters: { account: string }) => undefined)
vi.doMock('viem/actions', () => ({
prepareTransactionRequest,
sendCallsSync: vi.fn(),
signTransaction,
signTypedData: vi.fn(),
}))
vi.doMock('../internal/auto-swap.js', () => ({
defaultCurrencies: [currency],
findCalls,
resolve: vi.fn(() => ({ tokenIn: [currency], slippage: 1 })),
}))

try {
const { charge: chargeWithMockedActions } = await import('./Charge.js')
const client = createClient({
account,
chain: tempoLocalnet,
transport: http('http://127.0.0.1'),
})
const method = chargeWithMockedActions({
account,
autoSwap: true,
getClient: () => client,
resolveAccount(info) {
calls.push(info)
return selectedAccount
},
})

const credential = Credential.deserialize(
await method.createCredential({
challenge: createChallenge({ amount: '1', chainId, supportedModes: ['pull'] }),
context: {},
}),
)

expect(calls).toHaveLength(1)
expect(calls[0]!.operation.kind).toBe('executeCalls')
if (calls[0]!.operation.kind !== 'executeCalls') throw new Error('expected executeCalls')
expect(calls[0]!.operation.calls).toBeUndefined()
expect(findCalls).toHaveBeenCalledOnce()
expect(findCalls.mock.calls[0]?.[1].account).toBe(selectedAccount.address)
expect(credential.payload).toEqual({ signature: '0xdeadbeef', type: 'transaction' })
} finally {
vi.doUnmock('viem/actions')
vi.doUnmock('../internal/auto-swap.js')
vi.resetModules()
}
})

test('zero-amount proof binds to the root payer for an access-key account', async () => {
vi.resetModules()
// Capture the typed data so we can assert what the proof commits to.
Expand Down Expand Up @@ -115,9 +231,11 @@ describe('tempo.charge client', () => {
chain: tempoLocalnet,
transport: http('http://127.0.0.1'),
})
const resolveAccount = vi.fn()
const method = chargeWithMockedActions({
account: accessKey,
getClient: () => client,
resolveAccount,
})

const credential = Credential.deserialize(
Expand All @@ -128,6 +246,7 @@ describe('tempo.charge client', () => {
)

expect(signTypedData).toHaveBeenCalledOnce()
expect(resolveAccount).not.toHaveBeenCalled()
expect(signedTypedData?.message.account).toBe(account.address)
expect(credential.payload).toEqual({ signature: '0xdeadbeef', type: 'proof' })
expect(credential.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`)
Expand Down
91 changes: 58 additions & 33 deletions src/tempo/client/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ import * as Charge_internal from '../internal/charge.js'
import * as defaults from '../internal/defaults.js'
import * as Proof from '../internal/proof.js'
import * as Methods from '../Methods.js'
import type * as AccountResolution from './ResolveAccount.js'

/** Runtime context accepted by the Tempo charge client method. */
export type ChargeContext = {
account?: Account.getResolver.Parameters['account'] | undefined
autoSwap?: AutoSwap.resolve.Value | undefined
mode?: Methods.ChargeMode | undefined
}

const chargeContextSchema = z.object({
account: z.optional(z.custom<ChargeContext['account']>()),
autoSwap: z.optional(z.custom<ChargeContext['autoSwap']>()),
mode: z.optional(z.enum(Methods.chargeModes)),
})

/**
* Creates a Tempo charge method intent for usage on the client.
Expand All @@ -44,11 +58,7 @@ export function charge(parameters: charge.Parameters = {}) {
const getAccount = Account.getResolver({ account: parameters.account })

return Method.toClient(Methods.charge, {
context: z.object({
account: z.optional(z.custom<Account.getResolver.Parameters['account']>()),
autoSwap: z.optional(z.custom<charge.AutoSwap>()),
mode: z.optional(z.enum(Methods.chargeModes)),
}),
context: chargeContextSchema,

async createCredential({ challenge, context }) {
// Chain pinning: reject a challenge whose chain ID conflicts with the
Expand All @@ -68,19 +78,21 @@ export function charge(parameters: charge.Parameters = {}) {
if (chainId === undefined)
throw new Error('No `chainId` provided. Pass a chain ID in the challenge or client.')

const account = getAccount(client, context)

const { request } = challenge
const { amount, methodDetails } = request
const supportedModes = (methodDetails?.supportedModes as
| readonly Methods.ChargeMode[]
| undefined) ?? ['pull', 'push']
const defaultAccount = getAccount(client, context)

// Zero-amount: sign EIP-712 typed data instead of creating a transaction.
if (BigInt(amount) === 0n) {
const signature = await signTypedData(client, {
account,
account: defaultAccount,
// `account` here is the signing account; the proof's bound payer is
// `account.address` (echoed in the credential `source` below).
...Proof.typedData({
account: account.address,
account: defaultAccount.address,
chainId,
challengeId: challenge.id,
realm: challenge.realm,
Expand All @@ -89,7 +101,7 @@ export function charge(parameters: charge.Parameters = {}) {
return Credential.serialize({
challenge,
payload: { signature, type: 'proof' },
source: Proof.proofSource({ address: account.address, chainId }),
source: Proof.proofSource({ address: defaultAccount.address, chainId }),
})
}

Expand All @@ -104,22 +116,6 @@ export function charge(parameters: charge.Parameters = {}) {
}
}
}
const supportedModes = (methodDetails?.supportedModes as
| readonly Methods.ChargeMode[]
| undefined) ?? ['pull', 'push']
const mode = (() => {
const explicitMode = context?.mode ?? parameters.mode
if (explicitMode) {
if (!supportedModes.includes(explicitMode))
throw new Error(`Challenge does not support ${explicitMode} mode.`)
return explicitMode
}

const preferredMode = account.type === 'json-rpc' ? 'push' : 'pull'
if (supportedModes.includes(preferredMode)) return preferredMode
return supportedModes[0]!
})()

const memo = methodDetails?.memo
? (methodDetails.memo as Hex.Hex)
: Attribution.encode({ challengeId: challenge.id, clientId, serverId: challenge.realm })
Expand All @@ -131,20 +127,44 @@ export function charge(parameters: charge.Parameters = {}) {
},
recipient: request.recipient as Address,
})
const transferCalls = transfers.map((transfer) =>
Actions.token.transfer.call({
amount: BigInt(transfer.amount),
...(transfer.memo && { memo: transfer.memo as Hex.Hex }),
to: transfer.recipient as Address,
token: currency,
}),
const transferCalls = transfers.map(
(transfer): AccountResolution.ResolveAccountCall =>
Actions.token.transfer.call({
amount: BigInt(transfer.amount),
...(transfer.memo && { memo: transfer.memo as Hex.Hex }),
to: transfer.recipient as Address,
token: currency,
}) as AccountResolution.ResolveAccountCall,
)

const autoSwap = AutoSwap.resolve(
context?.autoSwap ?? parameters.autoSwap,
AutoSwap.defaultCurrencies,
)

const account =
(await parameters.resolveAccount?.({
account: defaultAccount,
chainId,
operation: {
kind: 'executeCalls',
...(autoSwap ? {} : { calls: transferCalls }),
},
})) ?? defaultAccount

const mode = (() => {
const explicitMode = context?.mode ?? parameters.mode
if (explicitMode) {
if (!supportedModes.includes(explicitMode))
throw new Error(`Challenge does not support ${explicitMode} mode.`)
return explicitMode
}

const preferredMode = account.type === 'json-rpc' ? 'push' : 'pull'
if (supportedModes.includes(preferredMode)) return preferredMode
return supportedModes[0]!
})()

const swapCalls = autoSwap
? await AutoSwap.findCalls(client, {
account: account.address,
Expand Down Expand Up @@ -202,6 +222,9 @@ export function charge(parameters: charge.Parameters = {}) {

export declare namespace charge {
type AutoSwap = AutoSwap.resolve.Value
type Context = ChargeContext
type ResolveAccount = AccountResolution.ResolveAccount
type ResolveAccountInfo = AccountResolution.ResolveAccountInfo

type Parameters = {
/**
Expand Down Expand Up @@ -236,6 +259,8 @@ export declare namespace charge {
* @default `'push'` for JSON-RPC accounts, `'pull'` for local accounts.
*/
mode?: Methods.ChargeMode | undefined
/** Selects the account that signs this charge after the challenge and chain are known. */
resolveAccount?: ResolveAccount | undefined
} & Account.getResolver.Parameters &
Client.getResolver.Parameters
}
Loading
Loading