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..3994ccb1 100644 --- a/src/client/Mppx.test-d.ts +++ b/src/client/Mppx.test-d.ts @@ -1,4 +1,4 @@ -import type { Account } from 'viem' +import type { Account, Address } from 'viem' import { describe, expectTypeOf, test } from 'vp/test' import * as Challenge from '../Challenge.js' @@ -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,25 @@ describe('create.Config', () => { expectTypeOf(mppx.fetch).toBeFunction() }) + test('tempo common accepts one resolveAccount hook for charge and session', () => { + const resolveAccount: ResolveAccount = (info) => { + expectTypeOf(info).toHaveProperty('account') + expectTypeOf(info.chainId).toEqualTypeOf() + if (info.operation.kind === 'executeCalls') { + expectTypeOf(info.operation.calls).toMatchTypeOf< + readonly { data: `0x${string}`; to: Address }[] | undefined + >() + } + if (info.operation.kind === 'authorizePaymentChannel') { + expectTypeOf(info.operation.authority).toEqualTypeOf
() + } + 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 f27701ca..a5044ea6 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -18,5 +18,12 @@ export { type ChannelStore, type JsonChannelKv, } from '../tempo/session/client/ChannelStore.js' +export type { ChargeContext } from '../tempo/client/Charge.js' +export type { + ResolveAccount, + ResolveAccountCall, + ResolveAccountInfo, + ResolveAccountOperation, +} from '../tempo/client/ResolveAccount.js' export * as Mppx from './Mppx.js' export * as Transport from './Transport.js' diff --git a/src/tempo/client/Charge.test.ts b/src/tempo/client/Charge.test.ts index 72e3eede..b55a0e20 100644 --- a/src/tempo/client/Charge.test.ts +++ b/src/tempo/client/Charge.test.ts @@ -84,6 +84,122 @@ describe('tempo.charge client', () => { expect(credential.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`) }) + test('resolveAccount selects the transaction account from executable calls', async () => { + vi.resetModules() + const selectedAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000002', + ) + const chainId = 42431 + const calls: charge.ResolveAccountInfo[] = [] + const prepareTransactionRequest = vi.fn(async () => ({})) + const signTransaction = vi.fn(async () => '0xdeadbeef') + vi.doMock('viem/actions', () => ({ + prepareTransactionRequest, + sendCallsSync: vi.fn(), + signTransaction, + signTypedData: vi.fn(), + })) + + try { + const { charge: chargeWithMockedActions } = await import('./Charge.js') + const client = createClient({ + account, + chain: tempoLocalnet, + transport: http('http://127.0.0.1'), + }) + const method = chargeWithMockedActions({ + account, + getClient: () => client, + resolveAccount(info) { + calls.push(info) + return selectedAccount + }, + }) + + const credential = Credential.deserialize( + await method.createCredential({ + challenge: createChallenge({ amount: '1', chainId, supportedModes: ['pull'] }), + context: {}, + }), + ) + + expect(calls).toHaveLength(1) + expect(calls[0]!.account.address).toBe(account.address) + expect(calls[0]!.chainId).toBe(chainId) + expect(calls[0]!.operation.kind).toBe('executeCalls') + if (calls[0]!.operation.kind !== 'executeCalls') throw new Error('expected executeCalls') + expect(calls[0]!.operation.calls).toHaveLength(1) + expect(calls[0]!.operation.calls?.[0]?.to.toLowerCase()).toBe(currency.toLowerCase()) + expect(prepareTransactionRequest).toHaveBeenCalledOnce() + expect(signTransaction).toHaveBeenCalledOnce() + expect(credential.payload).toEqual({ signature: '0xdeadbeef', type: 'transaction' }) + expect(credential.source).toBe(`did:pkh:eip155:${chainId}:${selectedAccount.address}`) + } finally { + vi.doUnmock('viem/actions') + vi.resetModules() + } + }) + + test('resolveAccount omits executable calls when auto-swap routing is account-dependent', async () => { + vi.resetModules() + const selectedAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000002', + ) + const chainId = 42431 + const calls: charge.ResolveAccountInfo[] = [] + const prepareTransactionRequest = vi.fn(async () => ({})) + const signTransaction = vi.fn(async () => '0xdeadbeef') + const findCalls = vi.fn(async (_client: unknown, _parameters: { account: string }) => undefined) + vi.doMock('viem/actions', () => ({ + prepareTransactionRequest, + sendCallsSync: vi.fn(), + signTransaction, + signTypedData: vi.fn(), + })) + vi.doMock('../internal/auto-swap.js', () => ({ + defaultCurrencies: [currency], + findCalls, + resolve: vi.fn(() => ({ tokenIn: [currency], slippage: 1 })), + })) + + try { + const { charge: chargeWithMockedActions } = await import('./Charge.js') + const client = createClient({ + account, + chain: tempoLocalnet, + transport: http('http://127.0.0.1'), + }) + const method = chargeWithMockedActions({ + account, + autoSwap: true, + getClient: () => client, + resolveAccount(info) { + calls.push(info) + return selectedAccount + }, + }) + + const credential = Credential.deserialize( + await method.createCredential({ + challenge: createChallenge({ amount: '1', chainId, supportedModes: ['pull'] }), + context: {}, + }), + ) + + expect(calls).toHaveLength(1) + expect(calls[0]!.operation.kind).toBe('executeCalls') + if (calls[0]!.operation.kind !== 'executeCalls') throw new Error('expected executeCalls') + expect(calls[0]!.operation.calls).toBeUndefined() + expect(findCalls).toHaveBeenCalledOnce() + expect(findCalls.mock.calls[0]?.[1].account).toBe(selectedAccount.address) + expect(credential.payload).toEqual({ signature: '0xdeadbeef', type: 'transaction' }) + } finally { + vi.doUnmock('viem/actions') + vi.doUnmock('../internal/auto-swap.js') + vi.resetModules() + } + }) + test('zero-amount proof binds to the root payer for an access-key account', async () => { vi.resetModules() // Capture the typed data so we can assert what the proof commits to. @@ -115,9 +231,11 @@ describe('tempo.charge client', () => { chain: tempoLocalnet, transport: http('http://127.0.0.1'), }) + const resolveAccount = vi.fn() const method = chargeWithMockedActions({ account: accessKey, getClient: () => client, + resolveAccount, }) const credential = Credential.deserialize( @@ -128,6 +246,7 @@ describe('tempo.charge client', () => { ) expect(signTypedData).toHaveBeenCalledOnce() + expect(resolveAccount).not.toHaveBeenCalled() expect(signedTypedData?.message.account).toBe(account.address) expect(credential.payload).toEqual({ signature: '0xdeadbeef', type: 'proof' }) expect(credential.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`) diff --git a/src/tempo/client/Charge.ts b/src/tempo/client/Charge.ts index 8ab218dd..82fa63ba 100644 --- a/src/tempo/client/Charge.ts +++ b/src/tempo/client/Charge.ts @@ -20,6 +20,20 @@ import * as Charge_internal from '../internal/charge.js' import * as defaults from '../internal/defaults.js' import * as Proof from '../internal/proof.js' import * as Methods from '../Methods.js' +import type * as AccountResolution from './ResolveAccount.js' + +/** Runtime context accepted by the Tempo charge client method. */ +export type ChargeContext = { + account?: Account.getResolver.Parameters['account'] | undefined + autoSwap?: AutoSwap.resolve.Value | undefined + mode?: Methods.ChargeMode | undefined +} + +const chargeContextSchema = z.object({ + account: z.optional(z.custom()), + 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 +58,7 @@ export function charge(parameters: charge.Parameters = {}) { const getAccount = Account.getResolver({ account: parameters.account }) return Method.toClient(Methods.charge, { - context: z.object({ - account: z.optional(z.custom()), - 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,19 +78,21 @@ export function charge(parameters: charge.Parameters = {}) { if (chainId === undefined) throw new Error('No `chainId` provided. Pass a chain ID in the challenge or client.') - const account = getAccount(client, context) - const { request } = challenge const { amount, methodDetails } = request + const supportedModes = (methodDetails?.supportedModes as + | readonly Methods.ChargeMode[] + | undefined) ?? ['pull', 'push'] + const defaultAccount = getAccount(client, context) // Zero-amount: sign EIP-712 typed data instead of creating a transaction. if (BigInt(amount) === 0n) { const signature = await signTypedData(client, { - account, + account: defaultAccount, // `account` here is the signing account; the proof's bound payer is // `account.address` (echoed in the credential `source` below). ...Proof.typedData({ - account: account.address, + account: defaultAccount.address, chainId, challengeId: challenge.id, realm: challenge.realm, @@ -89,7 +101,7 @@ export function charge(parameters: charge.Parameters = {}) { return Credential.serialize({ challenge, payload: { signature, type: 'proof' }, - source: Proof.proofSource({ address: account.address, chainId }), + source: Proof.proofSource({ address: defaultAccount.address, chainId }), }) } @@ -104,22 +116,6 @@ export function charge(parameters: charge.Parameters = {}) { } } } - const supportedModes = (methodDetails?.supportedModes as - | readonly Methods.ChargeMode[] - | undefined) ?? ['pull', 'push'] - const mode = (() => { - const explicitMode = context?.mode ?? parameters.mode - if (explicitMode) { - if (!supportedModes.includes(explicitMode)) - throw new Error(`Challenge does not support ${explicitMode} mode.`) - return explicitMode - } - - const preferredMode = account.type === 'json-rpc' ? 'push' : 'pull' - if (supportedModes.includes(preferredMode)) return preferredMode - return supportedModes[0]! - })() - const memo = methodDetails?.memo ? (methodDetails.memo as Hex.Hex) : Attribution.encode({ challengeId: challenge.id, clientId, serverId: challenge.realm }) @@ -131,13 +127,14 @@ export function charge(parameters: charge.Parameters = {}) { }, recipient: request.recipient as Address, }) - const transferCalls = transfers.map((transfer) => - Actions.token.transfer.call({ - amount: BigInt(transfer.amount), - ...(transfer.memo && { memo: transfer.memo as Hex.Hex }), - to: transfer.recipient as Address, - token: currency, - }), + const transferCalls = transfers.map( + (transfer): AccountResolution.ResolveAccountCall => + Actions.token.transfer.call({ + amount: BigInt(transfer.amount), + ...(transfer.memo && { memo: transfer.memo as Hex.Hex }), + to: transfer.recipient as Address, + token: currency, + }) as AccountResolution.ResolveAccountCall, ) const autoSwap = AutoSwap.resolve( @@ -145,6 +142,29 @@ export function charge(parameters: charge.Parameters = {}) { AutoSwap.defaultCurrencies, ) + const account = + (await parameters.resolveAccount?.({ + account: defaultAccount, + chainId, + operation: { + kind: 'executeCalls', + ...(autoSwap ? {} : { calls: transferCalls }), + }, + })) ?? defaultAccount + + const mode = (() => { + const explicitMode = context?.mode ?? parameters.mode + if (explicitMode) { + if (!supportedModes.includes(explicitMode)) + throw new Error(`Challenge does not support ${explicitMode} mode.`) + return explicitMode + } + + const preferredMode = account.type === 'json-rpc' ? 'push' : 'pull' + if (supportedModes.includes(preferredMode)) return preferredMode + return supportedModes[0]! + })() + const swapCalls = autoSwap ? await AutoSwap.findCalls(client, { account: account.address, @@ -202,6 +222,9 @@ export function charge(parameters: charge.Parameters = {}) { export declare namespace charge { type AutoSwap = AutoSwap.resolve.Value + type Context = ChargeContext + type ResolveAccount = AccountResolution.ResolveAccount + type ResolveAccountInfo = AccountResolution.ResolveAccountInfo type Parameters = { /** @@ -236,6 +259,8 @@ export declare namespace charge { * @default `'push'` for JSON-RPC accounts, `'pull'` for local accounts. */ mode?: Methods.ChargeMode | undefined + /** Selects the account that signs this charge after the challenge and chain are known. */ + resolveAccount?: ResolveAccount | undefined } & Account.getResolver.Parameters & Client.getResolver.Parameters } diff --git a/src/tempo/client/ResolveAccount.ts b/src/tempo/client/ResolveAccount.ts new file mode 100644 index 00000000..a8b354f2 --- /dev/null +++ b/src/tempo/client/ResolveAccount.ts @@ -0,0 +1,46 @@ +import type * as Hex from 'ox/Hex' +import type { Account, Address } from 'viem' + +import type { MaybePromise } from '../../internal/types.js' + +/** Resolves the account that should satisfy an mppx account operation. */ +export type ResolveAccount = (info: ResolveAccountInfo) => MaybePromise + +/** Account-resolution details for a client credential operation. */ +export type ResolveAccountInfo = { + /** Account mppx will use when the hook returns `undefined`. */ + account: Account + /** EIP-155 chain ID used for the operation. */ + chainId: number + /** Capability the selected account must satisfy. */ + operation: ResolveAccountOperation +} + +/** Capability an mppx-selected account must satisfy. */ +export type ResolveAccountOperation = + | { + kind: 'executeCalls' + /** + * Exact EVM calls the selected account will execute. + * + * Omitted when the calls depend on which account is selected, such as + * account-balance-dependent auto-swap routing. + */ + calls?: readonly ResolveAccountCall[] | undefined + } + | { + kind: 'authorizePaymentChannel' + /** + * Signer required by an existing reusable channel. Omitted when opening + * a new channel or when no existing channel has fixed a signer yet. + */ + authority?: Address | undefined + } + +/** EVM call data used by account resolvers for scoped account selection. */ +export type ResolveAccountCall = { + /** Contract address being called. */ + to: Address + /** Calldata being sent. */ + data: Hex.Hex +} diff --git a/src/tempo/session/client/ChannelOps.ts b/src/tempo/session/client/ChannelOps.ts index 233f3a2f..be08def8 100644 --- a/src/tempo/session/client/ChannelOps.ts +++ b/src/tempo/session/client/ChannelOps.ts @@ -9,14 +9,7 @@ * @see https://tips.sh/1034-1 */ import { Hex } from 'ox' -import { - encodeFunctionData, - isAddress, - zeroAddress, - type Account, - type Address, - type Client, -} from 'viem' +import { encodeFunctionData, isAddress, type Account, type Address, type Client } from 'viem' import { prepareTransactionRequest, signTransaction } from 'viem/actions' import { Transaction } from 'viem/tempo' @@ -62,10 +55,6 @@ export type ChannelEntry = { opened: boolean } -function voucherAuthorizedSigner(address: Address): Address | undefined { - return address.toLowerCase() === zeroAddress ? undefined : address -} - function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null } @@ -169,7 +158,6 @@ export async function createVoucherPayload( { channelId, cumulativeAmount: amount }, escrow, chainId, - voucherAuthorizedSigner(descriptor.authorizedSigner), ) return { @@ -281,7 +269,6 @@ export async function createOpenPayload( { channelId, cumulativeAmount: initialAmount }, escrow, parameters.chainId, - voucherAuthorizedSigner(authorizedSigner), ) return { action: 'open', diff --git a/src/tempo/session/client/Session.test.ts b/src/tempo/session/client/Session.test.ts index 0658c4f9..344f713b 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,46 @@ describe('precompile client session', () => { expect(updates).toEqual([100n, 200n]) }) + test('passes existing channel authority to resolveAccount before reusing session channel', 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) { + 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]!.operation).toEqual({ kind: 'authorizePaymentChannel' }) + expect(calls[1]!.operation).toEqual({ + kind: 'authorizePaymentChannel', + authority: 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 fb96ff3d..60099dd2 100644 --- a/src/tempo/session/client/Session.ts +++ b/src/tempo/session/client/Session.ts @@ -5,6 +5,10 @@ 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 { + ResolveAccount as ResolveAccount_, + ResolveAccountInfo as ResolveAccountInfo_, +} from '../../client/ResolveAccount.js' import * as defaults from '../../internal/defaults.js' import * as Methods from '../../Methods.js' import { serializeCredential, type ChannelEntry } from './ChannelOps.js' @@ -13,6 +17,7 @@ import { executeCredentialPlan, planCredential, resolveChallengeContext, + resolveRecoverContext, sessionContextSchema, } from './CredentialState.js' @@ -34,6 +39,7 @@ export function session(parameters: session.Parameters = {}) { getClient: getClientParameter, maxDeposit: maxDepositParameter, onChannelUpdate, + resolveAccount, } = parameters const getClient = Client.getResolver({ chain: tempo_chain, @@ -62,8 +68,22 @@ 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) + // Resolve recovery hints early so account selection can satisfy an existing channel authority. + const recoverContext = resolveRecoverContext({ context, snapshot: resolved.snapshot }) + const descriptor = context?.action + ? context.descriptor + : (entry?.descriptor ?? recoverContext?.descriptor) + const account = + (await resolveAccount?.({ + account: defaultAccount, + chainId: resolved.chainId, + operation: { + kind: 'authorizePaymentChannel', + ...(descriptor ? { authority: descriptor.authorizedSigner } : {}), + }, + })) ?? defaultAccount const payload = await executeCredentialPlan( planCredential({ account, @@ -82,6 +102,9 @@ export function session(parameters: session.Parameters = {}) { /** Type helpers for the low-level TIP-1034 session client method. */ export declare namespace session { + type ResolveAccount = ResolveAccount_ + type ResolveAccountInfo = ResolveAccountInfo_ + type Parameters = Account.getResolver.Parameters & Client.getResolver.Parameters & { /** Pluggable persistence for reusable channels. Defaults to an in-memory store. */ @@ -94,5 +117,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/index.ts b/src/tempo/session/client/index.ts index b3ae2ed7..f624334c 100644 --- a/src/tempo/session/client/index.ts +++ b/src/tempo/session/client/index.ts @@ -37,3 +37,4 @@ export type { WithdrawableSessionState, } from './Runtime.js' export type { SessionSnapshot } from '../Snapshot.js' +export type { ResolveAccount, 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..2d1a16fc 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,54 @@ 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, + ) + + 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..c2ca0d4f 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: { @@ -72,7 +89,6 @@ export async function signVoucher( voucher: Voucher, verifyingContract: Address, chainId: number, - _authorizedSigner?: Address | undefined, ): Promise { const signature = await (async () => { if (verifyingContract.toLowerCase() === Channel.address.toLowerCase()) { @@ -94,19 +110,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 +133,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)