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
1 change: 1 addition & 0 deletions src/Method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export type CreateCredentialFn<method extends Method, context = unknown> = (
/** Request transform function for a single method. */
export type RequestFn<method extends Method> = (options: {
credential?: Credential.Credential | null | undefined
input?: globalThis.Request | undefined
request: z.input<method['schema']['request']>
}) => MaybePromise<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 @@ -451,6 +451,20 @@ describe('Fetch.from: init passthrough (non-402)', () => {
expect(receivedInits[0]).toBe(customInit)
}
})

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 @@ -501,6 +515,25 @@ describe('Fetch.from: 402 retry path', () => {
expect(headers.Authorization).toBe('credential')
})

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
23 changes: 21 additions & 2 deletions src/client/internal/Fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ type WrappedFetch = typeof globalThis.fetch & {
[MPPX_FETCH_WRAPPER]?: typeof globalThis.fetch
}

type ResponseAwareClient = Method.AnyClient & {
onResponse?: ((response: Response) => Promise<void> | void) | undefined
}

let originalFetch: typeof globalThis.fetch | undefined

/**
Expand Down Expand Up @@ -46,7 +50,10 @@ export function from<const methods extends readonly Method.AnyClient[]>(
// Pass init through untouched to preserve object identity for non-402 responses.
const response = await baseFetch(input, 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 @@ -81,10 +88,12 @@ export function from<const methods extends readonly Method.AnyClient[]>(
const credential = onChallengeCredential ?? (await resolveCredential(challenge, mi, context))
validateCredentialHeaderValue(credential)

return baseFetch(input, {
const retryResponse = await baseFetch(input, {
...fetchInit,
headers: withAuthorizationHeader(fetchInit.headers, credential),
})
await handleResponse(methods, retryResponse)
return retryResponse
}

// Record the wrapped target so future polyfill() / restore() calls can detect origin
Expand Down Expand Up @@ -240,6 +249,16 @@ 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
2 changes: 1 addition & 1 deletion src/server/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
// Transform request if method provides a `request` function.
const request = (
parameters.request
? await parameters.request({ credential, request: merged } as never)
? await parameters.request({ credential, input, request: merged } as never)
: merged
) as never

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 @@ -208,4 +208,24 @@ 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')
})
})
12 changes: 12 additions & 0 deletions src/tempo/Methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,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 @@ -148,18 +150,24 @@ 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(),
}),
z.transform(
({
acceptedCumulative,
amount,
chainId,
channelId,
decimals,
deposit,
escrowContract,
feePayer,
minVoucherDelta,
requiredCumulative,
spent,
suggestedDeposit,
...rest
}) => ({
Expand All @@ -171,13 +179,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