diff --git a/.changeset/validate-settle-credentials.md b/.changeset/validate-settle-credentials.md new file mode 100644 index 00000000..8c6941a4 --- /dev/null +++ b/.changeset/validate-settle-credentials.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +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 3afe7877..969d7fee 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' @@ -117,12 +120,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 +165,11 @@ export type Server< preflight?: PreflightFn | undefined request?: RequestFn | undefined respond?: RespondFn | 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 @@ -226,6 +253,131 @@ export type VerifyFn = ( parameters: VerifyContext, ) => Promise +/** Non-mutating validation function for a single method. */ +export type ValidateFn = ( + parameters: ValidateContext, +) => Promise> + +/** 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) +} + +/** + * Parses a submitted credential into the inputs required for method execution. + * + * Dispatch is based on the challenge method and intent. When more than one + * server method handles the same wire identity, session protocol details select + * the appropriate implementation. The helper then asserts challenge expiry and + * parses the method-specific credential payload before returning the selected + * method and the unmodified challenge request. + * + * This intentionally does not verify that the challenge was issued by a + * particular host, authorize the caller or requested resource, validate the + * method request, or invoke method lifecycle hooks. Hosts that issue challenges + * must verify their challenge binding before accepting the credential. + */ +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. * @@ -336,10 +488,22 @@ export function toServer< preflight, request, respond, + 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, @@ -350,9 +514,11 @@ export function toServer< preflight, request, respond, + broadcast, stableBinding, transport, - verify, + validate, + verify: effectiveVerify, } as Server> } @@ -376,6 +542,16 @@ export declare namespace toServer { respond?: RespondFn | undefined stableBinding?: StableBindingFn | undefined transport?: transportOverride | Transport.AnyTransport | undefined - verify: VerifyFn - } + validate?: ValidateFn | undefined + } & ( + | { + 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 7a677c05..ba8bd5e4 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.broadcastCredential).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..54e07745 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 broadcast', 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 broadcast() { + calls.push('broadcast') + return mockReceipt('broadcast') + }, + 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('broadcastCredential revalidates and uses method broadcast', 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 broadcast() { + calls.push('broadcast') + return mockReceipt('broadcast') + }, + 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.broadcastCredential(credential) + + expect(receipt.method).toBe('broadcast') + expect(calls).toEqual(['validate', 'broadcast']) + }) + + test('verifyCredential remains a legacy alias for broadcast', 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 broadcast() { + calls.push('broadcast') + return mockReceipt('broadcast') + }, + 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('broadcast') + expect(calls).toEqual(['validate', 'broadcast']) + }) + + 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 broadcast() { + return mockReceipt('broadcast') + }, + 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('broadcastCredential 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 broadcast() { + calls.push('broadcast') + return mockReceipt('broadcast') + }, + 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.broadcastCredential(credential)).rejects.toThrow('risk denied') + + expect(calls).toEqual(['validate']) + expect(events).toEqual(['VerificationFailedError']) + }) + + test('route handlers revalidate before broadcast 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 broadcast() { + calls.push('broadcast') + return mockReceipt('broadcast') + }, + 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', 'broadcast']) + }) + 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..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 @@ -267,6 +267,27 @@ export type Mppx< credential: string | Credential.Credential, options?: VerifyCredentialOptions | undefined, ): Promise + /** + * 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 `broadcastCredential()` when accepting payment. + */ + validateCredential( + credential: string | Credential.Credential, + options?: VerifyCredentialOptions | undefined, + ): Promise + /** + * Re-validates and broadcasts a credential, returning a receipt. + * + * `verifyCredential()` is retained as a backwards-compatible alias for + * this mutating path. + */ + broadcastCredential( + 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', + 'broadcastCredential', '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, + broadcast: mi.broadcast 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,125 +521,117 @@ 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, ) - const mi = selectVerificationMethod(methodCandidates, credential.challenge) + const mi = Method.selectServerMethod(methodCandidates, credential.challenge) 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 } - // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create()) + if (!mi) { + await fail( + new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: `no registered method for ${credMethod}/${credIntent}`, + }), + ) + } + + 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) + } + + // broadcastCredential: single-call end-to-end validation and broadcast + async function broadcastCredentialFn( + 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.broadcast && mi.validate) + await mi.validate({ 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({ - 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 = broadcastCredentialFn + function composeFn( ...entries: readonly [ Method.AnyServer | AnyMethodFnWithMethod | string, @@ -760,7 +831,9 @@ export function create< onPaymentFailed, onPaymentSuccess, realm: realm as string | undefined, + broadcastCredential: broadcastCredentialFn, transport, + validateCredential: validateCredentialFn, verifyCredential: verifyCredentialFn, ...handlers, } as never @@ -809,8 +882,10 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R realm, respond, secretKey, + broadcast, 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 (broadcast && validate) + await validate({ credential: parsedCredential, envelope, request } as never) + const broadcastCredential = broadcast ?? verify + receiptData = await broadcastCredential({ + 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 + broadcast?: Method.BroadcastFn secretKey: string stableBinding?: Method.StableBindingFn transport: transport + validate?: Method.ValidateFn verify: Method.VerifyFn } @@ -1843,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.test.ts b/src/tempo/server/Charge.test.ts index 2ac24f3e..05e1c8d9 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -1531,6 +1531,62 @@ 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..96baa4df 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -87,6 +87,55 @@ export function charge( rpcUrl: defaults.rpcUrl, }) + 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 { challenge, payload } = credential + const resolvedRequest = resolveRequest(request) + const chainId = resolvedRequest.methodDetails?.chainId ?? request.chainId + const { amount, methodDetails } = resolvedRequest + 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 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: await getClient({ chainId }), + currency, + isZeroAmount, + memo, + methodDetails, + payload, + recipient, + requestAllowsFeePayer: request.feePayer !== false, + resolvedRequest, + supportedModes, + } + } + type Defaults = charge.DeriveDefaults return Method.toServer(Methods.charge, { defaults: { @@ -168,25 +217,194 @@ 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 client = await getClient({ chainId }) + const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo + if (!FeePayer.isTempoTransaction(serializedTransaction)) + throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {}) - const { amount, methodDetails } = resolvedRequest - const requestAllowsFeePayer = - request.feePayer !== false && - (request.feePayer === undefined || - request.feePayer === true || - typeof request.feePayer === 'object') + 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, + ) + } + + 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 broadcast({ 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 +412,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': { @@ -359,7 +561,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 @@ -542,6 +743,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}`