From 6d11a59397fc9e3ed84095d41aff566d2cbf2ff2 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/7] 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 23403470..f1b8b4a6 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 c4546f5b..b67422f2 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -4872,6 +4872,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 e42295e4..2f52fd1b 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -1546,6 +1546,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 f1c6b1d3..7877429a 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -91,6 +91,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: { @@ -172,25 +225,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' @@ -198,22 +421,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': { @@ -547,6 +754,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 c6992de747dfe87310e053e9e184062afa8f1600 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:51:31 -0700 Subject: [PATCH 2/7] refactor: rename credential settlement to broadcast --- .changeset/validate-settle-credentials.md | 2 +- src/Method.test.ts | 87 ++++++++++++- src/Method.ts | 143 ++++++++++++++++++++-- src/server/Mppx.test-d.ts | 2 +- src/server/Mppx.test.ts | 58 ++++----- src/server/Mppx.ts | 69 ++++------- src/tempo/server/Charge.ts | 2 +- 7 files changed, 273 insertions(+), 90 deletions(-) diff --git a/.changeset/validate-settle-credentials.md b/.changeset/validate-settle-credentials.md index 225af8b6..8c6941a4 100644 --- a/.changeset/validate-settle-credentials.md +++ b/.changeset/validate-settle-credentials.md @@ -2,4 +2,4 @@ 'mppx': patch --- -Added pure credential validation and explicit credential settlement APIs. +Added pure credential validation and explicit credential broadcast APIs. diff --git a/src/Method.test.ts b/src/Method.test.ts index 60985b7f..94a52067 100644 --- a/src/Method.test.ts +++ b/src/Method.test.ts @@ -1,4 +1,4 @@ -import { Method, z } from 'mppx' +import { Challenge, Credential, Method, z } from 'mppx' import { describe, expect, expectTypeOf, test } from 'vp/test' describe('from', () => { @@ -74,3 +74,88 @@ describe('from', () => { expectTypeOf(method.schema.credential.payload).toEqualTypeOf(payloadSchema) }) }) + +describe('credential execution', () => { + const base = Method.from({ + name: 'alpha', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ amount: z.string() }), + }, + }) + + function credential() { + return Credential.from({ + challenge: Challenge.from({ + id: 'challenge-id', + expires: new Date(Date.now() + 60_000).toISOString(), + intent: 'charge', + method: 'alpha', + realm: 'example.com', + request: { amount: '1000' }, + }), + payload: { token: 'valid' }, + }) + } + + test('validates without broadcasting', async () => { + const calls: string[] = [] + const method = Method.toServer(base, { + async validate({ credential, request }) { + calls.push('validate') + return { + challenge: credential.challenge, + credential, + details: {}, + intent: 'charge', + method: 'alpha', + request, + } + }, + async broadcast() { + calls.push('broadcast') + return { + method: 'alpha', + reference: 'reference', + status: 'success', + timestamp: new Date().toISOString(), + } + }, + }) + + await Method.validateCredential([method], credential()) + + expect(calls).toEqual(['validate']) + }) + + test('revalidates before broadcasting', async () => { + const calls: string[] = [] + const method = Method.toServer(base, { + async validate({ credential, request }) { + calls.push('validate') + return { + challenge: credential.challenge, + credential, + details: {}, + intent: 'charge', + method: 'alpha', + request, + } + }, + async broadcast() { + calls.push('broadcast') + return { + method: 'alpha', + reference: 'reference', + status: 'success', + timestamp: new Date().toISOString(), + } + }, + }) + + await Method.broadcastCredential([method], credential()) + + expect(calls).toEqual(['validate', 'broadcast']) + }) +}) diff --git a/src/Method.ts b/src/Method.ts index 6635c279..4ddd7f90 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -1,5 +1,8 @@ import type * as Challenge from './Challenge.js' -import type * as Credential from './Credential.js' +import * as Constants from './Constants.js' +import * as Credential from './Credential.js' +import * as Errors from './Errors.js' +import * as Expires from './Expires.js' import type { ExactPartial, LooseOmit, MaybePromise } from './internal/types.js' import type * as Receipt from './Receipt.js' import type * as Html from './server/internal/html/config.js' @@ -162,10 +165,11 @@ export type Server< preflight?: PreflightFn | undefined request?: RequestFn | undefined respond?: RespondFn | undefined - settle?: SettleFn | undefined + broadcast?: BroadcastFn | undefined stableBinding?: StableBindingFn | undefined transport?: transportOverride | undefined validate?: ValidateFn | undefined + /** @deprecated Implement `broadcast` for new methods. */ verify: VerifyFn } export type AnyServer = Server @@ -254,11 +258,112 @@ export type ValidateFn = ( parameters: ValidateContext, ) => Promise> -/** Mutating settlement function for a single method. */ -export type SettleFn = ( +/** Terminal payment function for a single method. */ +export type BroadcastFn = ( parameters: VerifyContext, ) => Promise +/** + * Validates a credential against one of the configured methods. + * + * This checks credential structure, challenge expiry, and method-specific + * validation. It does not prove that the challenge was issued by a particular + * server; hosts that issue challenges must verify that binding separately. + */ +export async function validateCredential( + methods: methods, + input: string | Credential.Credential, +): Promise> { + const prepared = prepareCredential(methods, input) + if (!prepared.method.validate) + throw new Errors.VerificationFailedError({ + reason: `${prepared.method.name}/${prepared.method.intent} does not support non-mutating credential validation`, + }) + return prepared.method.validate({ + credential: prepared.credential, + request: prepared.request, + } as never) as Promise> +} + +/** + * Re-validates and performs the terminal payment operation for a credential. + * + * This does not prove that the challenge was issued by a particular server; + * hosts that issue challenges must verify that binding separately. + */ +export async function broadcastCredential( + methods: methods, + input: string | Credential.Credential, +): Promise { + const prepared = prepareCredential(methods, input) + const { method } = prepared + + if (method.broadcast && method.validate) + await method.validate({ credential: prepared.credential, request: prepared.request } as never) + + const broadcast = method.broadcast ?? method.verify + return broadcast({ credential: prepared.credential, request: prepared.request } as never) +} + +function prepareCredential( + methods: readonly AnyServer[], + input: string | Credential.Credential, +): { + credential: Credential.Credential + method: AnyServer + request: Record +} { + const credential = typeof input === 'string' ? Credential.deserialize(input) : input + const candidates = methods.filter( + (method) => + method.name === credential.challenge.method && method.intent === credential.challenge.intent, + ) + const method = selectServerMethod(candidates, credential.challenge) + if (!method) + throw new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: `no registered method for ${credential.challenge.method}/${credential.challenge.intent}`, + }) + + Expires.assert(credential.challenge.expires, credential.challenge.id) + + let payload: unknown + try { + payload = method.schema.credential.payload.parse(credential.payload) + } catch (error) { + throw new Errors.InvalidPayloadError(error instanceof Error ? { reason: error.message } : {}) + } + + return { + credential: { ...credential, payload }, + method, + request: credential.challenge.request, + } +} + +/** @internal */ +export function selectServerMethod( + methods: readonly AnyServer[], + challenge: Challenge.Challenge, +): AnyServer | undefined { + if (methods.length <= 1) return methods[0] + if ( + challenge.method !== Constants.Methods.tempo || + challenge.intent !== Constants.Intents.session + ) + return methods[0] + + const sessionProtocol = Constants.getMethodDetail( + challenge.request.methodDetails, + Constants.MethodDetailKeys.sessionProtocol, + ) + if (sessionProtocol === undefined || sessionProtocol === Constants.SessionProtocols.v1) + return methods.find((method) => method.alias === 'sessionLegacy') ?? methods[0] + if (sessionProtocol === Constants.SessionProtocols.v2) + return methods.find((method) => method.alias === undefined) ?? methods[0] + return undefined +} + /** * Optional respond function for a server-side method. * @@ -369,12 +474,22 @@ export function toServer< preflight, request, respond, - settle, + broadcast, stableBinding, transport, validate, verify, } = options + const effectiveVerify = + verify ?? + (async (parameters: VerifyContext) => { + if (validate) await validate(parameters) + if (!broadcast) + throw new Errors.VerificationFailedError({ + reason: `${method.name}/${method.intent} does not support credential broadcast`, + }) + return broadcast(parameters) + }) return { ...method, alias, @@ -385,11 +500,11 @@ export function toServer< preflight, request, respond, - settle, + broadcast, stableBinding, transport, validate, - verify, + verify: effectiveVerify, } as Server> } @@ -411,10 +526,18 @@ 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 - } + } & ( + | { + broadcast: BroadcastFn + /** @deprecated Implement `broadcast` for new methods. */ + verify?: VerifyFn | undefined + } + | { + broadcast?: undefined + verify: VerifyFn + } + ) } diff --git a/src/server/Mppx.test-d.ts b/src/server/Mppx.test-d.ts index f1b8b4a6..6d556f35 100644 --- a/src/server/Mppx.test-d.ts +++ b/src/server/Mppx.test-d.ts @@ -223,7 +223,7 @@ describe('Mppx type tests', () => { const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) expectTypeOf(mppx.verifyCredential).toBeFunction() - expectTypeOf(mppx.settleCredential).toBeFunction() + expectTypeOf(mppx.broadcastCredential).toBeFunction() expectTypeOf(mppx.validateCredential).toBeFunction() }) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index b67422f2..b403c1dd 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -4872,7 +4872,7 @@ describe('verifyCredential', () => { expect(verifyArgs).toBeDefined() }) - test('validateCredential uses pure method validation without settlement', async () => { + test('validateCredential uses pure method validation without broadcast', async () => { const calls: string[] = [] const splitServer = Method.toServer(mockCharge, { async validate({ credential, request }) { @@ -4887,9 +4887,9 @@ describe('verifyCredential', () => { source: credential.source, } }, - async settle() { - calls.push('settle') - return mockReceipt('settled') + async broadcast() { + calls.push('broadcast') + return mockReceipt('broadcast') }, async verify() { calls.push('verify') @@ -4906,7 +4906,7 @@ describe('verifyCredential', () => { expect(calls).toEqual(['validate']) }) - test('settleCredential revalidates and uses method settlement', async () => { + test('broadcastCredential revalidates and uses method broadcast', async () => { const calls: string[] = [] const splitServer = Method.toServer(mockCharge, { async validate({ credential, request }) { @@ -4921,9 +4921,9 @@ describe('verifyCredential', () => { source: credential.source, } }, - async settle() { - calls.push('settle') - return mockReceipt('settled') + async broadcast() { + calls.push('broadcast') + return mockReceipt('broadcast') }, async verify() { calls.push('verify') @@ -4934,13 +4934,13 @@ describe('verifyCredential', () => { const challenge = await mppx.challenge.alpha.charge(challengeOpts) const credential = Credential.from({ challenge, payload: { token: 'valid' } }) - const receipt = await mppx.settleCredential(credential) + const receipt = await mppx.broadcastCredential(credential) - expect(receipt.method).toBe('settled') - expect(calls).toEqual(['validate', 'settle']) + expect(receipt.method).toBe('broadcast') + expect(calls).toEqual(['validate', 'broadcast']) }) - test('verifyCredential remains a legacy alias for settlement', async () => { + test('verifyCredential remains a legacy alias for broadcast', async () => { const calls: string[] = [] const splitServer = Method.toServer(mockCharge, { async validate({ credential, request }) { @@ -4955,9 +4955,9 @@ describe('verifyCredential', () => { source: credential.source, } }, - async settle() { - calls.push('settle') - return mockReceipt('settled') + async broadcast() { + calls.push('broadcast') + return mockReceipt('broadcast') }, async verify() { calls.push('verify') @@ -4970,8 +4970,8 @@ describe('verifyCredential', () => { const receipt = await mppx.verifyCredential(credential) - expect(receipt.method).toBe('settled') - expect(calls).toEqual(['validate', 'settle']) + expect(receipt.method).toBe('broadcast') + expect(calls).toEqual(['validate', 'broadcast']) }) test('validateCredential rejects legacy-only methods without emitting payment failure', async () => { @@ -5003,8 +5003,8 @@ describe('verifyCredential', () => { source: credential.source, } }, - async settle() { - return mockReceipt('settled') + async broadcast() { + return mockReceipt('broadcast') }, async verify() { return mockReceipt('legacy') @@ -5027,7 +5027,7 @@ describe('verifyCredential', () => { ).rejects.toThrow('credential amount does not match this route') }) - test('settleCredential emits payment failure when split validation fails', async () => { + test('broadcastCredential emits payment failure when split validation fails', async () => { const calls: string[] = [] const events: string[] = [] const splitServer = Method.toServer(mockCharge, { @@ -5035,9 +5035,9 @@ describe('verifyCredential', () => { calls.push('validate') throw new Errors.VerificationFailedError({ reason: 'risk denied' }) }, - async settle() { - calls.push('settle') - return mockReceipt('settled') + async broadcast() { + calls.push('broadcast') + return mockReceipt('broadcast') }, async verify() { calls.push('verify') @@ -5051,13 +5051,13 @@ describe('verifyCredential', () => { 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') + await expect(mppx.broadcastCredential(credential)).rejects.toThrow('risk denied') expect(calls).toEqual(['validate']) expect(events).toEqual(['VerificationFailedError']) }) - test('route handlers revalidate before settlement for split methods', async () => { + test('route handlers revalidate before broadcast for split methods', async () => { const calls: string[] = [] const splitServer = Method.toServer(mockCharge, { async validate({ credential, request }) { @@ -5072,9 +5072,9 @@ describe('verifyCredential', () => { source: credential.source, } }, - async settle() { - calls.push('settle') - return mockReceipt('settled') + async broadcast() { + calls.push('broadcast') + return mockReceipt('broadcast') }, async verify() { calls.push('verify') @@ -5097,7 +5097,7 @@ describe('verifyCredential', () => { ) expect(result.status).toBe(200) - expect(calls).toEqual(['validate', 'settle']) + expect(calls).toEqual(['validate', 'broadcast']) }) test('verifies a parsed Credential object (charge)', async () => { diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index c3f9f272..d92e3c97 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -9,7 +9,7 @@ import * as Expires from '../Expires.js' import * as AcceptPayment from '../internal/AcceptPayment.js' import * as Env from '../internal/env.js' import type { MaybePromise } from '../internal/types.js' -import type * as Method from '../Method.js' +import * as Method from '../Method.js' import * as PaymentRequest from '../PaymentRequest.js' import type * as Receipt from '../Receipt.js' import * as x402_Header from '../x402/Header.js' @@ -248,9 +248,9 @@ export type Mppx< /** * Verify a credential string or object end-to-end: deserialize, * HMAC-check, match to a registered method, validate payload schema, - * check expiry, and call the method's verify function. + * check expiry, and call the method's broadcast or legacy verify function. * - * Method verification can settle payments and persist state. For example, + * Method verification can broadcast payments and persist state. For example, * subscription credentials may activate or renew a subscription. Failed * standalone verification emits `payment.failed` once a credential challenge * can be parsed; strings that cannot be deserialized have no challenge @@ -268,23 +268,23 @@ export type Mppx< options?: VerifyCredentialOptions | undefined, ): Promise /** - * Validate a credential without consuming or settling it. + * Validate a credential without consuming or broadcasting 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. + * mutate payment state. Use `broadcastCredential()` when accepting payment. */ validateCredential( credential: string | Credential.Credential, options?: VerifyCredentialOptions | undefined, ): Promise /** - * Re-validates and consumes/settles a credential, returning a receipt. + * Re-validates and broadcasts a credential, returning a receipt. * * `verifyCredential()` is retained as a backwards-compatible alias for * this mutating path. */ - settleCredential( + broadcastCredential( credential: string | Credential.Credential, options?: VerifyCredentialOptions | undefined, ): Promise @@ -313,7 +313,7 @@ const reservedMppxKeyValues = [ 'onPaymentFailed', 'onPaymentSuccess', 'realm', - 'settleCredential', + 'broadcastCredential', 'transport', 'validateCredential', 'verifyCredential', @@ -480,7 +480,7 @@ export function create< preflight: mi.preflight as never, request: mi.request as never, respond: mi.respond as never, - settle: mi.settle as never, + broadcast: mi.broadcast as never, secretKey, stableBinding: mi.stableBinding as never, transport: (mi.transport ?? transport) as never, @@ -535,7 +535,7 @@ export function create< const methodCandidates = (methods as readonly Method.AnyServer[]).filter( (m) => m.name === credMethod && m.intent === credIntent, ) - const mi = selectVerificationMethod(methodCandidates, credential.challenge) + const mi = Method.selectServerMethod(methodCandidates, credential.challenge) const eventMethod = mi ?? ({ intent: credIntent, name: credMethod } satisfies ServerMethodDescriptor) @@ -716,8 +716,8 @@ export function create< } as never) } - // settleCredential: single-call end-to-end verification and settlement - async function settleCredentialFn( + // broadcastCredential: single-call end-to-end validation and broadcast + async function broadcastCredentialFn( input: string | Credential.Credential, options?: VerifyCredentialOptions, ): Promise { @@ -747,10 +747,10 @@ export function create< let receipt: Receipt.Receipt try { - if (mi.settle && mi.validate) + if (mi.broadcast && 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) + const broadcast = mi.broadcast ?? mi.verify + receipt = await broadcast({ credential: parsedCredential, envelope, request } as never) } catch (e) { const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError() await emitStandalonePaymentFailed({ @@ -779,7 +779,7 @@ export function create< return receipt } - const verifyCredentialFn = settleCredentialFn + const verifyCredentialFn = broadcastCredentialFn function composeFn( ...entries: readonly [ @@ -831,7 +831,7 @@ export function create< onPaymentFailed, onPaymentSuccess, realm: realm as string | undefined, - settleCredential: settleCredentialFn, + broadcastCredential: broadcastCredentialFn, transport, validateCredential: validateCredentialFn, verifyCredential: verifyCredentialFn, @@ -882,7 +882,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R realm, respond, secretKey, - settle, + broadcast, stableBinding, transport, validate, @@ -1331,10 +1331,10 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R // If verification fails, re-issue the challenge so the client can retry. let receiptData: Receipt.Receipt try { - if (settle && validate) + if (broadcast && validate) await validate({ credential: parsedCredential, envelope, request } as never) - const settleCredential = settle ?? verify - receiptData = await settleCredential({ + const broadcastCredential = broadcast ?? verify + receiptData = await broadcastCredential({ credential: parsedCredential, envelope, request, @@ -1457,7 +1457,7 @@ declare namespace createMethodFn { realm: string | undefined request?: Method.RequestFn respond?: Method.RespondFn - settle?: Method.SettleFn + broadcast?: Method.BroadcastFn secretKey: string stableBinding?: Method.StableBindingFn transport: transport @@ -1927,31 +1927,6 @@ type PinnedRequestBindingField = (typeof pinnedRequestBindingFields)[number] type PinnedChallengeField = 'method' | 'intent' | 'realm' | 'opaque' | PinnedRequestBindingField type StableBinding = Record -function selectVerificationMethod( - methods: readonly Method.AnyServer[], - challenge: Challenge.Challenge, -): Method.AnyServer | undefined { - if (methods.length <= 1) return methods[0] - if ( - challenge.method !== Constants.Methods.tempo || - challenge.intent !== Constants.Intents.session - ) - return methods[0] - - const sessionProtocolMarker = Constants.getMethodDetail( - challenge.request.methodDetails, - Constants.MethodDetailKeys.sessionProtocol, - ) - if ( - sessionProtocolMarker === undefined || - sessionProtocolMarker === Constants.SessionProtocols.v1 - ) - return methods.find((method) => method.alias === 'sessionLegacy') ?? methods[0] - if (sessionProtocolMarker === Constants.SessionProtocols.v2) - return methods.find((method) => method.alias === undefined) ?? methods[0] - return undefined -} - function getChallengeBindingMismatch( expectedChallenge: Challenge.Challenge, actualChallenge: Challenge.Challenge, diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 7877429a..f726a0f2 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -399,7 +399,7 @@ export function charge( } }, - async verify({ credential, request }) { + async broadcast({ credential, request }) { const { amount, chainId, From 2ea71bef97442479ef86f356083878d9bb4b3cc4 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:54:01 -0700 Subject: [PATCH 3/7] docs: explain credential preparation --- src/Method.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Method.ts b/src/Method.ts index 4ddd7f90..969d7fee 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -305,6 +305,20 @@ export async function broadcastCredential Date: Mon, 20 Jul 2026 21:00:29 -0700 Subject: [PATCH 4/7] refactor: scope credential broadcast changes --- src/tempo/server/Charge.test.ts | 1 - src/tempo/server/Charge.ts | 38 ++++++++++++++------------------- test/html/server.ts | 6 +++--- 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 2f52fd1b..cd3380e0 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -1599,7 +1599,6 @@ describe('tempo', () => { const receipt = await chargeServer.verifyCredential(credential) expect(receipt.status).toBe('success') - httpServer.close() }) diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index f726a0f2..d8ba6165 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -91,35 +91,31 @@ export function charge( rpcUrl: defaults.rpcUrl, }) - async function resolveCredentialContext(parameters: { + function resolveRequest(request: Method.VerifyContext['request']) { + const parsed = Methods.charge.schema.request.safeParse(request) + if (parsed.success) return parsed.data + // Credential handlers receive the HMAC-bound request in canonical output + // form, so it must not be transformed a second time. + return request as unknown as z.output + } + + async function resolveCredentialContext({ + credential, + request, + }: { 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 { challenge, payload } = credential + const resolvedRequest = resolveRequest(request) 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) @@ -131,14 +127,14 @@ export function charge( amount, chainId, challenge, - client, + client: await getClient({ chainId }), currency, isZeroAmount, memo, methodDetails, payload, recipient, - requestAllowsFeePayer, + requestAllowsFeePayer: request.feePayer !== false, resolvedRequest, supportedModes, } @@ -347,7 +343,6 @@ export function charge( 'Transaction must be signed by the sender before fee payer co-signing.', {}, ) - const calls = (transaction.calls ?? []) as readonly { data?: `0x${string}` | undefined to?: `0x${string}` | undefined @@ -570,7 +565,6 @@ export function charge( 'Transaction must be signed by the sender before fee payer co-signing.', {}, ) - const calls = (transaction.calls ?? []) as readonly { data?: `0x${string}` | undefined to?: `0x${string}` | undefined diff --git a/test/html/server.ts b/test/html/server.ts index 9142de9b..954bd8a4 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: 2_000_000_000_000n, - maxPriorityFeePerGas: 2_000_000_000_000n, - maxTotalFee: 5_000_000_000_000_000_000n, + maxFeePerGas: 200_000_000_000n, + maxPriorityFeePerGas: 200_000_000_000n, + maxTotalFee: 500_000_000_000_000_000n, } const tempoMppx = Mppx.create({ methods: [ From 2ef76b4302c4bb0fac1e815fba7107386f55ebe0 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:10:59 -0700 Subject: [PATCH 5/7] refactor: share charge credential validation --- src/tempo/server/Charge.ts | 482 ++++++++++++++----------------------- 1 file changed, 187 insertions(+), 295 deletions(-) diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index d8ba6165..3e4d2073 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -140,6 +140,157 @@ export function charge( } } + type CredentialContext = Awaited> + + async function validateHashCredential( + credential: Method.VerifyContext['credential'], + context: CredentialContext, + ) { + const { + amount, + chainId, + challenge, + client, + currency, + memo, + methodDetails, + recipient, + supportedModes, + } = context + 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: (credential.payload as { hash: string }).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, + }) + return { receipt: toReceipt(receipt), sender, transfers } + } + + async function validateProofCredential( + credential: Method.VerifyContext['credential'], + context: CredentialContext, + ) { + const { chainId, challenge, client, isZeroAmount } = context + 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: (credential.payload as { signature: string }).signature as `0x${string}`, + }) + if (!valid) { + const proofSigner = recoverAuthorizedProofSigner({ + chainId: resolvedChainId, + challengeId: challenge.id, + realm: challenge.realm, + signature: (credential.payload as { signature: string }).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.', {}) + } + return { sender: source.address } + } + + async function validateTransactionCredential( + credential: Method.VerifyContext['credential'], + request: Method.VerifyContext['request'], + context: CredentialContext, + ) { + const { + amount, + chainId, + challenge, + client, + currency, + memo, + methodDetails, + recipient, + requestAllowsFeePayer, + supportedModes, + } = context + if (supportedModes && !supportedModes.includes('pull')) + throw new MismatchError('Transaction credentials are not supported for this challenge.', {}) + + const serializedTransaction = (credential.payload as { signature: string }) + .signature as Transaction.TransactionSerializedTempo + if (!FeePayer.isTempoTransaction(serializedTransaction)) + throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {}) + + 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 isFeePayerTx = + methodDetails?.feePayer === true && + requestAllowsFeePayer && + !!(typeof request.feePayer === 'object' ? request.feePayer : feePayer || feePayerUrl) + const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) + const matchedCalls = assertTransferCalls(transaction.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, + ) + } + + return { isFeePayerTx, serializedTransaction, transaction, transfers } + } + type Defaults = charge.DeriveDefaults return Method.toServer(Methods.charge, { defaults: { @@ -222,166 +373,35 @@ export function charge( }, 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 context = await resolveCredentialContext({ credential, request }) + const { challenge, payload, resolvedRequest } = context + const details: charge.ValidationDetails = await (async () => { + switch (payload.type) { + case 'hash': { + const { sender, transfers } = await validateHashCredential(credential, context) + return { mode: 'push', sender, transfers } } - 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.', {}) + case 'proof': { + const { sender } = await validateProofCredential(credential, context) + return { mode: 'proof', sender } } - 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 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 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, - ) + case 'transaction': { + const { serializedTransaction, transaction, transfers } = + await validateTransactionCredential(credential, request, context) + return { + mode: 'pull', + sender: transaction.from as `0x${string}`, + serializedTransaction, + transfers, + } } - 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}".`) } - - default: - throw new Error(`Unsupported credential type "${(payload as { type: string }).type}".`) - } + })() return { challenge, @@ -395,20 +415,19 @@ export function charge( }, async broadcast({ credential, request }) { + const context = await resolveCredentialContext({ credential, request }) const { amount, chainId, challenge, client, currency, - isZeroAmount, memo, methodDetails, payload, recipient, requestAllowsFeePayer, - supportedModes, - } = await resolveCredentialContext({ credential, request }) + } = context const feePayerAccount = methodDetails?.feePayer === true && requestAllowsFeePayer ? typeof request.feePayer === 'object' @@ -419,111 +438,16 @@ export function charge( switch (payload.type) { case 'hash': { - if (supportedModes && !supportedModes.includes('push')) - throw new MismatchError('Hash credentials are not supported for this challenge.', {}) - const hash = payload.hash as `0x${string}` - // Validate client-supplied identity before reserving the hash so a - // malformed source cannot burn an otherwise valid payment attempt. - const source = parseHashCredentialSource({ - chainId: chainId ?? client.chain?.id, - source: credential.source, - }) - // Reserve the hash while we verify it. This blocks concurrent - // requests from racing to reuse the same on-chain payment. + const { receipt } = await validateHashCredential(credential, context) if (!(await markHashUsed(store, hash))) { throw new VerificationFailedError({ reason: 'Transaction hash has already been used' }) } - - // If verification fails after reservation, release it so transient - // RPC/log-validation errors do not force the payer to pay again. - // Once we have proven the receipt is a successful matching payment, - // keep the marker to enforce single-use semantics. - let releaseReservation = true - - try { - const expectedTransfers = getExpectedTransfers({ - amount, - memo, - methodDetails, - recipient, - }) - const receipt = await getTransactionReceipt(client, { hash }) - const sender = source?.address ?? receipt.from - const matchedLogs = await assertTransferLogs(receipt, { - currency, - sender, - source, - transfers: expectedTransfers, - validateSender, - }) - // Only verify challenge binding when using auto-generated attribution memos. - // Explicit memos (set by the server) are strictly matched by assertTransferLogs - // but are NOT challenge-bound — callers that set explicit memos are responsible - // for ensuring memo uniqueness per challenge to prevent cross-challenge hash reuse. - if (!memo) - assertChallengeBoundMemo(matchedLogs, { - challengeId: challenge.id, - realm: challenge.realm, - }) - - const paymentReceipt = toReceipt(receipt) - // `toReceipt` can throw for reverted transactions. Only keep the - // reservation after it confirms the referenced transaction settled. - releaseReservation = false - return paymentReceipt - } catch (error) { - if (releaseReservation) await releaseHashUse(store, hash) - throw error - } + return receipt } 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, { - // Bind verification to the claimed payer (`source.address`): the - // signer may be the payer itself or an access key authorized for it. - 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.', {}) - } + await validateProofCredential(credential, context) if (proofStore && !(await markProofUsed(proofStore, challenge.id))) { throw new VerificationFailedError({ reason: 'Proof credential has already been used' }) @@ -538,13 +462,8 @@ export function charge( } 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 + const { isFeePayerTx, serializedTransaction, transaction, transfers } = + await validateTransactionCredential(credential, request, context) // Pre-broadcast dedup: catch exact byte-for-byte replays early. const hash = keccak256(serializedTransaction) @@ -556,35 +475,6 @@ export function charge( let sponsoredSenderReservation: { chainId: number; sender: `0x${string}` } | undefined try { - if (!FeePayer.isTempoTransaction(serializedTransaction)) - throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {}) - - 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 calls = (transaction.calls ?? []) as readonly { - data?: `0x${string}` | undefined - to?: `0x${string}` | undefined - }[] - const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) - const isFeePayerTx = - methodDetails?.feePayer === true && - requestAllowsFeePayer && - !!(feePayerAccount || feePayerUrl) - const matchedCalls = assertTransferCalls(calls, { - currency, - exactCount: isFeePayerTx, - transfers, - }) - if (!memo) - assertChallengeBoundCallMemo(matchedCalls, { - challengeId: challenge.id, - realm: challenge.realm, - }) - if (isFeePayerTx) { const reservationChainId = chainId ?? client.chain!.id if ( @@ -601,17 +491,19 @@ export function charge( chainId: reservationChainId, sender: transaction.from as `0x${string}`, } - FeePayer.validateCalls( - transaction.calls, - { amount, currency, recipient }, - { currency, expectedTransfers: transfers }, - ) } const allowedFeeTokens = FeePayer.defaultAllowedFeeTokens(chainId) if (isFeePayerTx) FeePayer.assertAllowedFeeToken(transaction, allowedFeeTokens) const selectableFeeTokens = allowedFeeTokens as readonly `0x${string}`[] + // Request for the pre-broadcast simulation; for sponsored payments + // this is overwritten below with the co-signed shape. + let simulationRequest: Record = FeePayer.simulationTransaction( + transaction, + { feePayer: isFeePayerTx }, + ) + const serializedTransaction_final = await (async () => { if (feePayerAccount && methodDetails?.feePayer !== false) { const completed = await FeePayer.preflightSponsorship({ From aa687c7f669385b452d36d8127b0bc26f57202ac Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:13:15 -0700 Subject: [PATCH 6/7] refactor: consolidate charge validation flow --- src/tempo/server/Charge.ts | 124 ++++++++++++++++++++----------------- 1 file changed, 67 insertions(+), 57 deletions(-) diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 3e4d2073..b119696a 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -141,9 +141,14 @@ export function charge( } type CredentialContext = Awaited> + type Credential = Method.VerifyContext['credential'] + type HashPayload = Extract + type ProofPayload = Extract + type TransactionPayload = Extract async function validateHashCredential( - credential: Method.VerifyContext['credential'], + credential: Credential, + payload: HashPayload, context: CredentialContext, ) { const { @@ -166,7 +171,7 @@ export function charge( }) const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) const receipt = await getTransactionReceipt(client, { - hash: (credential.payload as { hash: string }).hash as `0x${string}`, + hash: payload.hash as `0x${string}`, }) const sender = source?.address ?? receipt.from const matchedLogs = await assertTransferLogs(receipt, { @@ -185,7 +190,8 @@ export function charge( } async function validateProofCredential( - credential: Method.VerifyContext['credential'], + credential: Credential, + payload: ProofPayload, context: CredentialContext, ) { const { chainId, challenge, client, isZeroAmount } = context @@ -208,14 +214,14 @@ export function charge( challengeId: challenge.id, realm: challenge.realm, }), - signature: (credential.payload as { signature: string }).signature as `0x${string}`, + signature: payload.signature as `0x${string}`, }) if (!valid) { const proofSigner = recoverAuthorizedProofSigner({ chainId: resolvedChainId, challengeId: challenge.id, realm: challenge.realm, - signature: (credential.payload as { signature: string }).signature as `0x${string}`, + signature: payload.signature as `0x${string}`, sourceAddress: source.address, }) const authorized = proofSigner @@ -227,7 +233,8 @@ export function charge( } async function validateTransactionCredential( - credential: Method.VerifyContext['credential'], + credential: Credential, + payload: TransactionPayload, request: Method.VerifyContext['request'], context: CredentialContext, ) { @@ -246,8 +253,7 @@ export function charge( if (supportedModes && !supportedModes.includes('pull')) throw new MismatchError('Transaction credentials are not supported for this challenge.', {}) - const serializedTransaction = (credential.payload as { signature: string }) - .signature as Transaction.TransactionSerializedTempo + const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo if (!FeePayer.isTempoTransaction(serializedTransaction)) throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {}) @@ -291,6 +297,51 @@ export function charge( return { isFeePayerTx, serializedTransaction, transaction, transfers } } + async function validateCredential( + credential: Credential, + request: Method.VerifyContext['request'], + context: CredentialContext, + ) { + switch (context.payload.type) { + case 'hash': { + const { receipt, sender, transfers } = await validateHashCredential( + credential, + context.payload, + context, + ) + return { + details: { mode: 'push' as const, sender, transfers }, + hash: context.payload.hash as `0x${string}`, + receipt, + type: 'hash' as const, + } + } + + case 'proof': { + const { sender } = await validateProofCredential(credential, context.payload, context) + return { details: { mode: 'proof' as const, sender }, type: 'proof' as const } + } + + case 'transaction': { + const { isFeePayerTx, serializedTransaction, transaction, transfers } = + await validateTransactionCredential(credential, context.payload, request, context) + return { + details: { + mode: 'pull' as const, + sender: transaction.from as `0x${string}`, + serializedTransaction, + transfers, + }, + isFeePayerTx, + serializedTransaction, + transaction, + transfers, + type: 'transaction' as const, + } + } + } + } + type Defaults = charge.DeriveDefaults return Method.toServer(Methods.charge, { defaults: { @@ -374,42 +425,15 @@ export function charge( async validate({ credential, request }) { const context = await resolveCredentialContext({ credential, request }) - const { challenge, payload, resolvedRequest } = context - const details: charge.ValidationDetails = await (async () => { - switch (payload.type) { - case 'hash': { - const { sender, transfers } = await validateHashCredential(credential, context) - return { mode: 'push', sender, transfers } - } - - case 'proof': { - const { sender } = await validateProofCredential(credential, context) - return { mode: 'proof', sender } - } - - case 'transaction': { - const { serializedTransaction, transaction, transfers } = - await validateTransactionCredential(credential, request, context) - return { - mode: 'pull', - sender: transaction.from as `0x${string}`, - serializedTransaction, - transfers, - } - } - - default: - throw new Error(`Unsupported credential type "${(payload as { type: string }).type}".`) - } - })() + const validated = await validateCredential(credential, request, context) return { - challenge, + challenge: context.challenge, credential, - details, + details: validated.details, intent: 'charge', method: 'tempo', - request: resolvedRequest, + request: context.resolvedRequest, source: credential.source, } }, @@ -424,10 +448,10 @@ export function charge( currency, memo, methodDetails, - payload, recipient, requestAllowsFeePayer, } = context + const validated = await validateCredential(credential, request, context) const feePayerAccount = methodDetails?.feePayer === true && requestAllowsFeePayer ? typeof request.feePayer === 'object' @@ -436,10 +460,9 @@ export function charge( : undefined const expires = challenge.expires - switch (payload.type) { + switch (validated.type) { case 'hash': { - const hash = payload.hash as `0x${string}` - const { receipt } = await validateHashCredential(credential, context) + const { hash, receipt } = validated if (!(await markHashUsed(store, hash))) { throw new VerificationFailedError({ reason: 'Transaction hash has already been used' }) } @@ -447,8 +470,6 @@ export function charge( } case 'proof': { - await validateProofCredential(credential, context) - if (proofStore && !(await markProofUsed(proofStore, challenge.id))) { throw new VerificationFailedError({ reason: 'Proof credential has already been used' }) } @@ -462,8 +483,7 @@ export function charge( } case 'transaction': { - const { isFeePayerTx, serializedTransaction, transaction, transfers } = - await validateTransactionCredential(credential, request, context) + const { isFeePayerTx, serializedTransaction, transaction, transfers } = validated // Pre-broadcast dedup: catch exact byte-for-byte replays early. const hash = keccak256(serializedTransaction) @@ -497,13 +517,6 @@ export function charge( if (isFeePayerTx) FeePayer.assertAllowedFeeToken(transaction, allowedFeeTokens) const selectableFeeTokens = allowedFeeTokens as readonly `0x${string}`[] - // Request for the pre-broadcast simulation; for sponsored payments - // this is overwritten below with the co-signed shape. - let simulationRequest: Record = FeePayer.simulationTransaction( - transaction, - { feePayer: isFeePayerTx }, - ) - const serializedTransaction_final = await (async () => { if (feePayerAccount && methodDetails?.feePayer !== false) { const completed = await FeePayer.preflightSponsorship({ @@ -625,9 +638,6 @@ export function charge( await releaseSponsoredSenderInFlight(store, sponsoredSenderReservation) } } - - default: - throw new Error(`Unsupported credential type "${(payload as { type: string }).type}".`) } }, }) From 0532b68a4b66eadf09f2f9192efa32ce720ddd6e Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:35:39 -0700 Subject: [PATCH 7/7] docs: clarify credential lifecycle scope --- .changeset/validate-settle-credentials.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/validate-settle-credentials.md b/.changeset/validate-settle-credentials.md index 8c6941a4..15e8c4ff 100644 --- a/.changeset/validate-settle-credentials.md +++ b/.changeset/validate-settle-credentials.md @@ -2,4 +2,4 @@ 'mppx': patch --- -Added pure credential validation and explicit credential broadcast APIs. +Added generic `validate`/`broadcast` credential lifecycle APIs and a Tempo charge implementation; Tempo session credential handling remained unchanged.