diff --git a/.changeset/pluggable-channel-store.md b/.changeset/pluggable-channel-store.md new file mode 100644 index 00000000..7d32f1a6 --- /dev/null +++ b/.changeset/pluggable-channel-store.md @@ -0,0 +1,5 @@ +--- +'mppx': minor +--- + +Added a pluggable `channelStore` for persisting reusable payer session channels. diff --git a/.changeset/tempo-resolve-account.md b/.changeset/tempo-resolve-account.md new file mode 100644 index 00000000..8a222ac9 --- /dev/null +++ b/.changeset/tempo-resolve-account.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added generic Tempo account resolution for charge/session credentials and primitive session voucher signatures. diff --git a/src/client/Mppx.test-d.ts b/src/client/Mppx.test-d.ts index 45b84858..66d9bdb0 100644 --- a/src/client/Mppx.test-d.ts +++ b/src/client/Mppx.test-d.ts @@ -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' @@ -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() + expectTypeOf(info.supportedModes).toMatchTypeOf() + } + 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() + }) + test('orderChallenges receives supported challenge candidates', () => { const mppx = Mppx.create({ methods: [tempo({ account: {} as Account })], diff --git a/src/client/index.ts b/src/client/index.ts index f94845f1..0e4e0534 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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' diff --git a/src/tempo/client/Charge.test.ts b/src/tempo/client/Charge.test.ts index 72e3eede..f5e1a0eb 100644 --- a/src/tempo/client/Charge.test.ts +++ b/src/tempo/client/Charge.test.ts @@ -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. diff --git a/src/tempo/client/Charge.ts b/src/tempo/client/Charge.ts index 8ab218dd..833051e0 100644 --- a/src/tempo/client/Charge.ts +++ b/src/tempo/client/Charge.ts @@ -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()), + autoSwap: z.optional(z.custom()), + mode: z.optional(z.enum(Methods.chargeModes)), +}) /** * Creates a Tempo charge method intent for usage on the client. @@ -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()), - autoSwap: z.optional(z.custom()), - mode: z.optional(z.enum(Methods.chargeModes)), - }), + context: chargeContextSchema, async createCredential({ challenge, context }) { // Chain pinning: reject a challenge whose chain ID conflicts with the @@ -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) { @@ -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) { @@ -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 = { /** @@ -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 } diff --git a/src/tempo/client/ResolveAccount.ts b/src/tempo/client/ResolveAccount.ts new file mode 100644 index 00000000..51a87e7c --- /dev/null +++ b/src/tempo/client/ResolveAccount.ts @@ -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 +type SessionRequest = ReturnType + +/** 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 = { + /** 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 + /** 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 diff --git a/src/tempo/session/client/ChannelStore.ts b/src/tempo/session/client/ChannelStore.ts new file mode 100644 index 00000000..33a7cc36 --- /dev/null +++ b/src/tempo/session/client/ChannelStore.ts @@ -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 + /** Inserts or replaces a channel entry. */ + set(entry: ChannelEntry): MaybePromise + /** Removes the channel cached for `key`. */ + delete(key: string): MaybePromise +} + +/** 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() + 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 & { + /** 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 + /** Persists a `value` at `key`. */ + set(key: string, value: string): MaybePromise + /** Removes the value stored at `key`. */ + delete(key: string): MaybePromise +} + +/** 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 +} diff --git a/src/tempo/session/client/CredentialState.test.ts b/src/tempo/session/client/CredentialState.test.ts index 97ead3e2..847ddb54 100644 --- a/src/tempo/session/client/CredentialState.test.ts +++ b/src/tempo/session/client/CredentialState.test.ts @@ -11,7 +11,15 @@ import type { SessionSnapshot } from '../Snapshot.js' import type { ChannelEntry } from './ChannelOps.js' import { channelKey, - createChannelCache, + createChannelStore, + createJsonChannelStore, + deserializeEntry, + entryKey, + serializeEntry, + type ChannelSink, +} from './ChannelStore.js' +import { + canSignDescriptor, executeCredentialPlan, hasCredentialCumulativeAmount, hasManualSessionDescriptor, @@ -26,12 +34,15 @@ import { resolveRecoverContext, resolveReusableChannel, sessionContextSchema, - storeChannelEntry, - updateCachedCumulative, type ChallengeContext, type SessionContext, } from './CredentialState.js' +/** Builds a credential sink backed by a fresh in-memory store. */ +function sink(): ChannelSink { + return { store: createChannelStore(), notifyUpdate: () => {} } +} + describe('ChannelCache', () => { const channelId = `0x${'11'.repeat(32)}` as Hex @@ -66,16 +77,6 @@ describe('ChannelCache', () => { } } - function close(cumulativeAmount: string): SessionCredentialPayload { - return { - action: 'close', - channelId, - descriptor: channel().descriptor, - cumulativeAmount, - signature: '0x1234', - } - } - function topUp(additionalDeposit: string): SessionCredentialPayload { return { action: 'topUp', @@ -87,41 +88,51 @@ describe('ChannelCache', () => { } } - describe('precompile client ChannelCache', () => { - test('creates stable case-insensitive reusable channel keys', () => { + describe('precompile client ChannelStore', () => { + test('creates stable case-insensitive reusable channel keys scoped by chain', () => { expect( - channelKey( - '0x00000000000000000000000000000000000000AA' as Address, - '0x20C0000000000000000000000000000000000001' as Address, - '0x4D50500000000000000000000000000000000000' as Address, - ), + channelKey({ + payee: '0x00000000000000000000000000000000000000AA' as Address, + token: '0x20C0000000000000000000000000000000000001' as Address, + escrow: '0x4D50500000000000000000000000000000000000' as Address, + chainId: 4217, + }), ).toBe( - '0x00000000000000000000000000000000000000aa:0x20c0000000000000000000000000000000000001:0x4d50500000000000000000000000000000000000', + '0x00000000000000000000000000000000000000aa:0x20c0000000000000000000000000000000000001:0x4d50500000000000000000000000000000000000:4217', ) }) - test('stores entries by key and channel ID and notifies observers', () => { - const updates: ChannelEntry[] = [] - const cache = createChannelCache((entry) => updates.push(entry)) + test('derives a stored entry key from its descriptor, escrow, and chain', () => { const entry = channel() + expect(entryKey(entry)).toBe( + channelKey({ + payee: entry.descriptor.payee, + token: entry.descriptor.token, + escrow: entry.escrow, + chainId: entry.chainId, + }), + ) + }) + + test('stores, gets, and deletes entries by derived key', () => { + const store = createChannelStore() + const entry = channel() + store.set(entry) - storeChannelEntry(cache, 'payee:token:escrow', entry) + expect(store.get(entryKey(entry))).toBe(entry) - expect(cache.channels.get('payee:token:escrow')).toBe(entry) - expect(cache.channelIdToKey.get(channelId)).toBe('payee:token:escrow') - expect(updates).toEqual([entry]) + store.delete(entryKey(entry)) + expect(store.get(entryKey(entry))).toBeUndefined() }) - test('updates cached cumulative amounts monotonically', () => { - const cache = createChannelCache() - const entry = channel({ cumulativeAmount: 10n }) - storeChannelEntry(cache, 'payee:token:escrow', entry) - - updateCachedCumulative(cache, channelId, voucher('8')) - expect(entry.cumulativeAmount).toBe(10n) + test('replaces entries that share a scope key', () => { + const store = createChannelStore() + const first = channel({ cumulativeAmount: 10n }) + const second = channel({ cumulativeAmount: 12n }) + store.set(first) + store.set(second) - updateCachedCumulative(cache, channelId, voucher('12')) - expect(entry.cumulativeAmount).toBe(12n) + expect(store.get(entryKey(second))).toBe(second) }) test('reads cumulative amounts only from cumulative credential payloads', () => { @@ -130,26 +141,50 @@ describe('ChannelCache', () => { expect(hasCredentialCumulativeAmount(topUp('12'))).toBe(false) expect(readCredentialCumulativeAmount(topUp('12'))).toBeUndefined() }) + }) - test('ignores non-cumulative top-up credentials when updating cached cumulative amount', () => { - const cache = createChannelCache() - const entry = channel({ cumulativeAmount: 10n }) - storeChannelEntry(cache, 'payee:token:escrow', entry) - - updateCachedCumulative(cache, channelId, topUp('12')) + describe('serialization', () => { + test('serializes bigint amounts to decimal strings', () => { + const entry = channel({ cumulativeAmount: 2n ** 70n, deposit: 0n }) + const stored = serializeEntry(entry) + expect(stored.cumulativeAmount).toBe((2n ** 70n).toString()) + expect(stored.deposit).toBe('0') + }) - expect(entry.cumulativeAmount).toBe(10n) + test('round-trips a channel entry through JSON', () => { + const entry = channel({ cumulativeAmount: 2n ** 100n + 7n, deposit: 999n }) + const roundtrip = deserializeEntry( + JSON.parse(JSON.stringify(serializeEntry(entry))) as ReturnType, + ) + expect(roundtrip).toEqual(entry) }) + }) + + describe('createJsonChannelStore', () => { + function jsonStore() { + const backend = new Map() + const store = createJsonChannelStore({ + get: (key) => backend.get(key), + set: (key, value) => { + backend.set(key, value) + }, + delete: (key) => { + backend.delete(key) + }, + }) + return { backend, store } + } - test('marks cached channels closed from close credentials', () => { - const cache = createChannelCache() - const entry = channel({ opened: true }) - storeChannelEntry(cache, 'payee:token:escrow', entry) + test('persists, gets, and deletes via a string KV backend', async () => { + const { backend, store } = jsonStore() + const entry = channel({ cumulativeAmount: 2n ** 64n, deposit: 5n }) + await store.set(entry) - updateCachedCumulative(cache, channelId, close('12')) + expect(backend.size).toBe(1) + expect(await store.get(entryKey(entry))).toEqual(entry) - expect(entry.cumulativeAmount).toBe(12n) - expect(entry.opened).toBe(false) + await store.delete(entryKey(entry)) + expect(await store.get(entryKey(entry))).toBeUndefined() }) }) }) @@ -346,7 +381,7 @@ describe('CredentialPlan', () => { token, }) expect(resolved.key).toBe( - `${payee.toLowerCase()}:${token.toLowerCase()}:${escrow.toLowerCase()}`, + `${payee.toLowerCase()}:${token.toLowerCase()}:${escrow.toLowerCase()}:42431`, ) }) @@ -425,10 +460,9 @@ describe('CredentialPlan', () => { }) test('plans manual credentials only when an explicit action includes descriptor', () => { - const cache = createChannelCache() const plan = planCredential({ account, - cache, + entry: undefined, context: { action: 'voucher', descriptor, cumulativeAmountRaw: '10' }, decimals: 6, resolved: challengeContext(), @@ -443,7 +477,7 @@ describe('CredentialPlan', () => { expect(() => planCredential({ account, - cache: createChannelCache(), + entry: undefined, context: { action: 'voucher', cumulativeAmountRaw: '10' }, decimals: 6, resolved: challengeContext(), @@ -454,7 +488,7 @@ describe('CredentialPlan', () => { test('rejects manual descriptors that do not match the active challenge', async () => { const plan = planCredential({ account, - cache: createChannelCache(), + entry: undefined, context: { action: 'voucher', cumulativeAmountRaw: '10', @@ -467,7 +501,7 @@ describe('CredentialPlan', () => { resolved: challengeContext(), }) - await expect(executeCredentialPlan(plan, createChannelCache())).rejects.toThrow( + await expect(executeCredentialPlan(plan, sink())).rejects.toThrow( 'context descriptor payee does not match challenge', ) }) @@ -475,7 +509,7 @@ describe('CredentialPlan', () => { test('plans recovery from server snapshot when no reusable cache entry exists', () => { const plan = planCredential({ account, - cache: createChannelCache(), + entry: undefined, decimals: 6, resolved: challengeContext({ snapshot: snapshot() }), }) @@ -487,13 +521,11 @@ describe('CredentialPlan', () => { }) test('plans voucher reuse before snapshot recovery when cache entry is open', () => { - const cache = createChannelCache() const entry = channel() - storeChannelEntry(cache, 'payee:token:escrow', entry) const plan = planCredential({ account, - cache, + entry, decimals: 6, resolved: challengeContext({ snapshot: snapshot() }), }) @@ -501,11 +533,94 @@ describe('CredentialPlan', () => { expect(plan).toMatchObject({ type: 'voucher', entry }) }) + test('opens fresh instead of vouchering when the account cannot sign the cached entry', () => { + const entry = channel({ + descriptor: { + ...descriptor, + authorizedSigner: '0x00000000000000000000000000000000000000aa' as Address, + }, + }) + + const plan = planCredential({ + account, + entry, + decimals: 6, + resolved: challengeContext(), + }) + + expect(plan.type).toBe('open') + }) + + test('opens fresh instead of recovering when the account cannot sign the snapshot', () => { + const plan = planCredential({ + account, + entry: undefined, + decimals: 6, + resolved: challengeContext({ + snapshot: snapshot({ + descriptor: { + ...snapshotDescriptor, + authorizedSigner: '0x00000000000000000000000000000000000000aa' as Address, + }, + }), + }), + }) + + expect(plan.type).toBe('open') + }) + + test('vouchers when the account can satisfy the cached voucher authority', () => { + const delegatedAccount = privateKeyToAccount( + '0x2000000000000000000000000000000000000000000000000000000000000000', + ) + const entry = channel({ + descriptor: { ...descriptor, authorizedSigner: delegatedAccount.address }, + }) + + const plan = planCredential({ + account: Object.assign({}, account, { accessKeyAddress: delegatedAccount.address }), + entry, + decimals: 6, + resolved: challengeContext(), + }) + + expect(plan).toMatchObject({ type: 'voucher', entry }) + }) + + test('canSignDescriptor matches root, zero, and delegated authorities', () => { + const delegatedAuthority = '0x00000000000000000000000000000000000000aa' as Address + expect(canSignDescriptor(account, descriptor)).toBe(true) + expect( + canSignDescriptor(account, { + ...descriptor, + authorizedSigner: '0x0000000000000000000000000000000000000000' as Address, + }), + ).toBe(true) + expect( + canSignDescriptor(account, { ...descriptor, authorizedSigner: delegatedAuthority }), + ).toBe(false) + expect( + canSignDescriptor(Object.assign({}, account, { accessKeyAddress: delegatedAuthority }), { + ...descriptor, + authorizedSigner: delegatedAuthority, + }), + ).toBe(true) + const otherPayer = '0x00000000000000000000000000000000000000bb' as Address + expect(canSignDescriptor(account, { ...descriptor, payer: otherPayer })).toBe(false) + expect( + canSignDescriptor(Object.assign({}, account, { accessKeyAddress: delegatedAuthority }), { + ...descriptor, + payer: otherPayer, + authorizedSigner: delegatedAuthority, + }), + ).toBe(false) + }) + test('rejects channel ID reuse without descriptor or cache entry', () => { expect(() => planCredential({ account, - cache: createChannelCache(), + entry: undefined, context: { channelId }, decimals: 6, resolved: challengeContext(), diff --git a/src/tempo/session/client/CredentialState.ts b/src/tempo/session/client/CredentialState.ts index e99a3e68..a1891aed 100644 --- a/src/tempo/session/client/CredentialState.ts +++ b/src/tempo/session/client/CredentialState.ts @@ -25,6 +25,7 @@ import { resolveEscrow, type ChannelEntry, } from './ChannelOps.js' +import { channelKey, entryKey, type ChannelSink } from './ChannelStore.js' import { assertWithinMaxDeposit, resolveOpeningDeposit } from './Runtime.js' /** Credential payload variants that carry cumulative voucher authorization. */ @@ -33,39 +34,6 @@ export type CumulativeCredentialPayload = Extract< { cumulativeAmount: string } > -/** In-memory client channel cache used by automatic precompile session credential planning. */ -export type ChannelCache = { - /** Maps reusable channel keys to cached channel metadata. */ - channels: Map - /** Maps channel IDs back to reusable channel keys for manual credential cache updates. */ - channelIdToKey: Map - /** Emits cache updates to the public `onChannelUpdate` callback. */ - notifyUpdate(entry: ChannelEntry): void -} - -/** Creates an empty client channel cache with an optional update callback. */ -export function createChannelCache( - onUpdate?: ((entry: ChannelEntry) => void) | undefined, -): ChannelCache { - return { - channels: new Map(), - channelIdToKey: new Map(), - notifyUpdate: (entry) => onUpdate?.(entry), - } satisfies ChannelCache -} - -/** Returns the cache key for a reusable payer session channel. */ -export function channelKey(payee: Address, token: Address, escrow: Address): string { - return `${payee.toLowerCase()}:${token.toLowerCase()}:${escrow.toLowerCase()}` -} - -/** Stores a channel entry and notifies observers. */ -export function storeChannelEntry(cache: ChannelCache, key: string, entry: ChannelEntry): void { - cache.channels.set(key, entry) - cache.channelIdToKey.set(entry.channelId, key) - cache.notifyUpdate(entry) -} - /** Returns whether a credential payload carries cumulative voucher authorization. */ export function hasCredentialCumulativeAmount( payload: SessionCredentialPayload, @@ -81,21 +49,31 @@ export function readCredentialCumulativeAmount( return BigInt(payload.cumulativeAmount) } -/** Applies a credential payload's cumulative amount to an existing cached channel. */ -export function updateCachedCumulative( - cache: ChannelCache, - channelId: Hex, +/** + * Persists a channel entry through the sink and notifies observers. Closed + * channels are removed from the store but still reported to observers so callers + * can react to the close. + */ +async function storeChannelEntry(sink: ChannelSink, entry: ChannelEntry): Promise { + if (entry.opened) await sink.store.set(entry) + else await sink.store.delete(entryKey(entry)) + sink.notifyUpdate(entry) +} + +/** Applies a credential payload's cumulative amount to the stored channel at `key`. */ +async function applyCumulative( + sink: ChannelSink, + key: string, payload: SessionCredentialPayload, -): void { - const key = cache.channelIdToKey.get(channelId) +): Promise { const cumulativeAmount = readCredentialCumulativeAmount(payload) - if (!key || cumulativeAmount === undefined) return - const entry = cache.channels.get(key) + if (cumulativeAmount === undefined) return + const entry = await sink.store.get(key) if (!entry) return entry.cumulativeAmount = entry.cumulativeAmount > cumulativeAmount ? entry.cumulativeAmount : cumulativeAmount if (payload.action === 'close') entry.opened = false - cache.notifyUpdate(entry) + await storeChannelEntry(sink, entry) } const hexSchema = z.custom( @@ -301,7 +279,8 @@ export type ChallengeContext = { export type PlanCredentialParameters = { account: ViemAccount authorizedSigner?: Address | undefined - cache: ChannelCache + /** Channel previously stored for this challenge scope, fetched by the caller. */ + entry: ChannelEntry | undefined context?: SessionContext | undefined decimals: number maxDeposit?: bigint | undefined @@ -436,7 +415,7 @@ export async function resolveChallengeContext( client, escrow, feePayer: methodDetails.feePayer, - key: channelKey(payee, token, escrow), + key: channelKey({ payee, token, escrow, chainId }), operator: methodDetails.operator, payee, snapshot: methodDetails.sessionSnapshot, @@ -533,9 +512,22 @@ export function resolveRecoveredCumulative( return settled + requestAmount } +/** Returns whether `account` can satisfy the descriptor's voucher authority. */ +export function canSignDescriptor( + account: ViemAccount, + descriptor: Channel.ChannelDescriptor, + authorizedSigner?: Address | undefined, +): boolean { + // Only the payer can deposit into and voucher against its own channel. + if (!isSameAddress(account.address, descriptor.payer)) return false + const authority = descriptor.authorizedSigner + if (BigInt(authority) === 0n || isSameAddress(authority, descriptor.payer)) return true + return isSameAddress(resolveAuthorizedSigner(account, authorizedSigner), authority) +} + /** Chooses the next credential plan from local channel cache and optional caller context. */ export function planCredential(parameters: PlanCredentialParameters): CredentialPlan { - const { account, authorizedSigner, cache, context, decimals, maxDeposit, resolved } = parameters + const { account, authorizedSigner, entry, context, decimals, maxDeposit, resolved } = parameters if (hasSessionAction(context)) { if (!hasManualSessionDescriptor(context)) @@ -550,11 +542,14 @@ export function planCredential(parameters: PlanCredentialParameters): Credential } } - const entry = cache.channels.get(resolved.key) if (!entry && context?.channelId && !context.descriptor) throw new Error('descriptor required to reuse TIP-1034 channel') const recoverContext = resolveRecoverContext({ context, snapshot: resolved.snapshot }) - if (!entry && recoverContext) { + if ( + !entry && + recoverContext && + canSignDescriptor(account, recoverContext.descriptor, authorizedSigner) + ) { return { type: 'recover', account, @@ -565,30 +560,31 @@ export function planCredential(parameters: PlanCredentialParameters): Credential resolved, } } - if (entry?.opened) return { type: 'voucher', account, entry, maxDeposit, resolved } + if (entry?.opened && canSignDescriptor(account, entry.descriptor, authorizedSigner)) + return { type: 'voucher', account, entry, maxDeposit, resolved } return { type: 'open', account, authorizedSigner, context, maxDeposit, resolved } } /** Executes a credential plan and returns the concrete session credential payload. */ export async function executeCredentialPlan( plan: CredentialPlan, - cache: ChannelCache, + sink: ChannelSink, ): Promise { switch (plan.type) { case 'open': - return open(plan, cache) + return open(plan, sink) case 'recover': - return recover(plan, cache) + return recover(plan, sink) case 'voucher': - return voucher(plan, cache) + return voucher(plan, sink) case 'manual': - return manual(plan, cache) + return manual(plan, sink) } } async function open( plan: Extract, - cache: ChannelCache, + sink: ChannelSink, ): Promise { const { account, authorizedSigner, resolved } = plan const deposit = resolveOpeningDeposit({ @@ -608,7 +604,7 @@ async function open( payee: resolved.payee, token: resolved.token, }) - storeChannelEntry(cache, resolved.key, { + await storeChannelEntry(sink, { channelId: payload.channelId, cumulativeAmount: resolved.amount, deposit, @@ -622,7 +618,7 @@ async function open( async function recover( plan: Extract, - cache: ChannelCache, + sink: ChannelSink, ): Promise { const { account, context, decimals, maxDeposit, resolved } = plan const { descriptor } = context @@ -657,7 +653,7 @@ async function recover( resolved.chainId, resolved.escrow, ) - storeChannelEntry(cache, resolved.key, { + await storeChannelEntry(sink, { channelId: reusable.channelId, cumulativeAmount, deposit: reusable.state.deposit, @@ -671,7 +667,7 @@ async function recover( async function voucher( plan: Extract, - cache: ChannelCache, + sink: ChannelSink, ): Promise { const { account, entry, resolved } = plan const cumulativeAmount = entry.cumulativeAmount + resolved.amount @@ -685,13 +681,13 @@ async function voucher( resolved.escrow, ) entry.cumulativeAmount = cumulativeAmount - cache.notifyUpdate(entry) + await storeChannelEntry(sink, entry) return payload } async function manual( plan: Extract, - cache: ChannelCache, + sink: ChannelSink, ): Promise { const { account, context, decimals, resolved } = plan const { descriptor } = context @@ -718,7 +714,7 @@ async function manual( descriptor, resolved, }) - updateCachedCumulative(cache, channelId, payload) + await applyCumulative(sink, resolved.key, payload) return payload } diff --git a/src/tempo/session/client/Session.test.ts b/src/tempo/session/client/Session.test.ts index 0658c4f9..6aa065b0 100644 --- a/src/tempo/session/client/Session.test.ts +++ b/src/tempo/session/client/Session.test.ts @@ -1,6 +1,6 @@ import { type Address, createClient, custom, decodeFunctionData, encodeFunctionResult } from 'viem' import { privateKeyToAccount } from 'viem/accounts' -import { Transaction } from 'viem/tempo' +import { Account as TempoAccount, Secp256k1, Transaction } from 'viem/tempo' import { describe, expect, test } from 'vp/test' import type { Challenge } from '../../../Challenge.js' @@ -286,6 +286,45 @@ describe('precompile client session', () => { expect(updates).toEqual([100n, 200n]) }) + test('resolveAccount selects an access-key account for reusable session credentials', async () => { + const accessKey = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey(), { + access: account, + }) + const calls: session.ResolveAccountInfo[] = [] + const method = session({ + account, + decimals: 0, + maxDeposit: '1000', + getClient: () => client, + resolveAccount(info) { + if (info.intent !== 'session') throw new Error('expected session account resolution') + calls.push(info) + return accessKey + }, + }) + const first = deserializeCredential( + await method.createCredential({ challenge: makeChallenge(), context: {} }), + ) + const second = deserializeCredential( + await method.createCredential({ challenge: makeChallenge(), context: {} }), + ) + + expect(calls).toHaveLength(2) + expect(calls[0]!.account.address).toBe(account.address) + expect(calls[0]!.entry).toBeUndefined() + expect(calls[0]!.payee).toBe(descriptor.payee) + expect(calls[1]!.entry?.descriptor.authorizedSigner).toBe(accessKey.accessKeyAddress) + expect(first.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`) + expect(second.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`) + expect(first.payload.action).toBe('open') + if (first.payload.action !== 'open' || second.payload.action !== 'voucher') + throw new Error('expected open then voucher payloads') + expect(first.payload.descriptor.payer).toBe(account.address) + expect(first.payload.descriptor.authorizedSigner).toBe(accessKey.accessKeyAddress) + expect(second.payload.channelId).toBe(first.payload.channelId) + expect(second.payload.cumulativeAmount).toBe('200') + }) + test('enforces maxDeposit when reusing a cached auto-mode channel', async () => { const updates: bigint[] = [] const method = session({ diff --git a/src/tempo/session/client/Session.ts b/src/tempo/session/client/Session.ts index 75bb588e..85ceb3d6 100644 --- a/src/tempo/session/client/Session.ts +++ b/src/tempo/session/client/Session.ts @@ -5,15 +5,17 @@ import * as Constants from '../../../Constants.js' import * as Method from '../../../Method.js' import * as Account from '../../../viem/Account.js' import * as Client from '../../../viem/Client.js' +import type * as AccountResolution from '../../client/ResolveAccount.js' import * as defaults from '../../internal/defaults.js' import * as Methods from '../../Methods.js' import { serializeCredential, type ChannelEntry } from './ChannelOps.js' -import { sessionContextSchema } from './CredentialState.js' +import { createChannelStore, type ChannelStore } from './ChannelStore.js' import { - createChannelCache, executeCredentialPlan, planCredential, resolveChallengeContext, + resolveRecoverContext, + sessionContextSchema, } from './CredentialState.js' export { sessionContextSchema, type SessionContext } from './CredentialState.js' @@ -29,11 +31,13 @@ export function session(parameters: session.Parameters = {}) { const { account, authorizedSigner, + channelStore, decimals = defaults.decimals, escrow: escrowOverride, getClient: getClientParameter, maxDeposit: maxDepositParameter, onChannelUpdate, + resolveAccount, } = parameters const getClient = Client.getResolver({ chain: tempo_chain, @@ -43,7 +47,8 @@ export function session(parameters: session.Parameters = {}) { const getAccount = Account.getResolver({ account }) const maxDeposit = maxDepositParameter !== undefined ? parseUnits(maxDepositParameter, decimals) : undefined - const cache = createChannelCache(onChannelUpdate) + const store = channelStore ?? createChannelStore() + const sink = { store, notifyUpdate: (entry: ChannelEntry) => onChannelUpdate?.(entry) } return Method.toClient(Methods.session, { canHandleChallenge({ challenge }) { @@ -61,18 +66,38 @@ export function session(parameters: session.Parameters = {}) { escrowOverride, getClient, }) - const account = getAccount(resolved.client, context) + const defaultAccount = getAccount(resolved.client, context) + const entry = await store.get(resolved.key) + const recoverContext = resolveRecoverContext({ context, snapshot: resolved.snapshot }) + const request = resolved.challenge + .request as AccountResolution.ResolveSessionAccountInfo['request'] + const account = + (await resolveAccount?.({ + account: defaultAccount, + chainId: resolved.chainId, + challenge, + context, + entry, + escrow: resolved.escrow, + intent: 'session', + key: resolved.key, + payee: resolved.payee, + payer: defaultAccount.address, + recoverContext, + request, + token: resolved.token, + })) ?? defaultAccount const payload = await executeCredentialPlan( planCredential({ account, authorizedSigner, - cache, + entry, context, decimals, maxDeposit, resolved, }), - cache, + sink, ) return serializeCredential(challenge, payload, resolved.chainId, account) }, @@ -81,10 +106,15 @@ export function session(parameters: session.Parameters = {}) { /** Type helpers for the low-level TIP-1034 session client method. */ export declare namespace session { + type ResolveAccount = AccountResolution.ResolveAccount + type ResolveAccountInfo = AccountResolution.ResolveSessionAccountInfo + type Parameters = Account.getResolver.Parameters & Client.getResolver.Parameters & { - /** Address authorized to sign vouchers on behalf of the payer. Defaults to the account access key address when available, otherwise the account address. */ + /** Address authorized to sign vouchers for the payer. Defaults to the resolved account authority. */ authorizedSigner?: Address | undefined + /** Pluggable persistence for reusable channels. Defaults to an in-memory store. */ + channelStore?: ChannelStore | undefined /** Token decimals for parsing human-readable amounts (default: 6). */ decimals?: number | undefined /** TIP20EscrowChannel address override. */ @@ -93,5 +123,7 @@ export declare namespace session { maxDeposit?: string | undefined /** Called whenever channel state changes. */ onChannelUpdate?: ((entry: ChannelEntry) => void) | undefined + /** Selects the account that signs this session credential after the challenge is known. */ + resolveAccount?: ResolveAccount | undefined } } diff --git a/src/tempo/session/client/SessionManager.test.ts b/src/tempo/session/client/SessionManager.test.ts index 3f98f773..23eebef5 100644 --- a/src/tempo/session/client/SessionManager.test.ts +++ b/src/tempo/session/client/SessionManager.test.ts @@ -5,6 +5,8 @@ import { describe, expect, test, vi } from 'vp/test' import * as Challenge from '../../../Challenge.js' import * as Constants from '../../../Constants.js' import * as Credential from '../../../Credential.js' +import type { ChannelEntry } from '../client/ChannelOps.js' +import { createJsonChannelStore, entryKey, type ChannelStore } from '../client/ChannelStore.js' import * as Channel from '../precompile/Channel.js' import { escrowAbi } from '../precompile/escrow.abi.js' import { tip20ChannelEscrow } from '../precompile/Protocol.js' @@ -13,7 +15,6 @@ import type { NeedVoucherEvent, SessionReceipt } from '../precompile/Protocol.js import { formatNeedVoucherEvent, parseEvent } from '../precompile/Protocol.js' import type { SessionCredentialPayload } from '../precompile/Protocol.js' import { computeFallbackCloseAmount, sessionManager } from './SessionManager.js' -import type { StoredSessionChannel } from './SessionManager.js' const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex const challengeId = 'test-challenge-1' @@ -59,20 +60,40 @@ const storedChannelId = Channel.computeId({ escrow: tip20ChannelEscrow, }) -function storedChannel(overrides: Partial = {}): StoredSessionChannel { +function channelEntry(overrides: Partial = {}): ChannelEntry { return { channelId: storedChannelId, - cumulativeAmount: '1000000', - deposit: '10000000', + cumulativeAmount: 1_000_000n, + deposit: 10_000_000n, descriptor: storedDescriptor, escrow: tip20ChannelEscrow, chainId: 4217, opened: true, - updatedAt: 0, ...overrides, } } +/** + * In-memory {@link ChannelStore} with spied `set`/`delete`, optionally seeded. + * Seeded entries live in the entry index, so the plugin resumes them from + * `store.get(resolved.key)` after a 402 (there is no first-request hint). + */ +function makeChannelStore(seed: readonly ChannelEntry[] = []) { + const map = new Map(seed.map((entry) => [entryKey(entry), entry])) + const set = vi.fn((entry: ChannelEntry) => { + map.set(entryKey(entry), entry) + }) + const remove = vi.fn((key: string) => { + map.delete(key) + }) + const store: ChannelStore = { + get: (key) => map.get(key), + set, + delete: remove, + } + return { store, set, delete: remove, map } +} + function makeChallenge(overrides: Record = {}): Challenge.Challenge { return Challenge.from({ id: challengeId, @@ -234,6 +255,33 @@ describe('Session', () => { expect(mockFetch).toHaveBeenCalledOnce() }) + test('rejects a concurrent request while one is in flight', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const mockFetch = vi.fn().mockImplementation(async () => { + await gate + return makeOkResponse('hello') + }) + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + const first = s.fetch('https://api.example.com/data') + await expect(s.fetch('https://api.example.com/data')).rejects.toThrow( + 'concurrent requests on one manager are not supported', + ) + + release() + expect((await first).status).toBe(200) + + // The guard clears after the in-flight request settles. + expect((await s.fetch('https://api.example.com/data')).status).toBe(200) + }) + test('binds the default global fetch for browser runtimes', async () => { const originalFetch = globalThis.fetch const mockFetch = vi.fn(function (this: unknown) { @@ -255,28 +303,35 @@ describe('Session', () => { } }) - test('adds a stored channel hint to the first request', async () => { + test('resumes a seeded channel after a 402 without a first-request hint', async () => { + const posted: SessionCredentialPayload[] = [] const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => { - expect(new Headers(init?.headers).get('Payment-Session')).toBe(storedChannelId) + const authorization = new Headers(init?.headers).get(Constants.Headers.authorization) + const payload = authorization + ? Credential.deserialize(authorization).payload + : undefined + if (payload) posted.push(payload) + if (!payload) return Promise.resolve(make402Response()) return Promise.resolve(makeOkResponse()) }) const s = sessionManager({ account, client, fetch: mockFetch as typeof globalThis.fetch, - sessionStore: { - get: () => storedChannel(), - set: vi.fn(), - }, + channelStore: makeChannelStore([channelEntry()]).store, }) await s.fetch('https://api.example.com/data') - expect(mockFetch).toHaveBeenCalledOnce() + // No persisted hint: the first request carries no Payment-Session header, + // and the channel is resumed from the entry index with a voucher. + expect(new Headers(mockFetch.mock.calls[0]?.[1]?.headers).get('Payment-Session')).toBeNull() + expect(posted[0]).toMatchObject({ action: 'voucher', channelId: storedChannelId }) }) - test('stores same-route HEAD snapshot as a first-request channel hint', async () => { - const set = vi.fn() + test('seeds a same-route HEAD snapshot into the entry index and resumes it', async () => { + const { store, set } = makeChannelStore() + const posted: SessionCredentialPayload[] = [] const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => { const headers = new Headers(init?.headers) if (init?.method === 'HEAD' && !headers.get(Constants.Headers.authorization)) { @@ -306,7 +361,14 @@ describe('Session', () => { }), ) } - expect(headers.get(Constants.Headers.paymentSession)).toBe(storedChannelId) + // The seeded snapshot is not sent as a first-request hint; the content + // request gets a 402 and resumes from the entry index with a voucher. + const authorization = headers.get(Constants.Headers.authorization) + const payload = authorization + ? Credential.deserialize(authorization).payload + : undefined + if (payload) posted.push(payload) + if (!payload) return Promise.resolve(make402Response()) return Promise.resolve(makeOkResponse()) }) const s = sessionManager({ @@ -314,20 +376,16 @@ describe('Session', () => { bootstrap: true, client, fetch: mockFetch as typeof globalThis.fetch, - sessionStore: { - get: () => null, - set, - }, + channelStore: store, }) const response = await s.fetch('https://api.example.com/data') expect(response.status).toBe(200) - expect(response.channelId).toBeNull() - expect(s.channelId).toBeUndefined() - expect(s.cumulative).toBe(0n) expect(set).toHaveBeenCalledWith(expect.objectContaining({ channelId: storedChannelId })) - expect(mockFetch).toHaveBeenCalledTimes(3) + expect(posted[0]).toMatchObject({ action: 'voucher', channelId: storedChannelId }) + const contentCall = mockFetch.mock.calls.find((call) => call[1]?.method !== 'HEAD') + expect(new Headers(contentCall?.[1]?.headers).get('Payment-Session')).toBeNull() }) test('does not answer non-zero bootstrap charge challenges', async () => { @@ -374,28 +432,8 @@ describe('Session', () => { expect(new Headers(mockFetch.mock.calls[1]?.[1]?.headers).get('Payment-Session')).toBeNull() }) - test('clears stale stored channel hints and retries with a fresh channel', async () => { - const remove = vi.fn() - const staleClient = createClient({ - account, - chain: { id: 4217 } as never, - transport: custom({ - async request(args) { - if (args.method === 'eth_chainId') return '0x1079' - if (args.method === 'eth_getTransactionCount') return '0x0' - if (args.method === 'eth_estimateGas') return '0x5208' - if (args.method === 'eth_maxPriorityFeePerGas') return '0x1' - if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } - if (args.method === 'eth_call') - return encodeFunctionResult({ - abi: escrowAbi, - functionName: 'getChannelState', - result: { settled: 0n, deposit: 0n, closeRequestedAt: 0 }, - }) - throw new Error(`unexpected rpc request: ${args.method}`) - }, - }), - }) + test('drops a stale stored channel the server rejects and retries with a fresh one', async () => { + const { store, delete: remove } = makeChannelStore([channelEntry()]) const postedPayloads: SessionCredentialPayload[] = [] const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => { const headers = new Headers(init?.headers) @@ -405,28 +443,25 @@ describe('Session', () => { : undefined if (payload) postedPayloads.push(payload) - if (init?.method === 'HEAD') return Promise.resolve(new Response(null, { status: 204 })) if (!payload) return Promise.resolve(make402Response()) + // Reject any reuse of the stale stored channel; accept a freshly opened one. + if (payload.channelId === storedChannelId) + return Promise.resolve(new Response('gone', { status: 500 })) return Promise.resolve(makeOkResponse()) }) const s = sessionManager({ account, - bootstrap: true, - client: staleClient, + client, fetch: mockFetch as typeof globalThis.fetch, maxDeposit: '10', - sessionStore: { - get: () => storedChannel(), - set: vi.fn(), - delete: remove, - }, + channelStore: store, }) const response = await s.fetch('https://api.example.com/data') expect(response.status).toBe(200) expect(remove).toHaveBeenCalledOnce() - expect(postedPayloads.map((payload) => payload.action)).toEqual(['open']) + expect(postedPayloads.map((payload) => payload.action)).toEqual(['voucher', 'open']) expect(s.opened).toBe(true) expect(s.channelId).not.toBe(storedChannelId) }) @@ -461,10 +496,7 @@ describe('Session', () => { account, client, fetch: mockFetch as typeof globalThis.fetch, - sessionStore: { - get: () => storedChannel(), - set: vi.fn(), - }, + channelStore: makeChannelStore([channelEntry()]).store, }) await s.fetch('https://api.example.com/data') @@ -475,9 +507,70 @@ describe('Session', () => { }) }) + test('resumes a persisted channel across a restart via the entry index', async () => { + // A shared durable KV backing two manager instances simulates a restart: + // the second manager shares only what survived to disk. + const backend = new Map() + const durableStore = () => + createJsonChannelStore({ + get: (key) => backend.get(key), + set: (key, value) => { + backend.set(key, value) + }, + delete: (key) => { + backend.delete(key) + }, + }) + + const openFetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get(Constants.Headers.authorization) + if (!authorization) return Promise.resolve(make402Response()) + return Promise.resolve(makeOkResponse()) + }) + const first = sessionManager({ + account, + client, + fetch: openFetch as typeof globalThis.fetch, + maxDeposit: '10', + channelStore: durableStore(), + }) + await first.fetch('https://api.example.com/data') + const channelId = first.channelId + expect(channelId).toBeDefined() + + // The durable channel entry must have reached the KV. + expect([...backend.keys()].some((key) => key.startsWith('chan:'))).toBe(true) + // No persistent hints are written; only the durable channel entry survives. + expect([...backend.keys()].some((key) => key.startsWith('hint:'))).toBe(false) + + const posted: SessionCredentialPayload[] = [] + const resumeFetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get(Constants.Headers.authorization) + const payload = authorization + ? Credential.deserialize(authorization).payload + : undefined + if (payload) posted.push(payload) + if (!payload) return Promise.resolve(make402Response()) + return Promise.resolve(makeOkResponse()) + }) + const restarted = sessionManager({ + account, + client, + fetch: resumeFetch as typeof globalThis.fetch, + maxDeposit: '10', + channelStore: durableStore(), + }) + await restarted.fetch('https://api.example.com/data') + + // The first request carries no hint header; after the 402 the restarted + // manager resumes the persisted channel from the entry index with a + // voucher rather than opening a new one. + expect(new Headers(resumeFetch.mock.calls[0]?.[1]?.headers).get('Payment-Session')).toBeNull() + expect(posted[0]).toMatchObject({ action: 'voucher', channelId }) + }) + test('persists opened channels and deletes closed channels when supported', async () => { - const set = vi.fn() - const remove = vi.fn() + const { store, set, delete: remove } = makeChannelStore() let callCount = 0 const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => { const authorization = new Headers(init?.headers).get('Authorization') @@ -527,11 +620,7 @@ describe('Session', () => { client, fetch: mockFetch as typeof globalThis.fetch, maxDeposit: '10', - sessionStore: { - get: () => null, - set, - delete: remove, - }, + channelStore: store, }) await s.fetch('https://api.example.com/data') diff --git a/src/tempo/session/client/SessionManager.ts b/src/tempo/session/client/SessionManager.ts index eb8c3fc3..3cf1c0b5 100644 --- a/src/tempo/session/client/SessionManager.ts +++ b/src/tempo/session/client/SessionManager.ts @@ -8,9 +8,9 @@ import type * as Account from '../../../viem/Account.js' import type * as Client from '../../../viem/Client.js' import { charge as chargePlugin } from '../../client/Charge.js' import type { ChannelEntry } from '../client/ChannelOps.js' +import { createChannelStore, entryKey, type ChannelStore } from '../client/ChannelStore.js' import type { SessionContext } from '../client/CredentialState.js' import { session as sessionPlugin } from '../client/Session.js' -import type { ChannelDescriptor } from '../precompile/Protocol.js' import { deserializeSessionReceipt } from '../precompile/Protocol.js' import { readSessionChallengeAmount, type SessionReceipt } from '../precompile/Protocol.js' import { @@ -34,7 +34,6 @@ import { import { closeSocketSession } from './Runtime.js' import { closeHttpSession, - getSessionSnapshot, isTempoSessionChallenge, managementInput, postTopUp, @@ -98,36 +97,6 @@ export type PaymentResponse = Response & { cumulative: bigint } -/** Serializable client-side channel snapshot stored between manager instances. */ -export type StoredSessionChannel = { - /** Latest known channel ID. Used as the next request's channel hint. */ - channelId: Hex.Hex - /** Latest local cumulative voucher authorization in raw token units. */ - cumulativeAmount: string - /** Latest known deposit in raw token units. */ - deposit: string - /** TIP-1034 channel descriptor, used as a fallback when the server cannot snapshot. */ - descriptor: ChannelDescriptor - /** Escrow address used to derive the channel ID. */ - escrow: Address - /** Chain ID used to derive the channel ID. */ - chainId: number - /** Whether the channel was open when stored. Closed channels are not used as hints. */ - opened: boolean - /** Client timestamp for debugging or app-level eviction. */ - updatedAt: number -} - -/** Optional per-manager store for channel hints and restart recovery. */ -export type SessionStore = { - /** Reads the latest stored channel snapshot for this manager scope. */ - get(): Promise | StoredSessionChannel | null | undefined - /** Persists the latest channel snapshot. */ - set(channel: StoredSessionChannel): Promise | void - /** Deletes the stored snapshot when a channel is closed, when supported. */ - delete?(): Promise | void -} - /** Normalized runtime dependencies derived from `sessionManager()` parameters. */ type SessionManagerConfig = { /** Decimal precision used when parsing human-readable manager amounts. */ @@ -156,65 +125,16 @@ function isZeroAmountChargeChallenge(challenge: Challenge.Challenge) { } } -function storedChannelFromEntry(entry: ChannelEntry): StoredSessionChannel { - return { - channelId: entry.channelId, - cumulativeAmount: entry.cumulativeAmount.toString(), - deposit: entry.deposit.toString(), - descriptor: entry.descriptor, - escrow: entry.escrow, - chainId: entry.chainId, - opened: entry.opened, - updatedAt: Date.now(), - } -} - -function storedChannelContext(channel: StoredSessionChannel): SessionContext { - return { - channelId: channel.channelId, - cumulativeAmountRaw: channel.cumulativeAmount, - descriptor: channel.descriptor, - } -} - -function storedChannelFromSnapshot( - snapshot: ReturnType, -): StoredSessionChannel { +/** Builds a reusable channel entry from a server session snapshot header. */ +function entryFromSnapshot(snapshot: ReturnType): ChannelEntry { return { channelId: snapshot.channelId, - cumulativeAmount: snapshot.acceptedCumulative, - deposit: snapshot.deposit, + cumulativeAmount: BigInt(snapshot.acceptedCumulative), + deposit: BigInt(snapshot.deposit), descriptor: snapshot.descriptor, escrow: snapshot.escrow, chainId: snapshot.chainId, opened: true, - updatedAt: Date.now(), - } -} - -type CredentialContextResolution = { - context: SessionContext - usedStoredChannel: boolean -} - -function resolveCredentialContext(parameters: { - channel: ChannelEntry | null - challenge: TempoSessionChallenge - context: SessionContext - storedChannel: StoredSessionChannel | null -}): CredentialContextResolution { - const { channel, challenge, context, storedChannel } = parameters - if ( - context.action || - channel?.opened || - !storedChannel?.opened || - getSessionSnapshot(challenge) - ) { - return { context, usedStoredChannel: false } - } - return { - context: { ...context, ...storedChannelContext(storedChannel) }, - usedStoredChannel: true, } } @@ -267,25 +187,57 @@ function resolveSessionManagerConfig(parameters: sessionManager.Parameters): Ses * channel state management and credential creation, and to `Fetch.from` * for the 402 challenge/retry flow. * - * ## Session resumption - * - * The manager only keeps active client transport state in memory. Channel - * descriptors and cumulative voucher state are hydrated from server snapshots - * when challenges include them; otherwise the next request opens a fresh - * TIP-1034 channel. `maxDeposit` remains the local cap for all automatic - * voucher and top-up decisions. + * `channelStore` can persist reusable channels between manager instances. */ export function sessionManager(parameters: sessionManager.Parameters): SessionManager { const config = resolveSessionManagerConfig(parameters) - const sessionStore = parameters.sessionStore - let storedChannel: StoredSessionChannel | null = null - let bootstrapChannelId: Hex.Hex | null = null - const ignoredStoredChannelIds = new Set() const runtime = createSessionManagerRuntime() const receipts = createSessionReceiptCoordinator({ getSocketSession: () => runtime.socketSession, }) + const backing = parameters.channelStore ?? createChannelStore() + const ignoredChannelIds = new Set() + + // Tracks one fetch's channel reuse so stale stored entries can be evicted once. + type ChannelUse = { + seenExisting: Set + createdKeys: Set + resumed: ChannelEntry | undefined + } + let channelUse: ChannelUse | undefined + + /** Returns the backing entry for `key` only when it is open and not ignored. */ + async function getReusable(key: string): Promise { + const entry = await backing.get(key) + if (entry?.opened && !ignoredChannelIds.has(entry.channelId)) return entry + return undefined + } + + const store: ChannelStore = { + async get(key) { + const entry = await getReusable(key) + if (entry && channelUse) { + channelUse.seenExisting.add(key) + if (!channelUse.createdKeys.has(key)) channelUse.resumed ??= entry + } + return entry + }, + async set(entry) { + const key = entryKey(entry) + if (entry.opened) ignoredChannelIds.delete(entry.channelId) + if (channelUse && !channelUse.seenExisting.has(key)) channelUse.createdKeys.add(key) + await backing.set(entry) + }, + delete: (key) => backing.delete(key), + } + + /** Removes a failed channel from candidacy for the rest of this manager's life. */ + async function ignoreChannel(entry: ChannelEntry) { + ignoredChannelIds.add(entry.channelId) + await Promise.resolve(backing.delete(entryKey(entry))).catch(() => undefined) + } + function dispatch(event: Parameters[1]) { return dispatchSessionEvent(runtime, event) } @@ -297,10 +249,10 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa escrow: parameters.escrow, decimals: config.decimals, maxDeposit: parameters.maxDeposit, + channelStore: store, onChannelUpdate(entry) { if (entry.channelId !== runtime.channel?.channelId) runtime.spent = 0n runtime.channel = entry - persistStoredChannel(entry) if (runtime.lastChallenge) { dispatch({ type: 'activated', @@ -335,28 +287,12 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa requiredCumulative, }) } - const resolved = resolveCredentialContext({ - challenge, - channel: runtime.channel, - context: {}, - storedChannel, - }) - if (resolved.usedStoredChannel) return _helpers.createCredential(resolved.context) return undefined }, }) function createSessionCredential(challenge: TempoSessionChallenge, context: SessionContext) { - const resolved = resolveCredentialContext({ - challenge, - channel: runtime.channel, - context, - storedChannel, - }) - return method.createCredential({ - challenge, - context: resolved.context, - }) + return method.createCredential({ challenge, context }) } function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) { @@ -386,50 +322,21 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa }) } - async function getStoredChannel() { - if (!sessionStore) return null - if (storedChannel) return storedChannel - const channel = await sessionStore.get() - storedChannel = - channel?.opened && !ignoredStoredChannelIds.has(channel.channelId) ? channel : null - return storedChannel - } - - async function clearStoredChannel() { - if (!sessionStore) { - storedChannel = null - bootstrapChannelId = null - return - } - const channel = storedChannel - if (channel) ignoredStoredChannelIds.add(channel.channelId) - storedChannel = null - bootstrapChannelId = null - if (sessionStore.delete) { - await Promise.resolve(sessionStore.delete()).catch(() => undefined) - return - } - if (channel) { - await Promise.resolve( - sessionStore.set({ ...channel, opened: false, updatedAt: Date.now() }), - ).catch(() => undefined) - } - } - - async function storeSnapshotHeader(response: Response) { + /** Persists a server snapshot into the channel store and returns the entry. */ + async function storeSnapshotHeader(response: Response): Promise { const header = response.headers.get(Constants.Headers.paymentSessionSnapshot) - if (!header) return - const snapshot = deserializeSessionSnapshot(header) - bootstrapChannelId = snapshot.channelId - const channel = storedChannelFromSnapshot(snapshot) - storedChannel = channel - if (sessionStore) await Promise.resolve(sessionStore.set(channel)).catch(() => undefined) + if (!header) return undefined + const entry = entryFromSnapshot(deserializeSessionSnapshot(header)) + await Promise.resolve(store.set(entry)).catch(() => undefined) + return entry } - async function bootstrapSession(input: RequestInfo | URL, init?: RequestInit | undefined) { - if (!parameters.bootstrap) return - if (runtime.channel?.opened) return - if (await getStoredChannel()) return + async function bootstrapSession( + input: RequestInfo | URL, + init?: RequestInit | undefined, + ): Promise { + if (!parameters.bootstrap) return undefined + if (runtime.channel?.opened) return undefined const requestHeaders = input instanceof Request ? input.headers : undefined const { body: _body, method: _method, ...bootstrapInit } = init ?? {} @@ -446,13 +353,10 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa try { const challengeResponse = await config.fetch(bootstrapInput, headInit) - if (challengeResponse.status !== 402) { - await storeSnapshotHeader(challengeResponse) - return - } + if (challengeResponse.status !== 402) return await storeSnapshotHeader(challengeResponse) const challenge = Challenge.fromResponseList(challengeResponse).find(isTempoChargeChallenge) - if (!challenge) return - if (!isZeroAmountChargeChallenge(challenge)) return + if (!challenge) return undefined + if (!isZeroAmountChargeChallenge(challenge)) return undefined const credential = await chargeMethod.createCredential({ challenge: challenge as never, context: {}, @@ -464,22 +368,13 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa [Constants.Headers.authorization]: credential, }, }) - if (response.ok) await storeSnapshotHeader(response) + if (response.ok) return await storeSnapshotHeader(response) + return undefined } catch { - return + return undefined } } - function persistStoredChannel(entry: ChannelEntry) { - if (!sessionStore) return - const channel = storedChannelFromEntry(entry) - ignoredStoredChannelIds.delete(channel.channelId) - const operation = - entry.opened || !sessionStore.delete ? sessionStore.set(channel) : sessionStore.delete() - void Promise.resolve(operation).catch(() => undefined) - storedChannel = entry.opened ? channel : null - } - function getFallbackCloseAmount(challenge: TempoSessionChallenge, channelId: Hex.Hex): bigint { const currentSocket = runtime.socketSession return computeFallbackCloseAmount({ @@ -597,68 +492,83 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa } async function doFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + // The manager drives one shared `runtime` state machine, so requests are + // single-flight. Reject overlap loudly instead of letting concurrent calls + // corrupt each other's runtime and channel tracking. + if (channelUse) + throw new Error( + 'SessionManager: a request is already in flight; concurrent requests on one manager are not supported', + ) + const use: ChannelUse = { seenExisting: new Set(), createdKeys: new Set(), resumed: undefined } + channelUse = use + runtime.lastUrl = input - const stored = await getStoredChannel() - await bootstrapSession(input, init) - const hintedInit = requestInitWithSessionHint( - input, - init, - (await getStoredChannel())?.channelId ?? bootstrapChannelId ?? stored?.channelId, - ) + const previous = captureRuntimeStateSnapshot({ channel: runtime.channel, spent: runtime.spent, state: runtime.state, }) - let effectiveInit = hintedInit - let canRetryWithoutStoredHint = Boolean(stored?.opened && !previous.channel?.opened) - - for (;;) { - let response: Response - try { - response = await wrappedFetch(input, effectiveInit) - } catch (error) { - restoreRuntime(previous) - if (!canRetryWithoutStoredHint) throw error - canRetryWithoutStoredHint = false - await clearStoredChannel() - await bootstrapSession(input, init) - effectiveInit = requestInitWithSessionHint(input, init, bootstrapChannelId ?? undefined) - continue + // Cold starts resume from `channelStore` after the 402 reveals the scope. + const liveHint = runtime.channel?.opened ? runtime.channel.channelId : undefined + + try { + await bootstrapSession(input, init) + + let effectiveInit = requestInitWithSessionHint(input, init, liveHint) + // Stored channels may be stale, so retry once after evicting the resumed entry. + let canRetryResumed = !previous.channel?.opened + + async function retryWithoutResumed(): Promise { + const resumed = use.resumed + if (!canRetryResumed || !resumed) return false + canRetryResumed = false + await ignoreChannel(resumed) + effectiveInit = requestInitWithSessionHint(input, init, undefined) + return true } - let paymentResponse = toPaymentResponse(response) - let attemptedHttpManagement = false - if (paymentResponse.status === 402) { - const retry = await retryHttpPaymentRequired({ - input, - init: effectiveInit, - response: paymentResponse, - createSessionCredential, - fetch: config.fetch, - getChannel: () => runtime.channel, - restoreCumulative, - setChallenge(challenge) { - runtime.lastChallenge = challenge - }, - topUpIfNeeded, - }) - if (retry) { - attemptedHttpManagement = true - paymentResponse = toPaymentResponse(retry) + for (;;) { + let response: Response + try { + response = await wrappedFetch(input, effectiveInit) + } catch (error) { + restoreRuntime(previous) + if (await retryWithoutResumed()) continue + throw error } + + let paymentResponse = toPaymentResponse(response) + let attemptedHttpManagement = false + if (paymentResponse.status === 402) { + const retry = await retryHttpPaymentRequired({ + input, + init: effectiveInit, + response: paymentResponse, + createSessionCredential, + fetch: config.fetch, + getChannel: () => runtime.channel, + restoreCumulative, + setChallenge(challenge) { + runtime.lastChallenge = challenge + }, + topUpIfNeeded, + }) + if (retry) { + attemptedHttpManagement = true + paymentResponse = toPaymentResponse(retry) + } + } + if (!attemptedHttpManagement && !paymentResponse.ok && !paymentResponse.receipt) { + restoreRuntime(previous) + if (await retryWithoutResumed()) continue + return paymentResponse + } + return paymentResponse } - if (!attemptedHttpManagement && !paymentResponse.ok && !paymentResponse.receipt) { - restoreRuntime(previous) - if (!canRetryWithoutStoredHint) return paymentResponse - canRetryWithoutStoredHint = false - await clearStoredChannel() - await bootstrapSession(input, init) - effectiveInit = requestInitWithSessionHint(input, init, bootstrapChannelId ?? undefined) - continue - } - return paymentResponse + } finally { + channelUse = undefined } } @@ -741,7 +651,10 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa ) } const probeUrl = webSocketProbeUrl(input) - await bootstrapSession(probeUrl, init?.signal ? { signal: init.signal } : undefined) + const signalInit = init?.signal ? { signal: init.signal } : undefined + await bootstrapSession(probeUrl, signalInit) + // Cold starts resume from `channelStore` after the probe's 402. + const liveHint = runtime.channel?.opened ? runtime.channel.channelId : undefined const prepared = await prepareWebSocketSession({ createSessionCredential, @@ -750,11 +663,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa onProbeUrl(httpUrl) { runtime.lastUrl = httpUrl.toString() }, - probeInit: requestInitWithSessionHint( - probeUrl, - init?.signal ? { signal: init.signal } : undefined, - (await getStoredChannel())?.channelId ?? bootstrapChannelId ?? undefined, - ), + probeInit: requestInitWithSessionHint(probeUrl, signalInit, liveHint), signal: init?.signal, }) const { challenge, credential, httpUrl, wsUrl } = prepared @@ -823,7 +732,7 @@ export namespace sessionManager { export type Parameters = Account.getResolver.Parameters & Client.getResolver.Parameters & { - /** Address authorized to sign vouchers. Defaults to the account access key address when available, otherwise the account address. */ + /** Address authorized to sign vouchers for the payer. Defaults to the resolved account authority. */ authorizedSigner?: Address | undefined /** Enables same-route HEAD bootstrap from a server session snapshot before opening a new channel. */ bootstrap?: boolean | undefined @@ -837,8 +746,8 @@ export namespace sessionManager { fetch?: typeof globalThis.fetch | undefined /** Maximum deposit in human-readable units (e.g. `'10'` for 10 tokens). Converted to raw units via `decimals`. */ maxDeposit?: string | undefined - /** Optional per-manager store for persisted channel hints and restart recovery. */ - sessionStore?: SessionStore | undefined + /** Store for reusable session channels. Defaults to in-memory. */ + channelStore?: ChannelStore | undefined /** Optional websocket constructor for runtimes without a global WebSocket. */ webSocket?: WebSocketConstructor | undefined } diff --git a/src/tempo/session/client/index.ts b/src/tempo/session/client/index.ts index 5777c345..d364e29b 100644 --- a/src/tempo/session/client/index.ts +++ b/src/tempo/session/client/index.ts @@ -2,18 +2,20 @@ export { session } from './Session.js' export { sessionManager } from './SessionManager.js' export * as Machine from './Runtime.js' export { deserializeSnapshot, serializeSnapshot } from '../Snapshot.js' -/** Public client session manager types. */ +export { + createChannelStore, + createJsonChannelStore, + entryKey, + type ChannelStore, + type JsonChannelKv, +} from './ChannelStore.js' export type { PaymentResponse, - SessionStore, SessionManager, SessionManagerSseOptions, SessionManagerWebSocketOptions, - StoredSessionChannel, } from './SessionManager.js' -/** Public managed WebSocket facade returned by `sessionManager().ws()`. */ export type { SessionManagedWebSocket } from './Transports.js' -/** Public pure state-machine types. */ export type { ActiveSessionState, ChallengedSessionState, @@ -35,3 +37,7 @@ export type { WithdrawableSessionState, } from './Runtime.js' export type { SessionSnapshot } from '../Snapshot.js' +export type { + ResolveAccount, + ResolveSessionAccountInfo as ResolveAccountInfo, +} from '../../client/ResolveAccount.js' diff --git a/src/tempo/session/precompile/Voucher.test.ts b/src/tempo/session/precompile/Voucher.test.ts index cbedabd8..36dd1846 100644 --- a/src/tempo/session/precompile/Voucher.test.ts +++ b/src/tempo/session/precompile/Voucher.test.ts @@ -271,7 +271,7 @@ describe('Precompile Voucher', () => { ).toBe(false) }) - test('sign rejects p256 keychain access-key voucher delegation explicitly', async () => { + test('signs and verifies p256 access-key vouchers as primitives', async () => { const rootAccount = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey()) const accessKey = TempoAccount.fromP256(P256.randomPrivateKey(), { access: rootAccount, @@ -281,16 +281,55 @@ describe('Precompile Voucher', () => { transport: http('http://127.0.0.1'), }) - await expect( - signVoucher( - accessKeyClient, - accessKey, - { channelId, cumulativeAmount }, + const signature = await signVoucher( + accessKeyClient, + accessKey, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + accessKey.accessKeyAddress, + ) + + const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) + expect(envelope.type).toBe('p256') + expect( + verifyVoucher( escrowContract, chainId, + { channelId, cumulativeAmount, signature }, accessKey.accessKeyAddress, ), - ).rejects.toThrow('TIP-1034 voucher signing only supports secp256k1 voucher signatures.') + ).toBe(true) + }) + + test('signs and verifies webAuthn root vouchers as primitives', async () => { + const webAuthnAccount = TempoAccount.fromHeadlessWebAuthn(P256.randomPrivateKey(), { + origin: 'https://example.com', + rpId: 'example.com', + }) + const webAuthnClient = createClient({ + account: webAuthnAccount, + transport: http('http://127.0.0.1'), + }) + + const signature = await signVoucher( + webAuthnClient, + webAuthnAccount, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + ) + + const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) + expect(envelope.type).toBe('webAuthn') + expect( + verifyVoucher( + escrowContract, + chainId, + { channelId, cumulativeAmount, signature }, + webAuthnAccount.address, + ), + ).toBe(true) }) test('domain and type match TIP-1034', () => { diff --git a/src/tempo/session/precompile/Voucher.ts b/src/tempo/session/precompile/Voucher.ts index d9b1d1fe..b235e01b 100644 --- a/src/tempo/session/precompile/Voucher.ts +++ b/src/tempo/session/precompile/Voucher.ts @@ -1,4 +1,3 @@ -import { Signature } from 'ox' import { Channel, SignatureEnvelope } from 'ox/tempo' import type { Account, Address, Client, Hex } from 'viem' import { hashTypedData } from 'viem' @@ -45,6 +44,24 @@ function getVoucherDigest(chainId: number, voucher: Voucher): Hex { }) as Hex } +function getVoucherPayload(verifyingContract: Address, chainId: number, voucher: Voucher): Hex { + if (verifyingContract.toLowerCase() === Channel.address.toLowerCase()) + return getVoucherDigest(chainId, voucher) + return hashTypedData({ + domain: getVoucherDomain(verifyingContract, chainId), + types: voucherTypes, + primaryType: 'Voucher', + message: { + channelId: voucher.channelId, + cumulativeAmount: voucher.cumulativeAmount, + }, + }) +} + +function isPrimitiveEnvelope(type: string): boolean { + return type === 'secp256k1' || type === 'p256' || type === 'webAuthn' +} + function signCanonicalTempoVoucher( account: Account, parameters: { @@ -94,19 +111,19 @@ export async function signVoucher( })() const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) - if (envelope.type === 'keychain' && envelope.inner.type === 'secp256k1') - return Signature.toHex(envelope.inner.signature) - if (envelope.type === 'keychain' || envelope.type !== 'secp256k1') - throw new Error('TIP-1034 voucher signing only supports secp256k1 voucher signatures.') + if (!isPrimitiveEnvelope(envelope.type)) + throw new Error( + `TIP-1034 vouchers require a TIP-1020 primitive signature; received "${envelope.type}".`, + ) - return signature + return SignatureEnvelope.serialize(envelope) } /** * Verify a voucher signature matches the expected signer. * - * Only accepts raw secp256k1 signatures — the escrow contract verifies - * via ecrecover. Keychain, p256, and webAuthn signatures are rejected. + * Accepts canonical TIP-1020 primitive signatures. Keychain wrappers, + * multisig signatures, and magic-suffixed encodings are rejected. */ export function verifyVoucher( escrowContract: Address, @@ -117,25 +134,11 @@ export function verifyVoucher( try { const envelope = SignatureEnvelope.from(voucher.signature as SignatureEnvelope.Serialized) - // Reject keychain signatures — the escrow contract verifies raw ECDSA - // signatures against authorizedSigner, not keychain-wrapped ones. - if (envelope.type === 'keychain') return false - if (envelope.type !== 'secp256k1') return false + if (!isPrimitiveEnvelope(envelope.type)) return false if (SignatureEnvelope.serialize(envelope).toLowerCase() !== voucher.signature.toLowerCase()) return false - const payload = - escrowContract.toLowerCase() === Channel.address.toLowerCase() - ? getVoucherDigest(chainId, voucher) - : hashTypedData({ - domain: getVoucherDomain(escrowContract, chainId), - types: voucherTypes, - primaryType: 'Voucher', - message: { - channelId: voucher.channelId, - cumulativeAmount: voucher.cumulativeAmount, - }, - }) + const payload = getVoucherPayload(escrowContract, chainId, voucher) const signer = SignatureEnvelope.extractAddress({ payload, signature: envelope }) const valid = SignatureEnvelope.verify(envelope, { address: signer, payload }) return valid && TempoAddress.isEqual(signer, expectedSigner)