diff --git a/.changelog/mppx-session-manager.md b/.changelog/mppx-session-manager.md new file mode 100644 index 0000000..c6528ee --- /dev/null +++ b/.changelog/mppx-session-manager.md @@ -0,0 +1,5 @@ +--- +tempo-request: patch +--- + +Delegate Tempo payment-session recovery, validation, vouchers, top-ups, and SQLite persistence to MPPx's managed session client. diff --git a/package.json b/package.json index a18fd91..9053ff4 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "dependencies": { "accounts": "0.14.11", "incur": "0.4.8", - "mppx": "0.8.9", + "mppx": "https://pkg.pr.new/mppx@8b64593", "undici": "8.5.0", "viem": "2.54.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b524c1..1ebe9c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ importers: specifier: 0.4.8 version: 0.4.8 mppx: - specifier: 0.8.9 - version: 0.8.9(hono@4.12.25)(typescript@6.0.3)(viem@2.54.0(typescript@6.0.3)(zod@4.4.3)) + specifier: https://pkg.pr.new/mppx@8b64593 + version: https://pkg.pr.new/mppx@8b64593(hono@4.12.25)(typescript@6.0.3)(viem@2.54.0(typescript@6.0.3)(zod@4.4.3)) undici: specifier: 8.5.0 version: 8.5.0 @@ -1396,8 +1396,9 @@ packages: hono: optional: true - mppx@0.8.9: - resolution: {integrity: sha512-OkDecYp/nHjyGKBzEmOebMlW/gXRxQRvTVjPX5f6indEhkQ0rlXl+8v2aRfbS9ckMWY8i5ulJzYfLipJ82TEkw==} + mppx@https://pkg.pr.new/mppx@8b64593: + resolution: {tarball: https://pkg.pr.new/mppx@8b64593} + version: 0.8.12 hasBin: true peerDependencies: '@modelcontextprotocol/sdk': '>=1.25.0' @@ -2877,7 +2878,7 @@ snapshots: transitivePeerDependencies: - typescript - mppx@0.8.9(hono@4.12.25)(typescript@6.0.3)(viem@2.54.0(typescript@6.0.3)(zod@4.4.3)): + mppx@https://pkg.pr.new/mppx@8b64593(hono@4.12.25)(typescript@6.0.3)(viem@2.54.0(typescript@6.0.3)(zod@4.4.3)): dependencies: '@stripe/stripe-js': 9.9.0 eventsource-parser: 3.1.0 diff --git a/src/commands/request.ts b/src/commands/request.ts index 6f67df6..0ffe641 100644 --- a/src/commands/request.ts +++ b/src/commands/request.ts @@ -5,10 +5,9 @@ import { basename, dirname } from "node:path"; import { pipeline } from "node:stream/promises"; import { setTimeout as sleep } from "node:timers/promises"; -import { Challenge, Credential, PaymentRequest } from "mppx"; -import { Mppx, session as tempoSession, tempo } from "mppx/client"; +import { Challenge } from "mppx"; +import { Mppx, tempo } from "mppx/client"; import { Keystore } from "accounts"; -import { Session as TempoSession } from "mppx/tempo"; import { Agent, EnvHttpProxyAgent, @@ -17,40 +16,16 @@ import { FormData as UndiciFormData, ProxyAgent, } from "undici"; -import { createWalletClient, encodeFunctionData, http, parseUnits } from "viem"; -import { prepareTransactionRequest, signTransaction } from "viem/actions"; +import { createWalletClient, http, parseUnits } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { - Abis as TempoAbis, - Account as TempoAccount, - Actions, - Chain, - Channel as TempoChannel, - KeyAuthorizationManager, -} from "viem/tempo"; +import { Account as TempoAccount, Chain, KeyAuthorizationManager } from "viem/tempo"; import { withSessionLock } from "../payment/session-lock.js"; -import { requireSessionDescriptor } from "../payment/session-descriptor.js"; -import { - deleteSessionRecord, - findReusableSession, - originFromUrl, - type PersistedSessionRecord, - preserveSessionCumulative, - updateSessionReceipt, - upsertSessionRecord, -} from "../payment/session-store.js"; +import { fetchManagedSession } from "../payment/managed-session.js"; import { connect, createProvider } from "../provider.js"; -import { escrowAbi, version } from "../shared/constants.js"; +import { version } from "../shared/constants.js"; import { networkError, paymentError, usageError } from "../shared/errors.js"; -import { - chainId, - createTempoPublicClient, - escrowContract, - networkName, - rpcUrl, -} from "../shared/network.js"; -import { getRecord, nowSeconds, parseOnChainBigInt, stringValue } from "../shared/utils.js"; +import { chainId, rpcUrl } from "../shared/network.js"; import { loadWalletState, type WalletState } from "../wallet/store.js"; export type RequestOptions = { @@ -501,9 +476,7 @@ async function payAndRetryRequest( const sessionChallenge = sessionChallengeFromHeader(header); if (sessionChallenge) - return withSessionLock(request.url, () => - paySessionAndRetryRequest(paymentRequiredResponse, request, options, sessionChallenge), - ); + return withSessionLock(request.url, () => payManagedSessionAndRetryRequest(request, options)); try { const identity = await resolvePaymentIdentity(options); @@ -544,96 +517,24 @@ async function payAndRetryRequest( } } -async function paySessionAndRetryRequest( - paymentRequiredResponse: Response, - request: FetchPlan, - options: RequestOptions, - challenge: Challenge.Challenge, - skipChannelId?: string | undefined, -) { +async function payManagedSessionAndRetryRequest(request: FetchPlan, options: RequestOptions) { try { const identity = await resolvePaymentIdentity(options); - const details = sessionDetails(challenge, request.url, options); - const reusable = await reusableSessionRecord(details, identity, skipChannelId); - const challengeResponse = tempoPaymentChallengeResponse(paymentRequiredResponse); - const payment = Mppx.create({ - methods: [ - tempoSession({ - ...identity.methodOptions, - ...(options.maxSpend ? { maxDeposit: options.maxSpend } : {}), - }), - ], - polyfill: false, - }); - - let signedCumulative: bigint; - let credential: string; - let record: PersistedSessionRecord | undefined; - - if (reusable) { - signedCumulative = - maxBigInt(reusable.cumulative_amount, reusable.accepted_cumulative, reusable.server_spent) + - details.amount; - enforceSessionMaxSpend(signedCumulative, options); - await preserveSessionCumulative(reusable.channel_id, signedCumulative); - credential = await payment.createCredential(challengeResponse, { - action: "voucher", - channelId: reusable.channel_id as `0x${string}`, - cumulativeAmountRaw: signedCumulative.toString(), - ...(reusable.descriptor_json ? { descriptor: requireSessionDescriptor(reusable) } : {}), - }); - record = reusable; - } else { - const depositRaw = sessionDepositRaw(details, options); - await assertSufficientSessionBalance(identity.address, details, depositRaw); - signedCumulative = details.amount; - enforceSessionMaxSpend(signedCumulative, options); - credential = await payment.createCredential(challengeResponse, { - depositRaw: depositRaw.toString(), - }); - record = sessionRecordFromOpenCredential({ - challenge, - credential, - depositRaw, - details, - identity, - signedCumulative, - }); - await upsertSessionRecord(record); - } - - const paidInit = payment.transport.setCredential(cloneRequestInit(request.init), credential); - const response = await fetchWithRetries( - { init: paidInit, url: paymentRequiredResponse.url || request.url }, - options, - ); - if (reusable && response.status === 402) { - const recovered = await tryTopUpAndRetry({ - credential, - details, - identity, - options, - payment, - paymentRequiredResponse, - record: reusable, - request, - response, - signedCumulative, - }); - if (recovered) return recovered; - } - if (reusable && (await isSessionInvalidationResponse(response))) { - await deleteSessionRecord(reusable.channel_id); - return paySessionAndRetryRequest( - paymentRequiredResponse, - request, + const managedFetch: typeof globalThis.fetch = (input, init) => + fetchWithRetries( + { + init: cloneRequestInit((init ?? {}) as RequestInitWithDispatcher), + url: input instanceof Request ? input.url : input.toString(), + }, options, - challenge, - reusable.channel_id, ); - } - await persistSessionReceipt(response, record.channel_id, signedCumulative); - return response; + return await fetchManagedSession({ + fetch: managedFetch, + init: cloneRequestInit(request.init), + managerOptions: identity.methodOptions, + ...(options.maxSpend ? { maxDeposit: options.maxSpend } : {}), + url: request.url, + }); } catch (error) { if ( error && @@ -770,421 +671,6 @@ export async function storedAccessKeyIdentity(walletState: WalletState, options: return undefined; } -type PaymentIdentity = Awaited>; - -function sessionDetails( - challenge: Challenge.Challenge, - requestUrl: string, - options: RequestOptions, -) { - const request = challenge.request as Record; - const methodDetails = getRecord(request.methodDetails); - const resolvedChainId = - typeof methodDetails.chainId === "number" ? methodDetails.chainId : chainId(options.network); - const amount = bigintField(request.amount, "amount"); - const token = stringValue(request.currency).toLowerCase(); - const payee = stringValue(request.recipient).toLowerCase(); - if (!token) throw paymentError("Session challenge is missing currency"); - if (!payee) throw paymentError("Session challenge is missing recipient"); - - return { - amount, - chainId: resolvedChainId, - escrowContract: stringValue( - methodDetails.escrowContract || escrowContract(resolvedChainId), - ).toLowerCase(), - feePayer: Boolean(methodDetails.feePayer), - origin: originFromUrl(requestUrl), - payee, - requestUrl, - suggestedDeposit: - typeof request.suggestedDeposit === "string" && /^\d+$/.test(request.suggestedDeposit) - ? BigInt(request.suggestedDeposit) - : undefined, - token, - }; -} - -type SessionDetails = ReturnType; - -async function reusableSessionRecord( - details: SessionDetails, - identity: PaymentIdentity, - skipChannelId?: string | undefined, -): Promise { - const record = await findReusableSession({ - authorizedSigner: identity.signerAddress, - chainId: details.chainId, - escrowContract: details.escrowContract, - origin: details.origin, - payee: details.payee, - payer: identity.address, - token: details.token, - }); - if (!record) return undefined; - if (skipChannelId && record.channel_id.toLowerCase() === skipChannelId.toLowerCase()) - return undefined; - if ( - record.escrow_contract.toLowerCase() === TempoChannel.address.toLowerCase() && - !record.descriptor_json - ) { - await deleteSessionRecord(record.channel_id); - return await reusableSessionRecord(details, identity, skipChannelId); - } - - const onChain = await readOnChainChannel(record); - if (!onChain) { - await deleteSessionRecord(record.channel_id); - return undefined; - } - if (onChain.closeRequestedAt !== 0n || onChain.deposit <= onChain.settled) { - await deleteSessionRecord(record.channel_id); - return undefined; - } - if ( - onChain.payer.toLowerCase() !== identity.address.toLowerCase() || - onChain.payee.toLowerCase() !== details.payee || - onChain.token.toLowerCase() !== details.token || - onChain.authorizedSigner.toLowerCase() !== identity.signerAddress.toLowerCase() - ) { - await deleteSessionRecord(record.channel_id); - return undefined; - } - return record; -} - -export async function isSessionInvalidationResponse(response: Response) { - if (response.status !== 404 && response.status !== 410) return false; - - const authenticate = response.headers.get("www-authenticate")?.toLowerCase() ?? ""; - if (authenticate.includes("mpp") || authenticate.includes("payment")) return true; - - const body = await response - .clone() - .text() - .catch(() => ""); - const text = body.toLowerCase(); - return ( - (text.includes("session") || text.includes("channel")) && - (text.includes("not found") || - text.includes("invalid") || - text.includes("expired") || - text.includes("closed")) - ); -} - -async function tryTopUpAndRetry(options: { - credential: string; - details: SessionDetails; - identity: PaymentIdentity; - options: RequestOptions; - payment: ReturnType; - paymentRequiredResponse: Response; - record: PersistedSessionRecord; - request: FetchPlan; - response: Response; - signedCumulative: bigint; -}) { - const body = await options.response.text().catch(() => ""); - const additionalDeposit = topUpAmountFromProblem(body, options.record, options.signedCumulative); - if (additionalDeposit <= 0n) return undefined; - if (options.options.maxSpend) { - const maxSpend = parseUnits(options.options.maxSpend, 6); - if (options.record.deposit + additionalDeposit > maxSpend) return undefined; - } - - const transaction = await buildTopUpTransaction({ - additionalDeposit, - details: options.details, - identity: options.identity, - record: options.record, - }); - const topUpCredential = await options.payment.createCredential( - tempoPaymentChallengeResponse(options.paymentRequiredResponse), - { - action: "topUp", - additionalDepositRaw: additionalDeposit.toString(), - channelId: options.record.channel_id, - ...(options.record.descriptor_json - ? { descriptor: requireSessionDescriptor(options.record) } - : {}), - transaction, - } as never, - ); - const topUpInit = topUpRequestInit(options.request.init); - const authorizedTopUp = options.payment.transport.setCredential(topUpInit, topUpCredential); - const topUpResponse = await fetchWithRetries( - { init: authorizedTopUp, url: options.paymentRequiredResponse.url || options.request.url }, - options.options, - ); - if (topUpResponse.status >= 400) return topUpResponse; - - await upsertSessionRecord({ - ...options.record, - deposit: options.record.deposit + additionalDeposit, - last_used_at: nowSeconds(), - }); - await persistSessionReceipt(topUpResponse, options.record.channel_id, options.signedCumulative); - - const paidInit = options.payment.transport.setCredential( - cloneRequestInit(options.request.init), - options.credential, - ); - const retried = await fetchWithRetries( - { init: paidInit, url: options.paymentRequiredResponse.url || options.request.url }, - options.options, - ); - await persistSessionReceipt(retried, options.record.channel_id, options.signedCumulative); - return retried; -} - -async function buildTopUpTransaction(options: { - additionalDeposit: bigint; - details: SessionDetails; - identity: PaymentIdentity; - record: PersistedSessionRecord; -}) { - const request = buildTopUpTransactionRequest({ - additionalDeposit: options.additionalDeposit, - details: options.details, - record: options.record, - }); - const client = options.identity.getClient({ chainId: options.details.chainId }); - const prepared = await prepareTransactionRequest( - client as never, - { - account: (client as { account: unknown }).account, - ...request, - } as never, - ); - prepared.gas = (prepared.gas ?? 0n) + 5_000n; - return (await signTransaction(client as never, prepared as never)) as `0x${string}`; -} - -export function buildTopUpTransactionRequest(options: { - additionalDeposit: bigint; - details: Pick; - record: Pick; -}) { - const isPrecompile = - options.record.escrow_contract.toLowerCase() === TempoChannel.address.toLowerCase(); - const topUpData = encodeFunctionData({ - abi: isPrecompile ? TempoAbis.tip20ChannelReserve : escrowAbi, - functionName: "topUp", - args: isPrecompile - ? [requireSessionDescriptor(options.record), options.additionalDeposit] - : [options.record.channel_id as `0x${string}`, options.additionalDeposit], - } as never); - const calls = isPrecompile - ? [{ to: options.record.escrow_contract as `0x${string}`, data: topUpData }] - : [ - { - to: options.details.token as `0x${string}`, - data: encodeFunctionData({ - abi: tip20Abi, - functionName: "approve", - args: [options.record.escrow_contract as `0x${string}`, options.additionalDeposit], - }), - }, - { to: options.record.escrow_contract as `0x${string}`, data: topUpData }, - ]; - return { - calls, - ...(options.details.feePayer ? { feePayer: true } : {}), - feeToken: options.details.token, - }; -} - -function topUpRequestInit(init: RequestInitWithDispatcher) { - const next = cloneRequestInit(init); - next.method = "POST"; - delete next.body; - const headers = new Headers(next.headers); - headers.delete("content-length"); - headers.delete("content-type"); - next.headers = headers; - return next; -} - -function topUpAmountFromProblem( - body: string, - record: PersistedSessionRecord, - signedCumulative: bigint, -) { - const problem = parseProblemDetails(body); - if (!problem) return 0n; - const type = `${problem.type ?? ""} ${problem.title ?? ""} ${problem.detail ?? ""}`.toLowerCase(); - if (!type.includes("insufficient") && !type.includes("exceeds") && !type.includes("deposit")) - return 0n; - const requiredTopUp = stringValue(problem.requiredTopUp ?? problem.required_top_up); - if (/^\d+$/.test(requiredTopUp)) return BigInt(requiredTopUp); - return signedCumulative > record.deposit ? signedCumulative - record.deposit : 0n; -} - -function parseProblemDetails(body: string) { - try { - return getRecord(JSON.parse(body) as unknown); - } catch { - return null; - } -} - -const tip20Abi = [ - { - type: "function", - name: "approve", - stateMutability: "nonpayable", - inputs: [ - { name: "spender", type: "address" }, - { name: "amount", type: "uint256" }, - ], - outputs: [], - }, -] as const; - -function sessionDepositRaw(details: SessionDetails, options: RequestOptions) { - const maxSpend = options.maxSpend ? parseUnits(options.maxSpend, 6) : undefined; - const preferred = details.suggestedDeposit ?? maxSpend ?? details.amount; - const deposit = maxSpend && preferred > maxSpend ? maxSpend : preferred; - if (deposit < details.amount) throw paymentError("Session payment exceeds --max-spend"); - return deposit; -} - -async function assertSufficientSessionBalance( - payer: string, - details: SessionDetails, - depositRaw: bigint, -) { - const client = createTempoPublicClient(details.chainId === 42431 ? "testnet" : undefined); - const balance = ( - await Actions.token.getBalance(client as never, { - account: payer as `0x${string}`, - token: details.token as `0x${string}`, - }) - ).amount; - if (balance >= depositRaw) return; - throw paymentError( - `Insufficient balance for session deposit: available=${formatTokenAmount(balance)} required=${formatTokenAmount(depositRaw)}`, - ); -} - -function sessionRecordFromOpenCredential(options: { - challenge: Challenge.Challenge; - credential: string; - depositRaw: bigint; - details: SessionDetails; - identity: PaymentIdentity; - signedCumulative: bigint; -}): PersistedSessionRecord { - const parsed = Credential.deserialize>(options.credential); - const payload = getRecord(parsed.payload); - const channelId = stringValue(payload.channelId).toLowerCase(); - if (!channelId) throw paymentError("Session open credential did not include channelId"); - const now = nowSeconds(); - return { - accepted_cumulative: 0n, - authorized_signer: stringValue( - payload.authorizedSigner || options.identity.signerAddress, - ).toLowerCase(), - chain_id: options.details.chainId, - challenge_echo: challengeEchoJson(options.challenge), - channel_id: channelId, - close_requested_at: 0, - created_at: now, - cumulative_amount: options.signedCumulative, - deposit: options.depositRaw, - descriptor_json: payload.descriptor ? JSON.stringify(payload.descriptor) : undefined, - escrow_contract: options.details.escrowContract, - grace_ready_at: 0, - last_used_at: now, - network: networkName(options.details.chainId) ?? `chain-${options.details.chainId}`, - origin: options.details.origin, - payee: options.details.payee, - payer: options.identity.address.toLowerCase(), - request_url: options.details.requestUrl, - salt: "0x00", - server_spent: 0n, - session_protocol: sessionProtocol(options.challenge) ?? (payload.descriptor ? "v2" : "v1"), - state: "active", - token: options.details.token, - }; -} - -function sessionProtocol(challenge: Challenge.Challenge) { - const request = challenge.request as Record; - const methodDetails = getRecord(request.methodDetails); - return stringValue(methodDetails.sessionProtocol) || undefined; -} - -async function persistSessionReceipt( - response: Response, - channelId: string, - signedCumulative: bigint, -) { - const header = response.headers.get("payment-receipt"); - if (!header) return; - const receipt = TempoSession.Precompile.Receipt.deserializeSessionReceipt(header); - if (receipt.method !== "tempo" || receipt.intent !== "session" || receipt.status !== "success") - return; - if (receipt.channelId.toLowerCase() !== channelId.toLowerCase()) return; - const acceptedCumulative = BigInt(receipt.acceptedCumulative); - const serverSpent = BigInt(receipt.spent); - if (serverSpent > acceptedCumulative || acceptedCumulative > signedCumulative) - throw paymentError("Invalid session receipt cumulative values"); - await updateSessionReceipt({ - acceptedCumulative, - channelId, - serverSpent, - signedCumulative, - }); -} - -async function readOnChainChannel(record: PersistedSessionRecord) { - const network = record.chain_id === 42431 ? "testnet" : "mainnet"; - if (record.escrow_contract.toLowerCase() === TempoChannel.address.toLowerCase()) { - const state = (await createTempoPublicClient(network).readContract({ - address: TempoChannel.address, - abi: TempoAbis.tip20ChannelReserve, - functionName: "getChannelState", - args: [record.channel_id as `0x${string}`], - })) as unknown; - const object = getRecord(state); - const tuple = Array.isArray(state) ? state : []; - const deposit = parseOnChainBigInt(object.deposit ?? tuple[1]); - const settled = parseOnChainBigInt(object.settled ?? tuple[0]); - const closeRequestedAt = parseOnChainBigInt(object.closeRequestedAt ?? tuple[2]); - if (deposit === 0n) return null; - return { - authorizedSigner: record.authorized_signer, - closeRequestedAt, - deposit, - payee: record.payee, - payer: record.payer, - settled, - token: record.token, - }; - } - - const value = (await createTempoPublicClient(network).readContract({ - address: record.escrow_contract as `0x${string}`, - abi: escrowAbi, - functionName: "getChannel", - args: [record.channel_id as `0x${string}`], - })) as unknown; - const object = getRecord(value); - const tuple = Array.isArray(value) ? value : []; - if (object.finalized ?? tuple[0]) return null; - return { - authorizedSigner: stringValue(object.authorizedSigner ?? tuple[5]), - closeRequestedAt: parseOnChainBigInt(object.closeRequestedAt ?? tuple[1]), - deposit: parseOnChainBigInt(object.deposit ?? tuple[6]), - payee: stringValue(object.payee ?? tuple[3]), - payer: stringValue(object.payer ?? tuple[2]), - settled: parseOnChainBigInt(object.settled ?? tuple[7]), - token: stringValue(object.token ?? tuple[4]), - }; -} - function sessionChallengeFromHeader(header: string | null) { if (!header) return undefined; try { @@ -1197,37 +683,6 @@ function sessionChallengeFromHeader(header: string | null) { } } -function challengeEchoJson(challenge: Challenge.Challenge) { - return JSON.stringify({ - id: challenge.id, - realm: challenge.realm, - method: challenge.method, - intent: challenge.intent, - request: PaymentRequest.serialize(challenge.request), - ...(challenge.expires ? { expires: challenge.expires } : {}), - ...(challenge.digest ? { digest: challenge.digest } : {}), - ...(challenge.opaque ? { opaque: challenge.opaque } : {}), - }); -} - -function enforceSessionMaxSpend(cumulativeAmount: bigint, options: RequestOptions) { - if (!options.maxSpend) return; - const maxSpend = parseUnits(options.maxSpend, 6); - if (cumulativeAmount <= maxSpend) return; - throw paymentError( - `Payment max spend exceeded: max=${options.maxSpend} required=${formatTokenAmount(cumulativeAmount)}`, - ); -} - -function bigintField(value: unknown, name: string) { - if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value); - throw paymentError(`Session challenge is missing ${name}`); -} - -function maxBigInt(...values: bigint[]) { - return values.reduce((max, value) => (value > max ? value : max), 0n); -} - async function writeResponseBody( response: Response, options: RequestOptions, diff --git a/src/payment/managed-session.ts b/src/payment/managed-session.ts new file mode 100644 index 0000000..a84b535 --- /dev/null +++ b/src/payment/managed-session.ts @@ -0,0 +1,50 @@ +import { tempo } from "mppx/client"; +import type { ChannelStore } from "mppx/client"; +import { createSqliteChannelStore, type SqliteChannelStore } from "mppx/client/node"; + +type SessionManagerParameters = Parameters[0]; + +export type ManagedSessionRequest = { + channelStore?: ChannelStore | undefined; + fetch: typeof globalThis.fetch; + init: RequestInit; + managerOptions: Omit< + SessionManagerParameters, + "bootstrap" | "channelStore" | "fetch" | "maxDeposit" + >; + maxDeposit?: string | undefined; + url: string; +}; + +/** + * Executes one paid request through MPPx's owned TIP-1034 session lifecycle. + * + * CLI callers default to the SQLite store shared with Tempo Wallet. MPPx + * bootstraps from authenticated server state before consulting that local + * cache, and validates recovered channels against Tempo on-chain state. + */ +export async function fetchManagedSession(parameters: ManagedSessionRequest) { + const ownedStore = parameters.channelStore + ? undefined + : createRequestChannelStore(parameters.url); + const channelStore = parameters.channelStore ?? ownedStore; + try { + const manager = tempo.session.manager({ + ...parameters.managerOptions, + bootstrap: true, + channelStore, + fetch: parameters.fetch, + ...(parameters.maxDeposit ? { maxDeposit: parameters.maxDeposit } : {}), + }); + return await manager.fetch(parameters.url, parameters.init); + } finally { + ownedStore?.close(); + } +} + +function createRequestChannelStore(url: string): SqliteChannelStore { + return createSqliteChannelStore({ + namespace: new URL(url).origin, + requestUrl: url, + }); +} diff --git a/test/request.test.ts b/test/request.test.ts index e532992..056a344 100644 --- a/test/request.test.ts +++ b/test/request.test.ts @@ -5,13 +5,11 @@ import { join } from "node:path"; import { Challenge, Credential, Method, z } from "mppx"; import { Mppx, session as tempoSession } from "mppx/client"; import { Keystore } from "accounts"; -import { createClient, custom, decodeFunctionData } from "viem"; -import { Abis as TempoAbis, Channel as TempoChannel, KeyAuthorizationManager } from "viem/tempo"; +import { createClient, custom } from "viem"; +import { KeyAuthorizationManager } from "viem/tempo"; import { afterEach, describe, expect, it } from "vitest"; import { - buildTopUpTransactionRequest, - isSessionInvalidationResponse, parseRequestArgs, resolvePaymentIdentity, runRequest, @@ -266,6 +264,50 @@ describe("request command", () => { expect(await readFile(headersPath, "utf8")).toContain("www-authenticate"); }); + it("uses managed session bootstrap before retrying a Tempo session challenge", async () => { + await useTempHome(); + const methods: string[] = []; + let getCount = 0; + const challenge = Challenge.from({ + id: "session-bootstrap-test", + intent: "session", + method: "tempo", + realm: "openai.test", + request: { + amount: "1", + currency: "0x20c0000000000000000000000000000000000001", + methodDetails: { + chainId: 4217, + escrowContract: "0x4d50500000000000000000000000000000000000", + sessionProtocol: "v2", + }, + recipient: "0x0000000000000000000000000000000000000002", + unitType: "request", + }, + }); + const server = await testServer((request, response) => { + const method = request.method ?? ""; + methods.push(method); + if (method === "GET" && getCount++ === 0) { + response.statusCode = 402; + response.setHeader("www-authenticate", Challenge.serialize(challenge)); + response.end("Payment Required"); + return; + } + response.statusCode = method === "HEAD" ? 204 : 200; + response.end(method === "HEAD" ? undefined : "ok"); + }); + const stdout = captureStdout(); + + await runRequest( + ["--private-key", `0x${"01".repeat(32)}`, "--max-spend", "0", server.url("/paid")], + { stdout }, + ); + + expect(methods).toEqual(["GET", "HEAD", "GET"]); + expect(stdout.text()).toBe("ok"); + }); + it("ignores x402 payment-required headers when a Tempo payment challenge is present", async () => { const method = Method.from({ name: "tempo", @@ -598,85 +640,12 @@ describe("request command", () => { expect(reusable?.session_protocol).toBe("v2"); expect(reusable?.descriptor_json).toBe(JSON.stringify(descriptor)); }); - - it("does not invalidate reusable sessions for ordinary upstream 404 responses", async () => { - await expect( - isSessionInvalidationResponse( - new Response(JSON.stringify({ error: { message: "model not found" } }), { - status: 404, - headers: { "content-type": "application/json" }, - }), - ), - ).resolves.toBe(false); - - await expect( - isSessionInvalidationResponse( - new Response("session channel not found", { - status: 404, - headers: { "www-authenticate": "Payment method=tempo" }, - }), - ), - ).resolves.toBe(true); - }); - - it("builds v2 top-up transactions as one descriptor-based precompile call with fee payer", () => { - const descriptor = sessionDescriptor(); - const request = buildTopUpTransactionRequest({ - additionalDeposit: 5_000n, - details: { - feePayer: true, - token: descriptor.token, - }, - record: { - channel_id: `0x${"5".repeat(64)}`, - descriptor_json: JSON.stringify(descriptor), - escrow_contract: TempoChannel.address, - }, - }); - - expect(request.feePayer).toBe(true); - expect(request.feeToken).toBe(descriptor.token); - expect(request.calls).toHaveLength(1); - expect(request.calls[0]?.to.toLowerCase()).toBe(TempoChannel.address.toLowerCase()); - - const decoded = decodeFunctionData({ - abi: TempoAbis.tip20ChannelReserve, - data: request.calls[0]!.data, - }); - expect(decoded.functionName).toBe("topUp"); - expect(normalizeDescriptor(decoded.args[0])).toEqual(normalizeDescriptor(descriptor)); - expect(decoded.args[1]).toBe(5_000n); - }); }); function requestOptions(url: string): ReturnType { return parseRequestArgs([url]); } -function sessionDescriptor() { - return { - authorizedSigner: testAccessKey, - expiringNonceHash: `0x${"3".repeat(64)}` as `0x${string}`, - operator: "0x0000000000000000000000000000000000000000" as `0x${string}`, - payee: "0x0000000000000000000000000000000000000ccc" as `0x${string}`, - payer: testWallet, - salt: `0x${"4".repeat(64)}` as `0x${string}`, - token: "0x0000000000000000000000000000000000000ddd" as `0x${string}`, - }; -} - -function normalizeDescriptor(value: unknown) { - const descriptor = value as ReturnType; - return { - ...descriptor, - authorizedSigner: descriptor.authorizedSigner.toLowerCase(), - operator: descriptor.operator.toLowerCase(), - payee: descriptor.payee.toLowerCase(), - payer: descriptor.payer.toLowerCase(), - token: descriptor.token.toLowerCase(), - }; -} - async function testServer( handler: (request: IncomingMessage, response: ServerResponse) => void | Promise, ) {