diff --git a/packages/atxp-server/src/omniChallenge.ts b/packages/atxp-server/src/omniChallenge.ts index 73a176f..5f33c99 100644 --- a/packages/atxp-server/src/omniChallenge.ts +++ b/packages/atxp-server/src/omniChallenge.ts @@ -66,6 +66,61 @@ export async function fetchUptoFacilitatorAddresses( return result; } +/** + * Tempo MPP session (TIP-1034) parameters advertised by auth's GET /mpp/supported: + * the channel `authorizedSigner` + `operator` (auth's settler key), the escrow + * precompile, and the chain id. When present, the SDK advertises a `session`-intent + * challenge alongside the one-shot `charge` intent so accounts can open a metered + * channel; when absent, only `charge` is advertised (graceful fallback). + */ +export type MppSessionSupport = { + escrowContract: string; + authorizedSigner: string; + operator: string; + chainId: number; +}; + +const _mppSupportedCache = new Map }>(); + +/** + * Fetch Tempo MPP session support from the auth server's GET /mpp/supported. + * Response shape: `{ tempo: { authorizedSigner, operator, escrowContract, chainId } | null }`. + * Cached per auth server URL with the same TTL/retry semantics as the x402 fetch + * (the settler address could rotate; an unreachable auth must not be cached). + */ +export async function fetchMppSupported( + authServerUrl: AuthorizationServerUrl | string, + fetchFn: FetchLike = fetch.bind(globalThis), + logger?: { warn: (msg: string) => void }, +): Promise { + const url = new URL('/mpp/supported', authServerUrl).toString(); + const cached = _mppSupportedCache.get(url); + if (cached && Date.now() - cached.at < _FACILITATOR_ADDRESS_TTL_MS) return cached.value; + + const pending = (async (): Promise => { + try { + const response = await fetchFn(url); + if (!response.ok) { + logger?.warn(`fetchMppSupported: ${url} returned ${response.status}; advertising charge only`); + return null; + } + const body = await response.json() as { tempo?: MppSessionSupport | null }; + const tempo = body?.tempo; + if (tempo && tempo.authorizedSigner && tempo.operator && tempo.escrowContract) return tempo; + return null; + } catch (error) { + logger?.warn(`fetchMppSupported: failed to fetch ${url}: ${error}; advertising charge only`); + return null; + } + })(); + + _mppSupportedCache.set(url, { at: Date.now(), value: pending }); + const result = await pending; + // Don't cache a null (auth unreachable / not configured) — allow a later retry. + if (result === null) _mppSupportedCache.delete(url); + return result; +} + /** * Build X402 payment requirements from charge options. * @@ -187,6 +242,12 @@ export function buildMppChallenges(args: { id: string; options: Array<{ network: string; currency: string; address: string; amount: BigNumber }>; resource?: string; + /** When present, advertise a Tempo `session`-intent challenge alongside `charge` + * (TIP-1034 channel sessions). From GET /mpp/supported via fetchMppSupported. */ + mppSession?: MppSessionSupport; + /** Channel deposit budget hint (raw µUSDC) for the session challenge. accounts + * may override; a larger-than-cap deposit lets one channel serve many requests. */ + mppSuggestedDeposit?: string; }): MppChallengeData[] | null { const challenges: MppChallengeData[] = []; const resourceField = args.resource ? { resource: { url: args.resource } } : {}; @@ -238,6 +299,38 @@ export function buildMppChallenges(args: { ...resourceField, }, }); + + // Also advertise a `session`-intent challenge (TIP-1034 channel) when auth + // exposes the settler params. accounts picks charge vs session via + // ff:mpp-intent; both carry the same per-request `amount` (the cap). The + // channel params (escrow, authorizedSigner, operator) ride in + // request.methodDetails so accounts can open/reuse the channel. + if (args.mppSession) { + challenges.push({ + id: args.id, + method: 'tempo', + intent: 'session', + amount: tempoOption.amount.toString(), + currency: tempoOption.currency || 'USDC', + network: tempoOption.network, + recipient: tempoOption.address, + expires: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + ...resourceField, + request: { + amount: tempoOption.amount.toString(), + currency: tempoOption.currency || 'USDC', + recipient: tempoOption.address, + ...resourceField, + methodDetails: { + chainId: args.mppSession.chainId, + escrowContract: args.mppSession.escrowContract, + authorizedSigner: args.mppSession.authorizedSigner, + operator: args.mppSession.operator, + ...(args.mppSuggestedDeposit && { suggestedDeposit: args.mppSuggestedDeposit }), + }, + }, + }); + } } return challenges.length > 0 ? challenges : null; @@ -379,9 +472,11 @@ export function buildOmniChallenge(args: { mppChallengeId?: string; /** CAIP-2 network → upto facilitator address (from GET /x402/supported). */ facilitatorAddresses?: Record; + /** Tempo MPP session support (from GET /mpp/supported). Advertises the session intent. */ + mppSession?: MppSessionSupport; }): OmniChallenge { const mpp = args.mppChallengeId - ? buildMppChallenges({ id: args.mppChallengeId, options: args.options, resource: args.resource }) + ? buildMppChallenges({ id: args.mppChallengeId, options: args.options, resource: args.resource, mppSession: args.mppSession }) : null; return { @@ -431,6 +526,8 @@ export function buildPaymentOptions(args: { /** CAIP-2 network → upto facilitator address (from GET /x402/supported). * When absent for a network, only the exact x402 accept is advertised. */ facilitatorAddresses?: Record; + /** Tempo MPP session support (from GET /mpp/supported). Advertises the session intent. */ + mppSession?: MppSessionSupport; }): { x402: X402PaymentRequirements; mpp: MppChallengeData[] | null; @@ -447,7 +544,7 @@ export function buildPaymentOptions(args: { payeeName: args.payeeName ?? '', facilitatorAddresses: args.facilitatorAddresses, }), - mpp: buildMppChallenges({ id: challengeId, options, resource: args.resource }), + mpp: buildMppChallenges({ id: challengeId, options, resource: args.resource, mppSession: args.mppSession }), options, }; } diff --git a/packages/atxp-server/src/paymentSession.test.ts b/packages/atxp-server/src/paymentSession.test.ts index 621612f..b46ae6a 100644 --- a/packages/atxp-server/src/paymentSession.test.ts +++ b/packages/atxp-server/src/paymentSession.test.ts @@ -6,6 +6,30 @@ import * as TH from './serverTestHelpers.js'; const logger = TH.logger(); +/** A TIP-1034 Tempo session credential (channel opened on-chain at authorize). */ +function sessionCredential(amountDecimal = '0.01'): string { + return Buffer.from(JSON.stringify({ + challenge: { id: 'ch_1', method: 'tempo', intent: 'session', request: { amount: amountDecimal } }, + payload: { + action: 'voucher', + channelId: '0x' + 'aa'.repeat(32), + descriptor: { payer: '0x2', payee: '0x1' }, + cumulativeAmount: '10000', + signature: '0x' + 'bb'.repeat(65), + }, + source: 'tempo:0xpayer', + })).toString('base64'); +} + +/** An MPP one-shot `charge` credential (no on-chain channel; no intent/descriptor). */ +function chargeCredential(): string { + return Buffer.from(JSON.stringify({ + challenge: { id: 'ch_1', method: 'tempo', request: { amount: '0.01' } }, + payload: { action: 'transaction', transaction: '0xdeadbeef' }, + source: 'tempo:0xpayer', + })).toString('base64'); +} + describe('PaymentSession.charge', () => { it('accumulates charges across multiple calls', () => { // x402 cap of 1.00 USDC (atomic 1_000_000 / 1e6). @@ -169,4 +193,61 @@ describe('settlePaymentSession', () => { expect(actualAmount.toNumber()).toBeCloseTo(0.001); expect(actualAmount.isLessThan(session.cap)).toBe(true); }); + + // --- TIP-1034 channel sessions (requiresClose) --- + + it('flags requiresClose only for MPP session credentials', () => { + expect(new PaymentSessionState('mpp', sessionCredential(), {}, logger).requiresClose).toBe(true); + // One-shot MPP charge: no on-chain channel to tear down. + expect(new PaymentSessionState('mpp', chargeCredential(), {}, logger).requiresClose).toBe(false); + expect(new PaymentSessionState('atxp', JSON.stringify({ options: [{ amount: '0.01' }] }), {}, logger).requiresClose).toBe(false); + expect(new PaymentSessionState('x402', 'cred', { paymentRequirements: { amount: '100000' } }, logger).requiresClose).toBe(false); + }); + + it('settles a channel session even at spent == 0 (closes + refunds the locked deposit)', async () => { + // Bug: the early-return on spent<=0 strands the on-chain deposit opened at + // authorize. A session must still settle (capture 0, refund the deposit). + const settleSpy = vi.spyOn(ProtocolSettlement.prototype, 'settle').mockResolvedValue({ txHash: '0xclose', settledAmount: '0' }); + const session = new PaymentSessionState('mpp', sessionCredential(), {}, logger); + expect(session.spent.toNumber()).toBe(0); + + await settlePaymentSession(session, 'https://auth.atxp.ai', 'tempo:dest', undefined, logger); + + expect(settleSpy).toHaveBeenCalledTimes(1); + expect((settleSpy.mock.calls[0][3] as BigNumber).toNumber()).toBe(0); + expect(session.settled).toBe(true); + }); + + it('leaves a channel session unsettled when settle throws, so the locked deposit can be re-driven', async () => { + const settleSpy = vi.spyOn(ProtocolSettlement.prototype, 'settle').mockRejectedValueOnce(new Error('auth unreachable')); + const session = new PaymentSessionState('mpp', sessionCredential(), {}, logger); + session.charge(BigNumber(0.003)); + + await settlePaymentSession(session, 'https://auth.atxp.ai', 'tempo:dest', undefined, logger); + // Failure must NOT mark settled — otherwise the deposit is stranded forever. + expect(session.settled).toBe(false); + expect(settleSpy).toHaveBeenCalledTimes(1); + + // A later re-drive succeeds (on-chain close is idempotent). + settleSpy.mockResolvedValueOnce({ txHash: '0xclose', settledAmount: '0.003' }); + await settlePaymentSession(session, 'https://auth.atxp.ai', 'tempo:dest', undefined, logger); + expect(settleSpy).toHaveBeenCalledTimes(2); + expect(session.settled).toBe(true); + }); + + it('settles at most once under re-entrant calls (settling guard)', async () => { + let resolveSettle: (v: { txHash: string; settledAmount: string }) => void = () => {}; + const settleSpy = vi.spyOn(ProtocolSettlement.prototype, 'settle') + .mockReturnValue(new Promise((r) => { resolveSettle = r; })); + const session = new PaymentSessionState('mpp', sessionCredential(), {}, logger); + session.charge(BigNumber(0.001)); + + const p1 = settlePaymentSession(session, 'https://auth.atxp.ai', 'tempo:dest', undefined, logger); + const p2 = settlePaymentSession(session, 'https://auth.atxp.ai', 'tempo:dest', undefined, logger); + resolveSettle({ txHash: '0xclose', settledAmount: '0.001' }); + await Promise.all([p1, p2]); + + expect(settleSpy).toHaveBeenCalledTimes(1); + expect(session.settled).toBe(true); + }); }); diff --git a/packages/atxp-server/src/paymentSession.ts b/packages/atxp-server/src/paymentSession.ts index 861f07a..b0ee86b 100644 --- a/packages/atxp-server/src/paymentSession.ts +++ b/packages/atxp-server/src/paymentSession.ts @@ -1,6 +1,6 @@ import { BigNumber } from "bignumber.js"; import { Logger, type PaymentProtocol } from "@atxp/common"; -import { ProtocolSettlement, SettlementContext, parseCredentialBase64 } from "./protocol.js"; +import { ProtocolSettlement, SettlementContext, parseCredentialBase64, isMppSessionCredential } from "./protocol.js"; import type { DetectedCredential } from "./atxpContext.js"; /** @@ -105,6 +105,16 @@ export class PaymentSessionState implements PaymentSession { cap: BigNumber; spent: BigNumber = new BigNumber(0); settled = false; + /** Guards against re-entrant settle (e.g. res.end firing more than once). */ + settling = false; + /** + * True when settling tears down an on-chain resource that exists regardless of + * spend — a TIP-1034 MPP session opens a channel (deposit locked) at authorize. + * Such a session MUST settle even at `spent == 0` to refund the locked deposit, + * and a failed settle must stay retryable. One-shot protocols have nothing to + * tear down, so a zero-spend or failed settle is a no-op / fire-and-forget. + */ + readonly requiresClose: boolean; constructor( readonly protocol: PaymentProtocol, @@ -113,6 +123,7 @@ export class PaymentSessionState implements PaymentSession { logger: Logger, ) { this.cap = deriveCap(protocol, credential, context, logger); + this.requiresClose = protocol === 'mpp' && isMppSessionCredential(credential); } charge(cost: BigNumber): boolean { @@ -138,9 +149,19 @@ export function buildPaymentSession( } /** - * Settle the session if it was charged and not already settled. Idempotent: - * subsequent calls (e.g. if res.end fires more than once) are no-ops. Builds - * the ProtocolSettlement from config exactly as the middleware did previously. + * Settle the session at response close. Idempotent and re-entrancy-safe: + * concurrent or repeat calls (e.g. res.end firing more than once) settle at most + * once. Builds the ProtocolSettlement from config exactly as the middleware did + * previously. + * + * Two protocol-shape-dependent rules (see PaymentSessionState.requiresClose): + * - A session that locked funds on-chain (MPP channel) settles even at + * `spent == 0` — that close refunds the locked deposit. One-shot protocols + * have nothing to settle at zero spend, so they short-circuit. + * - On settle failure, a channel session stays unsettled so the locked deposit + * can be re-driven later (on-chain settle is idempotent). One-shot protocols + * have nothing to re-drive — the served request is marked settled to avoid a + * pointless re-attempt (reconcile via the logged marker; atxp-dev/sdk#178). */ export async function settlePaymentSession( session: PaymentSessionState, @@ -149,9 +170,9 @@ export async function settlePaymentSession( appName: string | undefined, logger: Logger, ): Promise { - if (session.settled) return; - if (session.spent.isLessThanOrEqualTo(0)) return; - session.settled = true; + if (session.settled || session.settling) return; + if (session.spent.isLessThanOrEqualTo(0) && !session.requiresClose) return; + session.settling = true; const settlement = new ProtocolSettlement( authServer, @@ -169,15 +190,20 @@ export async function settlePaymentSession( // "up-to" semantics: settle the accumulated actual (the sum of charged // prices, ≤ cap), not the cap. For a single requirePayment(price), spent // is that price — which equals the cap only when the cap wasn't inflated - // by the server's minimumPayment. + // by the server's minimumPayment. For a channel session at zero spend this + // is 0: auth closes the channel capturing nothing and refunds the deposit. session.spent, ); + session.settled = true; logger.info(`Settled ${session.protocol} at session close: txHash=${result.txHash ?? ''}, amount=${result.settledAmount}`); } catch (error) { - // No retry/outbox yet (tracked in atxp-dev/sdk#178): the request is already - // served and settled=true prevents re-attempt at close. Log a greppable, - // metric-able marker carrying protocol + amount so an unbilled served - // request can be reconciled later. + // Log a greppable, metric-able marker carrying protocol + amount so an + // unbilled served request can be reconciled later. logger.error(`settle_failed_at_close protocol=${session.protocol} amount=${session.spent.toFixed()}: ${error instanceof Error ? error.message : String(error)}`); + // One-shot protocols: nothing to re-drive, mark settled. Channel sessions: + // leave unsettled so the locked deposit can be closed on a later re-drive. + if (!session.requiresClose) session.settled = true; + } finally { + session.settling = false; } } diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index 50a9c60..98f733e 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -447,7 +447,10 @@ describe('ProtocolSettlement', () => { expect(body.settlementOverrides).toEqual({ amount: '3000' }); }); - it('still warns (greppable) and drops actualAmount for mpp (up-to not yet wired)', async () => { + it('does not set settlementOverrides for a one-shot mpp charge credential (up-to is session-only)', async () => { + // A `charge`-intent credential settles the pre-signed transfer as-is, so + // actualAmount must NOT become a settlementOverrides.amount — that override + // is only for `session`-intent (TIP-1034 channel) credentials. mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xmpp', settledAmount: '10000' }) }); const mppCredential = Buffer.from(JSON.stringify({ challenge: { id: 'ch', method: 'tempo', intent: 'charge', request: { amount: '10000' } }, @@ -457,7 +460,22 @@ describe('ProtocolSettlement', () => { await settlement.settle('mpp', mppCredential, undefined, BigNumber(0.003)); - expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('settle_actual_dropped protocol=mpp')); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.settlementOverrides).toBeUndefined(); + }); + + it('sets settlementOverrides.amount (raw µUSDC) for a session-intent mpp credential', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xmpp', settledAmount: '3000' }) }); + const sessionCredential = Buffer.from(JSON.stringify({ + challenge: { id: 'ch', method: 'tempo', intent: 'session', request: { amount: '0.01' } }, + payload: { action: 'voucher', channelId: '0x' + 'aa'.repeat(32), descriptor: { payer: '0x2', payee: '0x1' }, cumulativeAmount: '10000', signature: '0x' + 'bb'.repeat(65) }, + source: 'tempo:0xpayer', + })).toString('base64url'); + + await settlement.settle('mpp', sessionCredential, undefined, BigNumber(0.003)); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.settlementOverrides).toEqual({ amount: '3000' }); }); it('should call /settle/mpp with standard MPP credential', async () => { diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index 539d315..396b8fa 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -182,6 +182,20 @@ export function parseCredentialBase64(credential: string): Record | undefined; + const payload = parsed?.payload as Record | undefined; + return challenge?.intent === 'session' || payload?.descriptor != null; +} + /** * Constructor options for `ProtocolSettlement`. Kept as a trailing options * bag so new knobs can be added without shifting positional arguments. @@ -328,13 +342,6 @@ export class ProtocolSettlement { * See docs/STREAMING_PAYMENT_SESSIONS.md. */ private buildRequestBody(protocol: PaymentProtocol, credential: string, context?: SettlementContext, actualAmount?: BigNumber): unknown { - // mpp has no up-to mapping yet, so the settle body still carries the - // credential's cap and a multi-charge session over-settles relative to the - // metered actual. Emit a greppable marker so the overcharge is visible. - if (actualAmount && protocol === 'mpp') { - this.logger.warn(`settle_actual_dropped protocol=${protocol} actual=${actualAmount.toFixed()}: up-to settlement not yet wired for ${protocol}; settling the credential cap`); - } - if (protocol === 'x402') { // X402: auth expects { payload, paymentRequirements } // The credential is the base64-encoded PAYMENT-SIGNATURE header containing the payload. @@ -417,8 +424,25 @@ export class ProtocolSettlement { if (!parsedCredential) { throw new Error('MPP credential is not valid base64 JSON or raw JSON'); } + + // "up-to" semantics for TIP-1034 session credentials: settle the metered + // actual (≤ the channel deposit) by passing settlementOverrides.amount in + // raw atomic µUSDC. auth signs a voucher for (on-chain settled + this + // amount) and submits settle() as the operator. ONLY for session + // credentials — the one-shot `charge` path ignores the override and + // settles the pre-signed transfer as-is. Detect session via the + // challenge intent or a channel descriptor on the payload. + const isSession = isMppSessionCredential(credential); + let settlementOverrides = {}; + if (actualAmount && isSession) { + // session.spent is decimal USDC; the channel encodes raw µUSDC (uint96). + // toFixed(0) keeps it an integer string (no decimals/exponential). + settlementOverrides = { settlementOverrides: { amount: new BigNumber(actualAmount.times(1e6).toFixed(0)).toFixed(0) } }; + } + return { credential: parsedCredential, + ...settlementOverrides, ...(context?.paymentRequestId && { paymentRequestId: context.paymentRequestId }), ...(context?.sourceAccountId && { sourceAccountId: context.sourceAccountId }), ...(this.destinationAccountId && { destinationAccountId: this.destinationAccountId }), diff --git a/packages/atxp-server/src/requirePayment.ts b/packages/atxp-server/src/requirePayment.ts index 16ada2f..c5b8675 100644 --- a/packages/atxp-server/src/requirePayment.ts +++ b/packages/atxp-server/src/requirePayment.ts @@ -1,7 +1,7 @@ import { RequirePaymentConfig, extractNetworkFromAccountId, extractAddressFromAccountId, Network, AuthorizationServerUrl } from "@atxp/common"; import { BigNumber } from "bignumber.js"; import { getATXPConfig, atxpAccountId, atxpToken, getPaymentRequestId, setPendingPaymentChallenge, paymentSession } from "./atxpContext.js"; -import { buildPaymentOptions, omniChallengeMcpError, fetchUptoFacilitatorAddresses } from "./omniChallenge.js"; +import { buildPaymentOptions, omniChallengeMcpError, fetchUptoFacilitatorAddresses, fetchMppSupported } from "./omniChallenge.js"; import { getATXPResource } from "./atxpContext.js"; import { signOpaqueIdentity } from "./opaqueIdentity.js"; @@ -142,10 +142,14 @@ async function buildOmniError( ) { const resource = getATXPResource()?.toString() ?? ''; - // Fetch (once per process) the upto facilitator addresses so the x402 challenge - // can advertise the upto accept for networks the facilitator supports. On - // failure this returns {} and only the exact accept is advertised. - const facilitatorAddresses = await fetchUptoFacilitatorAddresses(config.server, undefined, config.logger); + // Fetch (once per process, TTL-cached) the protocol "supported" params so the + // challenge can advertise the metered variants: x402 upto (facilitator address) + // and MPP session (Tempo TIP-1034 channel settler). On failure each returns + // empty/null and only the base variant (x402 exact / MPP charge) is advertised. + const [facilitatorAddresses, mppSession] = await Promise.all([ + fetchUptoFacilitatorAddresses(config.server, undefined, config.logger), + fetchMppSupported(config.server, undefined, config.logger), + ]); const payment = buildPaymentOptions({ amount: paymentAmount, @@ -154,6 +158,7 @@ async function buildOmniError( payeeName: '', challengeId: paymentId, facilitatorAddresses, + mppSession: mppSession ?? undefined, }); if (payment.x402.accepts.length === 0 && sources.length > 0) {