Skip to content
Closed
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/pluggable-channel-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': minor
---

Added a pluggable `channelStore` for persisting reusable payer session channels.
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.
19 changes: 19 additions & 0 deletions src/client/Mppx.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,24 @@ describe('create.Config', () => {
expectTypeOf(mppx.fetch).toBeFunction()
})

test('tempo common accepts one resolveAccount hook for charge and session', () => {
const resolveAccount: ResolveAccount = (info) => {
expectTypeOf(info.intent).toEqualTypeOf<'charge' | 'session'>()
if (info.intent === 'charge') {
expectTypeOf(info.request.amount).toEqualTypeOf<string>()
expectTypeOf(info.supportedModes).toMatchTypeOf<readonly Methods.ChargeMode[]>()
}
if (info.intent === 'session') {
if (info.entry) expectTypeOf(info.entry).toHaveProperty('descriptor')
if (info.recoverContext) expectTypeOf(info.recoverContext).toHaveProperty('descriptor')
}
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
14 changes: 14 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,19 @@ export {
stripe,
tempo,
} from './Methods.js'
export {
createChannelStore,
createJsonChannelStore,
entryKey,
type ChannelStore,
type JsonChannelKv,
} from '../tempo/session/client/ChannelStore.js'
export type {
ChargeContext,
ResolveAccount,
ResolveAccountInfo,
ResolveChargeAccountInfo,
ResolveSessionAccountInfo,
} from '../tempo/client/ResolveAccount.js'
export * as Mppx from './Mppx.js'
export * as Transport from './Transport.js'
37 changes: 37 additions & 0 deletions src/tempo/client/Charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,43 @@ describe('tempo.charge client', () => {
expect(credential.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`)
})

test('resolveAccount selects the proof account after challenge resolution', async () => {
const selectedAccount = privateKeyToAccount(
'0x0000000000000000000000000000000000000000000000000000000000000002',
)
const chainId = 42431
const calls: charge.ResolveAccountInfo[] = []
const client = createClient({
account,
chain: tempoLocalnet,
transport: http('http://127.0.0.1'),
})
const method = charge({
account,
getClient: () => client,
resolveAccount(info) {
if (info.intent !== 'charge') throw new Error('expected charge account resolution')
calls.push(info)
return selectedAccount
},
})

const credential = Credential.deserialize(
await method.createCredential({
challenge: createChallenge({ chainId }),
context: {},
}),
)

expect(calls).toHaveLength(1)
expect(calls[0]!.account.address).toBe(account.address)
expect(calls[0]!.chainId).toBe(chainId)
expect(calls[0]!.request.recipient).toBe(recipient)
expect(calls[0]!.supportedModes).toEqual(['pull', 'push'])
expect(credential.payload).toMatchObject({ type: 'proof' })
expect(credential.source).toBe(`did:pkh:eip155:${chainId}:${selectedAccount.address}`)
})

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
37 changes: 27 additions & 10 deletions src/tempo/client/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ 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'

const chargeContextSchema = 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)),
})

/**
* Creates a Tempo charge method intent for usage on the client.
Expand All @@ -44,11 +51,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,10 +71,22 @@ 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)
const account =
(await parameters.resolveAccount?.({
account: defaultAccount,
chainId,
challenge,
context,
intent: 'charge',
request,
supportedModes,
})) ?? defaultAccount

// Zero-amount: sign EIP-712 typed data instead of creating a transaction.
if (BigInt(amount) === 0n) {
Expand Down Expand Up @@ -104,9 +119,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) {
Expand Down Expand Up @@ -202,6 +214,9 @@ export function charge(parameters: charge.Parameters = {}) {

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

type Parameters = {
/**
Expand Down Expand Up @@ -236,6 +251,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
}
56 changes: 56 additions & 0 deletions src/tempo/client/ResolveAccount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Account as ViemAccount, Address } from 'viem'

import type * as Challenge from '../../Challenge.js'
import type { MaybePromise } from '../../internal/types.js'
import type * as Account from '../../viem/Account.js'
import type * as AutoSwap from '../internal/auto-swap.js'
import type * as Methods from '../Methods.js'
import type { ChannelEntry } from '../session/client/ChannelOps.js'
import type { DescriptorSessionContext, SessionContext } from '../session/client/CredentialState.js'

type ChargeRequest = ReturnType<typeof Methods.charge.schema.request.parse>
type SessionRequest = ReturnType<typeof Methods.session.schema.request.parse>

/** 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
}

type BaseInfo<intent extends 'charge' | 'session', request> = {
/** Default account resolved from method parameters and credential context. */
account: ViemAccount
/** EVM chain ID used for signing. */
chainId: number
/** Deserialized 402 challenge being answered. */
challenge: Challenge.Challenge<request, intent, 'tempo'>
/** Tempo payment intent being answered. */
intent: intent
}

/** Account-resolution details for a Tempo charge credential. */
export type ResolveChargeAccountInfo = BaseInfo<'charge', ChargeRequest> & {
context?: ChargeContext | undefined
request: ChargeRequest
supportedModes: readonly Methods.ChargeMode[]
}

/** Account-resolution details for a TIP-1034 session credential. */
export type ResolveSessionAccountInfo = BaseInfo<'session', SessionRequest> & {
context?: SessionContext | undefined
entry?: ChannelEntry | undefined
escrow: Address
key: string
payee: Address
payer: Address
recoverContext?: DescriptorSessionContext | undefined
request: SessionRequest
token: Address
}

/** Account-resolution details for Tempo client credentials. */
export type ResolveAccountInfo = ResolveChargeAccountInfo | ResolveSessionAccountInfo

/** Resolves the account that should sign a Tempo protocol credential. */
export type ResolveAccount = (info: ResolveAccountInfo) => MaybePromise<ViemAccount | undefined>
111 changes: 111 additions & 0 deletions src/tempo/session/client/ChannelStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { Address } from 'viem'

import type { MaybePromise } from '../../../internal/types.js'
import type { ChannelEntry } from './ChannelOps.js'

/** Store of reusable payer session channels keyed by payment scope. */
export type ChannelStore = {
/** Returns the channel cached for `key`, when present. */
get(key: string): MaybePromise<ChannelEntry | undefined>
/** Inserts or replaces a channel entry. */
set(entry: ChannelEntry): MaybePromise<void>
/** Removes the channel cached for `key`. */
delete(key: string): MaybePromise<void>
}

/** Channel persistence and update notification for credential results. */
export type ChannelSink = {
store: ChannelStore
notifyUpdate: (entry: ChannelEntry) => void
}

/** Returns the scope key for a reusable payer session channel. */
export function channelKey(scope: {
payee: Address
token: Address
escrow: Address
chainId: number
}): string {
const { payee, token, escrow, chainId } = scope
return `${payee.toLowerCase()}:${token.toLowerCase()}:${escrow.toLowerCase()}:${chainId}`
}

/** Returns the scope key for a stored channel entry. */
export function entryKey(entry: ChannelEntry): string {
return channelKey({
payee: entry.descriptor.payee,
token: entry.descriptor.token,
escrow: entry.escrow,
chainId: entry.chainId,
})
}

/** Creates the default in-memory {@link ChannelStore}. */
export function createChannelStore(): ChannelStore {
const channels = new Map<string, ChannelEntry>()
return {
get: (key) => channels.get(key),
set(entry) {
channels.set(entryKey(entry), entry)
},
delete(key) {
channels.delete(key)
},
} satisfies ChannelStore
}

/** JSON-safe projection of a {@link ChannelEntry}, with bigint amounts as decimal strings. */
export type StoredChannel = Omit<ChannelEntry, 'cumulativeAmount' | 'deposit'> & {
/** Cumulative voucher authorization in raw token units, as a decimal string. */
cumulativeAmount: string
/** Channel deposit in raw token units, as a decimal string. */
deposit: string
}

/** Converts a channel entry into its JSON-safe stored form. */
export function serializeEntry(entry: ChannelEntry): StoredChannel {
return {
...entry,
cumulativeAmount: entry.cumulativeAmount.toString(),
deposit: entry.deposit.toString(),
}
}

/** Restores a channel entry from its JSON-safe stored form. */
export function deserializeEntry(stored: StoredChannel): ChannelEntry {
return {
...stored,
cumulativeAmount: BigInt(stored.cumulativeAmount),
deposit: BigInt(stored.deposit),
}
}

/** Prefix for serialized channel entries persisted by {@link createJsonChannelStore}. */
const channelPrefix = 'chan:'

/** Plain string key-value backend a {@link createJsonChannelStore} persists into. */
export type JsonChannelKv = {
/** Returns the value stored at `key`, when present. */
get(key: string): MaybePromise<string | undefined>
/** Persists a `value` at `key`. */
set(key: string, value: string): MaybePromise<void>
/** Removes the value stored at `key`. */
delete(key: string): MaybePromise<void>
}

/** Wraps a string KV backend as a bigint-safe channel store. */
export function createJsonChannelStore(kv: JsonChannelKv): ChannelStore {
return {
async get(key) {
const value = await kv.get(channelPrefix + key)
if (value === undefined) return undefined
return deserializeEntry(JSON.parse(value) as StoredChannel)
},
async set(entry) {
await kv.set(channelPrefix + entryKey(entry), JSON.stringify(serializeEntry(entry)))
},
async delete(key) {
await kv.delete(channelPrefix + key)
},
} satisfies ChannelStore
}
Loading
Loading