Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/Method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ export type VerifiedChallengeEnvelope<
/** Request hook parameters for a single method. */
export type RequestContext<method extends Method> = {
capturedRequest?: CapturedRequest
credential?: Credential.Credential | null
credential?: Credential.Credential | null | undefined
/** Incoming HTTP request when the server transport can provide one. */
input?: globalThis.Request | undefined
request: z.input<method['schema']['request']>
}

Expand Down
33 changes: 33 additions & 0 deletions src/client/internal/Fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,20 @@ describe('Fetch.from: init passthrough (non-402)', () => {
expect(new Headers(receivedInits[0]?.headers).get('Accept-Payment')).toBe('test/test')
}
})

test('calls method response hooks for successful non-402 responses', async () => {
const onResponse = vi.fn()
const method = { ...noopMethod, onResponse }
const fetch = Fetch.from({
fetch: async () => new Response('OK', { status: 200 }),
methods: [method],
})

await fetch('https://example.com/api')

expect(onResponse).toHaveBeenCalledOnce()
expect(onResponse.mock.calls[0]![0]).toBeInstanceOf(Response)
})
})

describe('Fetch.from: 402 retry path', () => {
Expand Down Expand Up @@ -930,6 +944,25 @@ describe('Fetch.from: 402 retry path', () => {
expect(events).toEqual(['failed:true:abc', '*:payment.failed'])
})

test('calls method response hooks for successful retry responses', async () => {
let callCount = 0
const onResponse = vi.fn()
const method = { ...noopMethod, onResponse }
const fetch = Fetch.from({
fetch: async () => {
callCount++
if (callCount === 1) return make402()
return new Response('OK', { status: 200 })
},
methods: [method],
})

await fetch('https://example.com/api')

expect(onResponse).toHaveBeenCalledOnce()
expect(callCount).toBe(2)
})

test('preserves existing headers on retry', async () => {
let callCount = 0
const calls: { init: RequestInit | undefined }[] = []
Expand Down
21 changes: 20 additions & 1 deletion src/client/internal/Fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ type WrappedFetch = typeof globalThis.fetch & {
[MPPX_FETCH_WRAPPER]?: typeof globalThis.fetch
}

/** Client method extension used to reconcile successful HTTP responses. */
type ResponseAwareClient = Method.AnyClient & {
onResponse?: ((response: Response) => Promise<void> | void) | undefined
}

let originalFetch: typeof globalThis.fetch | undefined

export type ClientEventMap<
Expand Down Expand Up @@ -183,7 +188,10 @@ export function from<const methods extends readonly Method.AnyClient[]>(
)
const response = await baseFetch(initialRequest.input, initialRequest.init)

if (response.status !== 402) return response
if (response.status !== 402) {
await handleResponse(methods, response)
return response
}

// Only extract context for payment handling after confirming 402.
const context = (init as Record<string, unknown> | undefined)?.context
Expand Down Expand Up @@ -262,6 +270,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
...fetchInit,
headers: withAuthorizationHeader(initialRequest.headers, credential),
})
await handleResponse(methods, paymentResponse)
if (paymentResponse.ok)
await events.emit(
'payment.response',
Expand Down Expand Up @@ -765,6 +774,16 @@ export function validateCredentialHeaderValue(credential: string): void {
}
}

async function handleResponse(
methods: readonly Method.AnyClient[],
response: Response,
): Promise<void> {
for (const method of methods) {
const onResponse = (method as ResponseAwareClient).onResponse
if (onResponse) await onResponse(response)
}
}

/** @internal */
async function resolveCredential(
challenge: Challenge.Challenge,
Expand Down
3 changes: 3 additions & 0 deletions src/server/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
defaults,
description,
expires,
input: input instanceof globalThis.Request ? input : undefined,
meta: effectiveMeta,
method,
realm,
Expand Down Expand Up @@ -1639,6 +1640,7 @@ async function resolveRouteChallenge(parameters: {
defaults?: Record<string, unknown> | undefined
description?: string | undefined
expires?: string | undefined
input?: globalThis.Request | undefined
meta?: Record<string, string> | undefined
method: Method.Method
realm?: string | undefined
Expand All @@ -1659,6 +1661,7 @@ async function resolveRouteChallenge(parameters: {
? ((await parameters.request({
capturedRequest: parameters.capturedRequest,
credential: parameters.credential,
input: parameters.input,
request: merged,
} as never)) as Record<string, unknown>)
: merged
Expand Down
20 changes: 20 additions & 0 deletions src/tempo/Methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,26 @@ describe('session', () => {
expect(request.amount).toBe('1000000')
expect(request.methodDetails?.minVoucherDelta).toBe('100000')
})

test('schema: preserves additive session hints in methodDetails', () => {
const request = Methods.session.schema.request.parse({
acceptedCumulative: '5000000',
amount: '1',
currency: '0x20c0000000000000000000000000000000000001',
decimals: 6,
deposit: '10000000',
escrowContract: '0x1234567890abcdef1234567890abcdef12345678',
recipient: '0x1234567890abcdef1234567890abcdef12345678',
requiredCumulative: '6000000',
spent: '4000000',
unitType: 'token',
})

expect(request.methodDetails?.acceptedCumulative).toBe('5000000')
expect(request.methodDetails?.deposit).toBe('10000000')
expect(request.methodDetails?.requiredCumulative).toBe('6000000')
expect(request.methodDetails?.spent).toBe('4000000')
})
})

describe('subscription', () => {
Expand Down
12 changes: 12 additions & 0 deletions src/tempo/Methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,13 @@ export const session = Method.from({
request: z.pipe(
z
.object({
acceptedCumulative: z.optional(z.string()),
amount: z.amount(),
chainId: z.optional(z.number()),
channelId: z.optional(z.hash()),
currency: z.string(),
decimals: z.number(),
deposit: z.optional(z.string()),
escrowContract: z.optional(z.string()),
feePayer: z.optional(
z.pipe(
Expand All @@ -238,6 +240,8 @@ export const session = Method.from({
),
minVoucherDelta: z.optional(z.amount()),
recipient: z.optional(z.string()),
requiredCumulative: z.optional(z.string()),
spent: z.optional(z.string()),
suggestedDeposit: z.optional(z.amount()),
unitType: z.string(),
})
Expand All @@ -249,13 +253,17 @@ export const session = Method.from({
),
z.transform(
({
acceptedCumulative,
amount,
chainId,
channelId,
decimals,
deposit,
escrowContract,
feePayer,
minVoucherDelta,
requiredCumulative,
spent,
suggestedDeposit,
...rest
}) => ({
Expand All @@ -267,13 +275,17 @@ export const session = Method.from({
}
: {}),
methodDetails: {
...(acceptedCumulative !== undefined && { acceptedCumulative }),
...(deposit !== undefined && { deposit }),
escrowContract,
...(channelId !== undefined && { channelId }),
...(minVoucherDelta !== undefined && {
minVoucherDelta: parseUnits(minVoucherDelta, decimals).toString(),
}),
...(requiredCumulative !== undefined && { requiredCumulative }),
...(chainId !== undefined && { chainId }),
...(feePayer !== undefined && { feePayer }),
...(spent !== undefined && { spent }),
},
}),
),
Expand Down
70 changes: 70 additions & 0 deletions src/tempo/client/ChannelOps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import {
} from '../internal/defaults.js'
import { verifyVoucher } from '../session/Voucher.js'
import {
createHintedChannelEntry,
createClosePayload,
createOpenPayload,
createVoucherPayload,
reconcileChannelEntry,
resolveEscrow,
serializeCredential,
tryRecoverChannel,
Expand Down Expand Up @@ -169,6 +171,74 @@ describe('createClosePayload', () => {
})
})

describe('reconcileChannelEntry', () => {
test('hinted entries do not treat server snapshots as local authorization', () => {
const entry = createHintedChannelEntry({
chainId,
channelId,
escrowContract,
hints: {
acceptedCumulative: '6000000',
deposit: '10000000',
spent: '4000000',
},
})

expect(entry.acceptedCumulative).toBe(6_000_000n)
expect(entry.cumulativeAmount).toBe(0n)
expect(entry.spent).toBe(4_000_000n)
})

test('does not move channel state backwards for stale snapshots', () => {
const entry = createHintedChannelEntry({
chainId,
channelId,
escrowContract,
hints: {
acceptedCumulative: '6000000',
deposit: '10000000',
spent: '4000000',
},
})
entry.cumulativeAmount = 7_000_000n

const changed = reconcileChannelEntry(entry, {
acceptedCumulative: '5000000',
deposit: '9000000',
spent: '3000000',
})

expect(changed).toBe(false)
expect(entry.acceptedCumulative).toBe(6_000_000n)
expect(entry.cumulativeAmount).toBe(7_000_000n)
expect(entry.deposit).toBe(10_000_000n)
expect(entry.spent).toBe(4_000_000n)
})

test('raises spent without lowering a newer local cumulative amount', () => {
const entry = createHintedChannelEntry({
chainId,
channelId,
escrowContract,
hints: {
acceptedCumulative: '5000000',
deposit: '10000000',
spent: '3000000',
},
})
entry.cumulativeAmount = 7_000_000n

const changed = reconcileChannelEntry(entry, {
spent: '6000000',
})

expect(changed).toBe(true)
expect(entry.acceptedCumulative).toBe(6_000_000n)
expect(entry.cumulativeAmount).toBe(7_000_000n)
expect(entry.spent).toBe(6_000_000n)
})
})

describe.runIf(isLocalnet)('createOpenPayload', () => {
const payer = accounts[2]
const payee = accounts[1].address
Expand Down
Loading
Loading