From 1a46b24ce606abd982fda1c451db4c35eae723c1 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:37:32 -0700 Subject: [PATCH 1/2] feat: add credential validation and settlement APIs --- .changeset/validate-settle-credentials.md | 5 + src/Method.ts | 39 +++ src/server/Mppx.test-d.ts | 2 + src/server/Mppx.test.ts | 228 ++++++++++++++++++ src/server/Mppx.ts | 232 ++++++++++++------ src/tempo/server/Charge.test.ts | 57 +++++ src/tempo/server/Charge.ts | 280 +++++++++++++++++++--- test/html/server.ts | 6 +- 8 files changed, 739 insertions(+), 110 deletions(-) create mode 100644 .changeset/validate-settle-credentials.md diff --git a/.changeset/validate-settle-credentials.md b/.changeset/validate-settle-credentials.md new file mode 100644 index 00000000..225af8b6 --- /dev/null +++ b/.changeset/validate-settle-credentials.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added pure credential validation and explicit credential settlement APIs. diff --git a/src/Method.ts b/src/Method.ts index 3afe7877..6635c279 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -117,12 +117,33 @@ export type VerifyContext = { request: z.input } +/** Validation hook parameters for a single method. */ +export type ValidateContext = VerifyContext + /** Response hook parameters for a single method. */ export type RespondContext = VerifyContext & { input: globalThis.Request receipt: Receipt.Receipt } +/** Non-mutating method-specific validation result. */ +export type Validation = Readonly<{ + challenge: Challenge.Challenge< + z.output, + method['intent'], + method['name'] + > + credential: Credential.Credential< + z.output, + Challenge.Challenge, method['intent'], method['name']> + > + details: details + intent: method['intent'] + method: method['name'] + request: z.output + source?: string | undefined +}> + /** * A server-side configured method with verification logic. */ @@ -141,8 +162,10 @@ export type Server< preflight?: PreflightFn | undefined request?: RequestFn | undefined respond?: RespondFn | undefined + settle?: SettleFn | undefined stableBinding?: StableBindingFn | undefined transport?: transportOverride | undefined + validate?: ValidateFn | undefined verify: VerifyFn } export type AnyServer = Server @@ -226,6 +249,16 @@ export type VerifyFn = ( parameters: VerifyContext, ) => Promise +/** Non-mutating validation function for a single method. */ +export type ValidateFn = ( + parameters: ValidateContext, +) => Promise> + +/** Mutating settlement function for a single method. */ +export type SettleFn = ( + parameters: VerifyContext, +) => Promise + /** * Optional respond function for a server-side method. * @@ -336,8 +369,10 @@ export function toServer< preflight, request, respond, + settle, stableBinding, transport, + validate, verify, } = options return { @@ -350,8 +385,10 @@ export function toServer< preflight, request, respond, + settle, stableBinding, transport, + validate, verify, } as Server> } @@ -374,8 +411,10 @@ export declare namespace toServer { preflight?: PreflightFn | undefined request?: RequestFn | undefined respond?: RespondFn | undefined + settle?: SettleFn | undefined stableBinding?: StableBindingFn | undefined transport?: transportOverride | Transport.AnyTransport | undefined + validate?: ValidateFn | undefined verify: VerifyFn } } diff --git a/src/server/Mppx.test-d.ts b/src/server/Mppx.test-d.ts index 7a677c05..d0252e94 100644 --- a/src/server/Mppx.test-d.ts +++ b/src/server/Mppx.test-d.ts @@ -223,6 +223,8 @@ describe('Mppx type tests', () => { const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) expectTypeOf(mppx.verifyCredential).toBeFunction() + expectTypeOf(mppx.settleCredential).toBeFunction() + expectTypeOf(mppx.validateCredential).toBeFunction() }) test('server events receive typed method context', () => { diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 091410f0..4fe8e77d 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -4869,6 +4869,234 @@ describe('verifyCredential', () => { expect(verifyArgs).toBeDefined() }) + test('validateCredential uses pure method validation without settlement', async () => { + const calls: string[] = [] + const splitServer = Method.toServer(mockCharge, { + async validate({ credential, request }) { + calls.push('validate') + return { + challenge: credential.challenge, + credential, + details: { token: credential.payload.token }, + intent: 'charge', + method: 'alpha', + request, + source: credential.source, + } + }, + async settle() { + calls.push('settle') + return mockReceipt('settled') + }, + async verify() { + calls.push('verify') + return mockReceipt('legacy') + }, + }) + const mppx = Mppx.create({ methods: [splitServer], realm, secretKey }) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + const validation = await mppx.validateCredential(credential) + + expect(validation.details).toEqual({ token: 'valid' }) + expect(calls).toEqual(['validate']) + }) + + test('settleCredential revalidates and uses method settlement', async () => { + const calls: string[] = [] + const splitServer = Method.toServer(mockCharge, { + async validate({ credential, request }) { + calls.push('validate') + return { + challenge: credential.challenge, + credential, + details: {}, + intent: 'charge', + method: 'alpha', + request, + source: credential.source, + } + }, + async settle() { + calls.push('settle') + return mockReceipt('settled') + }, + async verify() { + calls.push('verify') + return mockReceipt('legacy') + }, + }) + const mppx = Mppx.create({ methods: [splitServer], realm, secretKey }) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + const receipt = await mppx.settleCredential(credential) + + expect(receipt.method).toBe('settled') + expect(calls).toEqual(['validate', 'settle']) + }) + + test('verifyCredential remains a legacy alias for settlement', async () => { + const calls: string[] = [] + const splitServer = Method.toServer(mockCharge, { + async validate({ credential, request }) { + calls.push('validate') + return { + challenge: credential.challenge, + credential, + details: {}, + intent: 'charge', + method: 'alpha', + request, + source: credential.source, + } + }, + async settle() { + calls.push('settle') + return mockReceipt('settled') + }, + async verify() { + calls.push('verify') + return mockReceipt('legacy') + }, + }) + const mppx = Mppx.create({ methods: [splitServer], realm, secretKey }) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + const receipt = await mppx.verifyCredential(credential) + + expect(receipt.method).toBe('settled') + expect(calls).toEqual(['validate', 'settle']) + }) + + test('validateCredential rejects legacy-only methods without emitting payment failure', async () => { + const events: string[] = [] + const mppx = Mppx.create({ methods: [alphaChargeServer], realm, secretKey }) + mppx.onPaymentFailed((context) => { + events.push(context.error.name) + }) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + await expect(mppx.validateCredential(credential)).rejects.toThrow( + 'does not support non-mutating credential validation', + ) + + expect(events).toEqual([]) + }) + + test('validateCredential enforces supplied route requirements', async () => { + const splitServer = Method.toServer(mockCharge, { + async validate({ credential, request }) { + return { + challenge: credential.challenge, + credential, + details: { amount: request.amount }, + intent: 'charge', + method: 'alpha', + request, + source: credential.source, + } + }, + async settle() { + return mockReceipt('settled') + }, + async verify() { + return mockReceipt('legacy') + }, + }) + const mppx = Mppx.create({ methods: [splitServer], realm, secretKey }) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + const validation = await mppx.validateCredential(credential, { request: challengeOpts }) + expect(validation.details).toEqual({ amount: '1000' }) + + await expect( + mppx.validateCredential(credential, { + request: { + ...challengeOpts, + amount: '2000', + }, + }), + ).rejects.toThrow('credential amount does not match this route') + }) + + test('settleCredential emits payment failure when split validation fails', async () => { + const calls: string[] = [] + const events: string[] = [] + const splitServer = Method.toServer(mockCharge, { + async validate() { + calls.push('validate') + throw new Errors.VerificationFailedError({ reason: 'risk denied' }) + }, + async settle() { + calls.push('settle') + return mockReceipt('settled') + }, + async verify() { + calls.push('verify') + return mockReceipt('legacy') + }, + }) + const mppx = Mppx.create({ methods: [splitServer], realm, secretKey }) + mppx.onPaymentFailed((context) => { + events.push(context.error.name) + }) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + await expect(mppx.settleCredential(credential)).rejects.toThrow('risk denied') + + expect(calls).toEqual(['validate']) + expect(events).toEqual(['VerificationFailedError']) + }) + + test('route handlers revalidate before settlement for split methods', async () => { + const calls: string[] = [] + const splitServer = Method.toServer(mockCharge, { + async validate({ credential, request }) { + calls.push('validate') + return { + challenge: credential.challenge, + credential, + details: {}, + intent: 'charge', + method: 'alpha', + request, + source: credential.source, + } + }, + async settle() { + calls.push('settle') + return mockReceipt('settled') + }, + async verify() { + calls.push('verify') + return mockReceipt('legacy') + }, + }) + const mppx = Mppx.create({ methods: [splitServer], realm, secretKey }) + const firstResult = await mppx.charge(challengeOpts)( + new Request('https://api.example.com/resource'), + ) + expect(firstResult.status).toBe(402) + if (firstResult.status !== 402) throw new Error() + + const challenge = Challenge.fromResponse(firstResult.challenge) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + const result = await mppx.charge(challengeOpts)( + new Request('https://api.example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(result.status).toBe(200) + expect(calls).toEqual(['validate', 'settle']) + }) + test('verifies a parsed Credential object (charge)', async () => { verifyArgs = undefined const mppx = Mppx.create({ diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 343c4879..c3f9f272 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -267,6 +267,27 @@ export type Mppx< credential: string | Credential.Credential, options?: VerifyCredentialOptions | undefined, ): Promise + /** + * Validate a credential without consuming or settling it. + * + * This is an advisory pre-check. Methods that support it must not write + * replay state, sign fee-payer transactions, broadcast, or otherwise + * mutate payment state. Use `settleCredential()` when accepting payment. + */ + validateCredential( + credential: string | Credential.Credential, + options?: VerifyCredentialOptions | undefined, + ): Promise + /** + * Re-validates and consumes/settles a credential, returning a receipt. + * + * `verifyCredential()` is retained as a backwards-compatible alias for + * this mutating path. + */ + settleCredential( + credential: string | Credential.Credential, + options?: VerifyCredentialOptions | undefined, + ): Promise } /** Extracts the transport override from a method, if any. */ @@ -292,7 +313,9 @@ const reservedMppxKeyValues = [ 'onPaymentFailed', 'onPaymentSuccess', 'realm', + 'settleCredential', 'transport', + 'validateCredential', 'verifyCredential', ] as const @@ -457,9 +480,11 @@ export function create< preflight: mi.preflight as never, request: mi.request as never, respond: mi.respond as never, + settle: mi.settle as never, secretKey, stableBinding: mi.stableBinding as never, transport: (mi.transport ?? transport) as never, + validate: mi.validate as never, verify: mi.verify as never, }) const wireKey = `${mi.name}/${mi.intent}` @@ -496,17 +521,16 @@ export function create< }) } - // verifyCredential: single-call end-to-end verification - async function verifyCredentialFn( + async function prepareStandaloneCredential( input: string | Credential.Credential, - options?: VerifyCredentialOptions, - ): Promise { + options: VerifyCredentialOptions | undefined, + parameters: { emitFailures?: boolean | undefined; requireValidate?: boolean | undefined } = {}, + ) { const credential = hydrateCredentialMeta( typeof input === 'string' ? Credential.deserialize(input) : input, ) - // Find matching method by name + intent, then use method-details markers - // when multiple handlers intentionally share the same wire identity. + const emitFailures = parameters.emitFailures === true const { method: credMethod, intent: credIntent } = credential.challenge const methodCandidates = (methods as readonly Method.AnyServer[]).filter( (m) => m.name === credMethod && m.intent === credIntent, @@ -515,106 +539,99 @@ export function create< const eventMethod = mi ?? ({ intent: credIntent, name: credMethod } satisfies ServerMethodDescriptor) - const emitStandalonePaymentFailed = async (parameters: { + const emitStandalonePaymentFailed = async (failure: { challenge: Challenge.Challenge credential: Credential.Credential | null error: Errors.PaymentError request: Record submittedChallenge?: Challenge.Challenge | undefined }) => { + if (!emitFailures) return await serverEvents.emit( 'payment.failed', createPaymentFailedContext({ capturedRequest: options?.capturedRequest, - challenge: parameters.challenge, - credential: parameters.credential, - error: parameters.error, + challenge: failure.challenge, + credential: failure.credential, + error: failure.error, method: eventMethod, - request: parameters.request, - submittedChallenge: parameters.submittedChallenge, + request: failure.request, + submittedChallenge: failure.submittedChallenge, }) as never, ) } - if (!mi) { - const error = new Errors.InvalidChallengeError({ - id: credential.challenge.id, - reason: `no registered method for ${credMethod}/${credIntent}`, - }) + const fail = async ( + error: Errors.PaymentError, + failure: { + credential?: Credential.Credential | null | undefined + request?: Record | undefined + submittedChallenge?: Challenge.Challenge | undefined + } = {}, + thrown: unknown = error, + ): Promise => { await emitStandalonePaymentFailed({ challenge: credential.challenge, - credential, + credential: failure.credential ?? credential, error, - request: credential.challenge.request as Record, - submittedChallenge: credential.challenge, + request: failure.request ?? (credential.challenge.request as Record), + submittedChallenge: failure.submittedChallenge ?? credential.challenge, }) - throw error + throw thrown + } + + if (!mi) { + await fail( + new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: `no registered method for ${credMethod}/${credIntent}`, + }), + ) } - // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create()) + if (parameters.requireValidate && !mi.validate) + await fail( + new Errors.VerificationFailedError({ + reason: `${credMethod}/${credIntent} does not support non-mutating credential validation`, + }), + ) + if (!Challenge.verify(credential.challenge, { secretKey: secretKey! })) { - const error = new Errors.InvalidChallengeError({ - id: credential.challenge.id, - reason: 'challenge was not issued by this server', - }) - await emitStandalonePaymentFailed({ - challenge: credential.challenge, - credential, - error, - request: credential.challenge.request as Record, - submittedChallenge: credential.challenge, - }) - throw error + await fail( + new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: 'challenge was not issued by this server', + }), + ) } - // Expiry check try { Expires.assert(credential.challenge.expires, credential.challenge.id) } catch (e) { - if (e instanceof Errors.PaymentError) - await emitStandalonePaymentFailed({ - challenge: credential.challenge, - credential, - error: e, - request: credential.challenge.request as Record, - submittedChallenge: credential.challenge, - }) + if (e instanceof Errors.PaymentError) await fail(e) throw e } - // Validate payload against method schema - let parsedCredential: Credential.Credential + let parsedCredential!: Credential.Credential try { parsedCredential = withParsedCredentialPayload( credential, mi.schema.credential.payload.parse(credential.payload), ) } catch (e) { - await emitStandalonePaymentFailed({ - challenge: credential.challenge, - credential, - error: new Errors.InvalidPayloadError(), - request: credential.challenge.request as Record, - submittedChallenge: credential.challenge, - }) - throw e + await fail(new Errors.InvalidPayloadError(), {}, e) } const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope }) if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) { - const error = new Errors.InvalidChallengeError({ - id: credential.challenge.id, - reason: "credential scope does not match this route's requirements", - }) - await emitStandalonePaymentFailed({ - challenge: credential.challenge, - credential: parsedCredential, - error, - request: credential.challenge.request as Record, - submittedChallenge: credential.challenge, - }) - throw error + await fail( + new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: "credential scope does not match this route's requirements", + }), + { credential: parsedCredential }, + ) } const shouldValidateRoute = @@ -660,12 +677,9 @@ export function create< : (credential.challenge.request as z.input) } catch (e) { if (e instanceof Errors.PaymentError) - await emitStandalonePaymentFailed({ - challenge: credential.challenge, + await fail(e, { credential: parsedCredential, - error: e, request: credential.challenge.request as Record, - submittedChallenge: credential.challenge, }) throw e } @@ -679,17 +693,72 @@ export function create< } as Method.VerifiedChallengeEnvelope) : undefined + return { + credential, + envelope, + eventMethod, + method: mi, + parsedCredential, + parsedRequest, + request, + } + } + + async function validateCredentialFn( + input: string | Credential.Credential, + options?: VerifyCredentialOptions, + ): Promise { + const prepared = await prepareStandaloneCredential(input, options, { requireValidate: true }) + return prepared.method.validate!({ + credential: prepared.parsedCredential, + envelope: prepared.envelope, + request: prepared.request, + } as never) + } + + // settleCredential: single-call end-to-end verification and settlement + async function settleCredentialFn( + input: string | Credential.Credential, + options?: VerifyCredentialOptions, + ): Promise { + const prepared = await prepareStandaloneCredential(input, options, { emitFailures: true }) + const { method: mi, parsedCredential, parsedRequest, request, envelope } = prepared + + const emitStandalonePaymentFailed = async (parameters: { + challenge: Challenge.Challenge + credential: Credential.Credential | null + error: Errors.PaymentError + request: Record + submittedChallenge?: Challenge.Challenge | undefined + }) => { + await serverEvents.emit( + 'payment.failed', + createPaymentFailedContext({ + capturedRequest: options?.capturedRequest, + challenge: parameters.challenge, + credential: parameters.credential, + error: parameters.error, + method: prepared.eventMethod, + request: parameters.request, + submittedChallenge: parameters.submittedChallenge, + }) as never, + ) + } + let receipt: Receipt.Receipt try { - receipt = await mi.verify({ credential: parsedCredential, envelope, request } as never) + if (mi.settle && mi.validate) + await mi.validate({ credential: parsedCredential, envelope, request } as never) + const settle = mi.settle ?? mi.verify + receipt = await settle({ credential: parsedCredential, envelope, request } as never) } catch (e) { const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError() await emitStandalonePaymentFailed({ - challenge: credential.challenge, + challenge: prepared.credential.challenge, credential: parsedCredential, error, request: parsedRequest, - submittedChallenge: credential.challenge, + submittedChallenge: prepared.credential.challenge, }) throw e } @@ -698,7 +767,7 @@ export function create< 'payment.success', createPaymentSuccessContext({ capturedRequest: options?.capturedRequest, - challenge: credential.challenge, + challenge: prepared.credential.challenge, credential: parsedCredential, envelope, method: mi, @@ -710,6 +779,8 @@ export function create< return receipt } + const verifyCredentialFn = settleCredentialFn + function composeFn( ...entries: readonly [ Method.AnyServer | AnyMethodFnWithMethod | string, @@ -760,7 +831,9 @@ export function create< onPaymentFailed, onPaymentSuccess, realm: realm as string | undefined, + settleCredential: settleCredentialFn, transport, + validateCredential: validateCredentialFn, verifyCredential: verifyCredentialFn, ...handlers, } as never @@ -809,8 +882,10 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R realm, respond, secretKey, + settle, stableBinding, transport, + validate, verify, } = parameters @@ -1256,7 +1331,14 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R // If verification fails, re-issue the challenge so the client can retry. let receiptData: Receipt.Receipt try { - receiptData = await verify({ credential: parsedCredential, envelope, request } as never) + if (settle && validate) + await validate({ credential: parsedCredential, envelope, request } as never) + const settleCredential = settle ?? verify + receiptData = await settleCredential({ + credential: parsedCredential, + envelope, + request, + } as never) } catch (e) { if (!(e instanceof Errors.PaymentError)) console.error('mppx: internal verification error', e) @@ -1375,9 +1457,11 @@ declare namespace createMethodFn { realm: string | undefined request?: Method.RequestFn respond?: Method.RespondFn + settle?: Method.SettleFn secretKey: string stableBinding?: Method.StableBindingFn transport: transport + validate?: Method.ValidateFn verify: Method.VerifyFn } diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 2ac24f3e..0d6996f0 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -1531,6 +1531,63 @@ describe('tempo', () => { httpServer.close() }) + test('behavior: validates fee-payer pull credential before settlement', async () => { + const chargeServer = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return client + }, + currency: asset, + account: accounts[0], + feePayer: accounts[0], + store: Store.memory(), + }), + ], + realm, + secretKey, + }) + const mppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client({ + account: accounts[1], + getClient() { + return client + }, + }), + ], + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + chargeServer.charge({ + amount: '1', + currency: asset, + recipient: accounts[0].address, + }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await fetch(httpServer.url) + expect(response.status).toBe(402) + + const credential = await mppx.createCredential(response) + const validation = await chargeServer.validateCredential(credential) + + expect(validation.details).toMatchObject({ + mode: 'pull', + sender: accounts[1].address.toLowerCase(), + }) + + const receipt = await chargeServer.verifyCredential(credential) + expect(receipt.status).toBe('success') + + httpServer.close() + }) + test('behavior: fee payer simulates before broadcasting in confirmation mode', async () => { const rpcMethods: string[] = [] const interceptingClient = createClient({ diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 555b6ce7..5bcc5788 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -87,6 +87,59 @@ export function charge( rpcUrl: defaults.rpcUrl, }) + async function resolveCredentialContext(parameters: { + credential: Method.VerifyContext['credential'] + request: Method.VerifyContext['request'] + }) { + const { credential, request } = parameters + const { challenge } = credential + const resolvedRequest = (() => { + const parsed = Methods.charge.schema.request.safeParse(request) + if (parsed.success) return parsed.data + // verifyCredential() passes the HMAC-bound challenge request, which is + // already in canonical output form and should not be transformed again. + return request as unknown as z.output + })() + const chainId = resolvedRequest.methodDetails?.chainId ?? request.chainId + const client = await getClient({ chainId }) + + const { amount, methodDetails } = resolvedRequest + const requestAllowsFeePayer = + request.feePayer !== false && + (request.feePayer === undefined || + request.feePayer === true || + typeof request.feePayer === 'object') + const supportedModes = methodDetails?.supportedModes as + | readonly Methods.ChargeMode[] + | undefined + const currency = resolvedRequest.currency as `0x${string}` + const recipient = resolvedRequest.recipient as `0x${string}` + const memo = methodDetails?.memo as `0x${string}` | undefined + const payload = credential.payload + const isZeroAmount = BigInt(amount) === 0n + + Expires.assert(challenge.expires, challenge.id) + + if (isZeroAmount && payload.type !== 'proof') + throw new MismatchError('Zero-amount challenges require a proof credential.', {}) + + return { + amount, + chainId, + challenge, + client, + currency, + isZeroAmount, + memo, + methodDetails, + payload, + recipient, + requestAllowsFeePayer, + resolvedRequest, + supportedModes, + } + } + type Defaults = charge.DeriveDefaults return Method.toServer(Methods.charge, { defaults: { @@ -168,25 +221,195 @@ export function charge( } }, - async verify({ credential, request }) { - const { challenge } = credential - const resolvedRequest = (() => { - const parsed = Methods.charge.schema.request.safeParse(request) - if (parsed.success) return parsed.data - // verifyCredential() passes the HMAC-bound challenge request, which is - // already in canonical output form and should not be transformed again. - return request as unknown as z.output - })() - const chainId = resolvedRequest.methodDetails?.chainId ?? request.chainId + async validate({ credential, request }) { + const { + amount, + chainId, + challenge, + client, + currency, + isZeroAmount, + memo, + methodDetails, + payload, + recipient, + requestAllowsFeePayer, + resolvedRequest, + supportedModes, + } = await resolveCredentialContext({ credential, request }) + const isFeePayerTx = + methodDetails?.feePayer === true && + requestAllowsFeePayer && + !!(typeof request.feePayer === 'object' ? request.feePayer : feePayer || feePayerUrl) + + const details: charge.ValidationDetails = { + mode: payload.type === 'hash' ? 'push' : payload.type === 'transaction' ? 'pull' : 'proof', + } + + switch (payload.type) { + case 'hash': { + if (supportedModes && !supportedModes.includes('push')) + throw new MismatchError('Hash credentials are not supported for this challenge.', {}) + + const source = parseHashCredentialSource({ + chainId: chainId ?? client.chain?.id, + source: credential.source, + }) + const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) + const receipt = await getTransactionReceipt(client, { + hash: payload.hash as `0x${string}`, + }) + const sender = source?.address ?? receipt.from + const matchedLogs = await assertTransferLogs(receipt, { + currency, + sender, + source, + transfers, + validateSender, + }) + if (!memo) + assertChallengeBoundMemo(matchedLogs, { + challengeId: challenge.id, + realm: challenge.realm, + }) + toReceipt(receipt) + details.sender = sender + details.transfers = transfers + break + } + + case 'proof': { + if (!isZeroAmount) + throw new MismatchError( + 'Proof credentials are only valid for zero-amount challenges.', + {}, + ) + + const expectedSource = credential.source + if (!expectedSource) + throw new MismatchError('Proof credential must include a source.', {}) + + const resolvedChainId = challenge.request.methodDetails?.chainId ?? chainId! + const source = Proof.parsePkhSource(expectedSource) + + if (!source || source.chainId !== resolvedChainId) { + throw new MismatchError('Proof credential source is invalid.', {}) + } + + const valid = await verifyTypedData(client, { + address: source.address, + ...Proof.typedData({ + account: source.address, + chainId: resolvedChainId, + challengeId: challenge.id, + realm: challenge.realm, + }), + signature: payload.signature as `0x${string}`, + }) + if (!valid) { + const proofSigner = recoverAuthorizedProofSigner({ + chainId: resolvedChainId, + challengeId: challenge.id, + realm: challenge.realm, + signature: payload.signature as `0x${string}`, + sourceAddress: source.address, + }) + const authorized = proofSigner + ? await isActiveAccessKey(client, { + accessKey: proofSigner, + account: source.address, + }) + : false + if (!authorized) throw new MismatchError('Proof signature does not match source.', {}) + } + details.sender = source.address + break + } + + case 'transaction': { + if (supportedModes && !supportedModes.includes('pull')) + throw new MismatchError( + 'Transaction credentials are not supported for this challenge.', + {}, + ) + + const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo + if (!FeePayer.isTempoTransaction(serializedTransaction)) + throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {}) - const client = await getClient({ chainId }) + const transaction = Transaction.deserialize(serializedTransaction) + if (!transaction.signature || !transaction.from) + throw new MismatchError( + 'Transaction must be signed by the sender before fee payer co-signing.', + {}, + ) - const { amount, methodDetails } = resolvedRequest - const requestAllowsFeePayer = - request.feePayer !== false && - (request.feePayer === undefined || - request.feePayer === true || - typeof request.feePayer === 'object') + const calls = (transaction.calls ?? []) as readonly { + data?: `0x${string}` | undefined + to?: `0x${string}` | undefined + }[] + const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) + const matchedCalls = assertTransferCalls(calls, { + currency, + exactCount: isFeePayerTx, + transfers, + }) + if (!memo) + assertChallengeBoundCallMemo(matchedCalls, { + challengeId: challenge.id, + realm: challenge.realm, + }) + + if (isFeePayerTx) { + FeePayer.validateCalls( + transaction.calls, + { amount, currency, recipient }, + { currency, expectedTransfers: transfers }, + ) + FeePayer.assertAllowedFeeToken(transaction, FeePayer.defaultAllowedFeeTokens(chainId)) + } else { + await viem_call( + client, + FeePayer.simulationTransaction(transaction, { feePayer: false }) as never, + ) + } + + details.sender = transaction.from as `0x${string}` + details.serializedTransaction = serializedTransaction + details.transfers = transfers + break + } + + default: + throw new Error(`Unsupported credential type "${(payload as { type: string }).type}".`) + } + + return { + challenge, + credential, + details, + intent: 'charge', + method: 'tempo', + request: resolvedRequest, + source: credential.source, + } + }, + + async verify({ credential, request }) { + const { + amount, + chainId, + challenge, + client, + currency, + isZeroAmount, + memo, + methodDetails, + payload, + recipient, + requestAllowsFeePayer, + supportedModes, + } = await resolveCredentialContext({ credential, request }) const feePayerAccount = methodDetails?.feePayer === true && requestAllowsFeePayer ? typeof request.feePayer === 'object' @@ -194,22 +417,6 @@ export function charge( : feePayer : undefined const expires = challenge.expires - const supportedModes = methodDetails?.supportedModes as - | readonly Methods.ChargeMode[] - | undefined - - const currency = resolvedRequest.currency as `0x${string}` - const recipient = resolvedRequest.recipient as `0x${string}` - - Expires.assert(expires, challenge.id) - - const memo = methodDetails?.memo as `0x${string}` | undefined - - const payload = credential.payload - const isZeroAmount = BigInt(amount) === 0n - - if (isZeroAmount && payload.type !== 'proof') - throw new MismatchError('Zero-amount challenges require a proof credential.', {}) switch (payload.type) { case 'hash': { @@ -542,6 +749,13 @@ export declare namespace charge { type ValidateSender = (parameters: ValidateSenderParameters) => boolean | Promise + type ValidationDetails = { + mode: 'proof' | 'pull' | 'push' + sender?: `0x${string}` | undefined + serializedTransaction?: Transaction.TransactionSerializedTempo | undefined + transfers?: readonly ExpectedTransfer[] | undefined + } + type ValidateSenderParameters = { /** Actual TIP-20 `Transfer.from` address. */ sender: `0x${string}` diff --git a/test/html/server.ts b/test/html/server.ts index 954bd8a4..9142de9b 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -29,9 +29,9 @@ export async function startServer(port: number): Promise { const createTokenUrl = '/stripe/create-spt' const feePayerPolicy = { - maxFeePerGas: 200_000_000_000n, - maxPriorityFeePerGas: 200_000_000_000n, - maxTotalFee: 500_000_000_000_000_000n, + maxFeePerGas: 2_000_000_000_000n, + maxPriorityFeePerGas: 2_000_000_000_000n, + maxTotalFee: 5_000_000_000_000_000_000n, } const tempoMppx = Mppx.create({ methods: [ From e15a622922d1252530547c4cfa5b0ef1217dcf5c Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:17:58 -0700 Subject: [PATCH 2/2] fix: harden tempo credential validation --- src/tempo/server/Charge.test.ts | 95 ++++++++++++++++++- src/tempo/server/Charge.ts | 160 +++++++++++++++++++++++++++++--- 2 files changed, 242 insertions(+), 13 deletions(-) diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 0d6996f0..d58503d4 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -360,6 +360,9 @@ describe('tempo', () => { } expect(await dedupStore.get(`tenant:mppx:charge:${receipt.transactionHash}`)).not.toBeNull() expect(await dedupStore.get(`mppx:charge:${receipt.transactionHash}`)).toBeNull() + await expect( + dedupServer.validateCredential(Credential.serialize(credential1)), + ).rejects.toThrow('Transaction hash has already been used') const response2 = await fetch(httpServer.url) expect(response2.status).toBe(402) @@ -1584,10 +1587,84 @@ describe('tempo', () => { const receipt = await chargeServer.verifyCredential(credential) expect(receipt.status).toBe('success') + await expect(chargeServer.validateCredential(credential)).rejects.toThrow( + 'Transaction hash has already been used', + ) httpServer.close() }) + test('behavior: rejects pull credential with forged explicit sender slot', async () => { + const chargeServer = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return client + }, + currency: asset, + account: accounts[0], + store: Store.memory(), + }), + ], + realm, + secretKey, + }) + const mppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client({ + account: accounts[1], + mode: 'pull', + getClient() { + return client + }, + }), + ], + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + chargeServer.charge({ + amount: '1', + currency: asset, + recipient: accounts[0].address, + }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + try { + const response = await fetch(httpServer.url) + expect(response.status).toBe(402) + + const credential = await mppx.createCredential(response) + const decoded = Credential.deserialize<{ + signature: Transaction.TransactionSerializedTempo + type: 'transaction' + }>(credential) + const forgedTransaction = TxEnvelopeTempo.serialize( + TxEnvelopeTempo.deserialize(decoded.payload.signature), + { format: 'feePayer', sender: accounts[2].address }, + ) + const forgedCredential = Credential.serialize( + Credential.from({ + challenge: decoded.challenge, + payload: { signature: forgedTransaction, type: 'transaction' as const }, + }), + ) + + await expect(chargeServer.validateCredential(forgedCredential)).rejects.toThrow( + 'Transaction signature does not match sender.', + ) + await expect(chargeServer.verifyCredential(forgedCredential)).rejects.toThrow( + 'Transaction signature does not match sender.', + ) + } finally { + httpServer.close() + } + }) + test('behavior: fee payer simulates before broadcasting in confirmation mode', async () => { const rpcMethods: string[] = [] const interceptingClient = createClient({ @@ -1975,6 +2052,9 @@ describe('tempo', () => { `mppx:charge:sponsor:${chain.id}:${accounts[1].address.toLowerCase()}`, ), ).toBeNull() + await expect(serverWithSlowSimulation.validateCredential(credential2)).rejects.toThrow( + 'Sponsored transaction from this sender is already in flight', + ) const second = fetch(httpServer.url, { headers: { Authorization: credential2 } }) try { @@ -2188,7 +2268,17 @@ describe('tempo', () => { res.end('OK') }) - const response = await mppx.fetch(httpServer.url) + const challengeResponse = await fetch(httpServer.url) + expect(challengeResponse.status).toBe(402) + + const credential = await mppx.createCredential(challengeResponse) + const validation = await serverWithFeePayer.validateCredential(credential) + expect((validation.details as { settleability?: string }).settleability).toBe('shape-only') + expect(feePayerRequests).toEqual([]) + + const response = await fetch(httpServer.url, { + headers: { Authorization: credential }, + }) expect(response.status).toBe(200) expect(feePayerRequests.map(({ method }) => method)).toEqual(['eth_fillTransaction']) @@ -3191,6 +3281,9 @@ describe('tempo', () => { headers: { Authorization: Credential.serialize(credential) }, }) expect(response2.status).toBe(200) + await expect(server_.validateCredential(Credential.serialize(credential))).rejects.toThrow( + 'Proof credential has already been used', + ) const replayResponse = await fetch(httpServer.url, { headers: { Authorization: Credential.serialize(credential) }, diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 5bcc5788..0131120d 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -1,3 +1,4 @@ +import { TxEnvelopeTempo } from 'ox/tempo' import * as SignatureEnvelope from 'ox/tempo/SignatureEnvelope' import { decodeFunctionData, @@ -237,10 +238,9 @@ export function charge( resolvedRequest, supportedModes, } = await resolveCredentialContext({ credential, request }) - const isFeePayerTx = - methodDetails?.feePayer === true && - requestAllowsFeePayer && - !!(typeof request.feePayer === 'object' ? request.feePayer : feePayer || feePayerUrl) + const isFeePayerTx = methodDetails?.feePayer === true && requestAllowsFeePayer + const feePayerAccount = + isFeePayerTx && typeof request.feePayer === 'object' ? request.feePayer : feePayer const details: charge.ValidationDetails = { mode: payload.type === 'hash' ? 'push' : payload.type === 'transaction' ? 'pull' : 'proof', @@ -251,13 +251,15 @@ export function charge( if (supportedModes && !supportedModes.includes('push')) throw new MismatchError('Hash credentials are not supported for this challenge.', {}) + const hash = payload.hash as `0x${string}` + await assertHashUnused(store, hash) const source = parseHashCredentialSource({ chainId: chainId ?? client.chain?.id, source: credential.source, }) const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) const receipt = await getTransactionReceipt(client, { - hash: payload.hash as `0x${string}`, + hash, }) const sender = source?.address ?? receipt.from const matchedLogs = await assertTransferLogs(receipt, { @@ -274,6 +276,7 @@ export function charge( }) toReceipt(receipt) details.sender = sender + details.settleability = 'checked' details.transfers = transfers break } @@ -296,6 +299,8 @@ export function charge( throw new MismatchError('Proof credential source is invalid.', {}) } + if (proofStore) await assertProofUnused(proofStore, challenge.id) + const valid = await verifyTypedData(client, { address: source.address, ...Proof.typedData({ @@ -323,6 +328,7 @@ export function charge( if (!authorized) throw new MismatchError('Proof signature does not match source.', {}) } details.sender = source.address + details.settleability = 'checked' break } @@ -334,6 +340,9 @@ export function charge( ) const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo + const hash = keccak256(serializedTransaction) + await assertHashUnused(store, hash) + if (!FeePayer.isTempoTransaction(serializedTransaction)) throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {}) @@ -343,6 +352,7 @@ export function charge( 'Transaction must be signed by the sender before fee payer co-signing.', {}, ) + const sender = assertTransactionSender(transaction) const calls = (transaction.calls ?? []) as readonly { data?: `0x${string}` | undefined @@ -361,12 +371,49 @@ export function charge( }) if (isFeePayerTx) { + const reservationChainId = chainId ?? client.chain!.id + await assertSponsoredSenderNotInFlight(store, { + chainId: reservationChainId, + sender, + }) FeePayer.validateCalls( transaction.calls, { amount, currency, recipient }, { currency, expectedTransfers: transfers }, ) - FeePayer.assertAllowedFeeToken(transaction, FeePayer.defaultAllowedFeeTokens(chainId)) + const allowedFeeTokens = FeePayer.defaultAllowedFeeTokens(chainId) + FeePayer.assertAllowedFeeToken(transaction, allowedFeeTokens) + FeePayer.assertTransactionPolicy({ + challengeExpires: challenge.expires, + chainId: reservationChainId, + details: { amount, currency, recipient }, + policy: feePayerPolicy, + transaction: { + ...transaction, + feeToken: transaction.feeToken ?? defaults.tokens.pathUsd, + }, + }) + if (feePayerAccount) { + const sponsored = FeePayer.prepareSponsoredTransaction({ + account: feePayerAccount, + allowedFeeTokens, + challengeExpires: challenge.expires, + chainId: reservationChainId, + details: { amount, currency, recipient }, + policy: feePayerPolicy, + transaction: { + ...transaction, + feeToken: transaction.feeToken ?? defaults.tokens.pathUsd, + }, + }) + await viem_call(client, { + ...sponsored, + account: sender, + feePayer: feePayerAccount.address, + feePayerSignature: undefined, + signature: undefined, + } as never) + } } else { await viem_call( client, @@ -374,7 +421,8 @@ export function charge( ) } - details.sender = transaction.from as `0x${string}` + details.sender = sender + details.settleability = isFeePayerTx && !feePayerAccount ? 'shape-only' : 'checked' details.serializedTransaction = serializedTransaction details.transfers = transfers break @@ -566,6 +614,7 @@ export function charge( 'Transaction must be signed by the sender before fee payer co-signing.', {}, ) + const sender = assertTransactionSender(transaction) const calls = (transaction.calls ?? []) as readonly { data?: `0x${string}` | undefined @@ -592,7 +641,7 @@ export function charge( if ( !(await markSponsoredSenderInFlight(store, { chainId: reservationChainId, - sender: transaction.from as `0x${string}`, + sender, })) ) { throw new VerificationFailedError({ @@ -601,7 +650,7 @@ export function charge( } sponsoredSenderReservation = { chainId: reservationChainId, - sender: transaction.from as `0x${string}`, + sender, } FeePayer.validateCalls( transaction.calls, @@ -638,7 +687,7 @@ export function charge( // sponsor that pays gas. simulationRequest = { ...sponsored, - account: transaction.from, + account: sender, feePayer: feePayerAccount.address, feePayerSignature: undefined, signature: undefined, @@ -659,7 +708,7 @@ export function charge( // fee token), mirroring the local-sponsor path. simulationRequest = { ...transaction, - account: transaction.from, + account: sender, feePayer: hosted.feePayer, feePayerSignature: undefined, feeToken: hosted.feeToken, @@ -680,7 +729,7 @@ export function charge( }) const matchedLogs = await assertTransferLogs(receipt, { currency, - sender: transaction.from! as `0x${string}`, + sender, transfers, }) if (!memo) @@ -752,6 +801,11 @@ export declare namespace charge { type ValidationDetails = { mode: 'proof' | 'pull' | 'push' sender?: `0x${string}` | undefined + /** + * Whether validation fully checked current settleability or only performed + * local shape/policy checks before settlement completes remote sponsorship. + */ + settleability?: 'checked' | 'shape-only' | undefined serializedTransaction?: Transaction.TransactionSerializedTempo | undefined transfers?: readonly ExpectedTransfer[] | undefined } @@ -1160,6 +1214,62 @@ async function isValidTransferSender(parameters: { }) } +type TempoTransaction = ReturnType<(typeof Transaction)['deserialize']> +type SignatureEnvelopeValue = ReturnType<(typeof SignatureEnvelope)['deserialize']> + +function assertTransactionSender(transaction: TempoTransaction): `0x${string}` { + const { from, signature } = transaction + if (!signature || !from) + throw new MismatchError( + 'Transaction must be signed by the sender before fee payer co-signing.', + {}, + ) + + const payload = TxEnvelopeTempo.getSignPayload( + TxEnvelopeTempo.from({ + ...transaction, + feePayerSignature: transaction.feePayerSignature === undefined ? undefined : null, + from: undefined, + signature: undefined, + } as never), + ) + const signer = recoverSignatureEnvelopeSender(signature, payload) + const sender = from as `0x${string}` + if (!TempoAddress.isEqual(signer, sender)) + throw new MismatchError('Transaction signature does not match sender.', {}) + return sender +} + +function recoverSignatureEnvelopeSender( + signature: NonNullable, + payload: `0x${string}`, +): `0x${string}` { + const envelope = SignatureEnvelope.from(signature as never) as SignatureEnvelopeValue + + if (envelope.type === 'keychain') { + const userAddress = envelope.userAddress as `0x${string}` + const keychainPayload = + envelope.version === 'v1' + ? payload + : keccak256(`0x04${payload.slice(2)}${userAddress.slice(2)}` as `0x${string}`) + recoverSignatureEnvelopeSender(envelope.inner as never, keychainPayload) + return userAddress + } + + if (envelope.type === 'multisig') return envelope.account as `0x${string}` + + const signer = SignatureEnvelope.extractAddress({ + payload, + signature: envelope, + }) as `0x${string}` + const valid = SignatureEnvelope.verify(envelope, { + address: signer, + payload, + }) + if (!valid) throw new MismatchError('Transaction signature does not match sender.', {}) + return signer +} + /** @internal */ function getHashStoreKey(hash: `0x${string}`): `mppx:charge:${string}` { return `mppx:charge:${hash.toLowerCase()}` @@ -1178,6 +1288,14 @@ function getSponsoredSenderStoreKey(parameters: { return `mppx:charge:sponsor:${parameters.chainId}:${parameters.sender.toLowerCase()}` } +async function assertHashUnused( + store: Store.AtomicStore, + hash: `0x${string}`, +): Promise { + if ((await store.get(getHashStoreKey(hash))) !== null) + throw new VerificationFailedError({ reason: 'Transaction hash has already been used' }) +} + async function markHashUsed( store: Store.AtomicStore, hash: `0x${string}`, @@ -1211,6 +1329,16 @@ function parseHashCredentialSource(parameters: { return parsed } +async function assertSponsoredSenderNotInFlight( + store: Store.AtomicStore, + parameters: { chainId: number; sender: `0x${string}` }, +): Promise { + if ((await store.get(getSponsoredSenderStoreKey(parameters))) !== null) + throw new VerificationFailedError({ + reason: 'Sponsored transaction from this sender is already in flight', + }) +} + /** @internal */ async function markSponsoredSenderInFlight( store: Store.AtomicStore, @@ -1230,6 +1358,14 @@ async function releaseSponsoredSenderInFlight( await store.delete(getSponsoredSenderStoreKey(parameters)) } +async function assertProofUnused( + store: Store.AtomicStore, + challengeId: string, +): Promise { + if ((await store.get(getProofStoreKey(challengeId))) !== null) + throw new VerificationFailedError({ reason: 'Proof credential has already been used' }) +} + /** @internal */ async function markProofUsed( store: Store.AtomicStore,