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
101 changes: 99 additions & 2 deletions packages/atxp-server/src/omniChallenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { at: number; value: Promise<MppSessionSupport | null> }>();

/**
* 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<MppSessionSupport | null> {
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<MppSessionSupport | null> => {
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.
*
Expand Down Expand Up @@ -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 } } : {};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -379,9 +472,11 @@ export function buildOmniChallenge(args: {
mppChallengeId?: string;
/** CAIP-2 network → upto facilitator address (from GET /x402/supported). */
facilitatorAddresses?: Record<string, string>;
/** 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 {
Expand Down Expand Up @@ -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<string, string>;
/** Tempo MPP session support (from GET /mpp/supported). Advertises the session intent. */
mppSession?: MppSessionSupport;
}): {
x402: X402PaymentRequirements;
mpp: MppChallengeData[] | null;
Expand All @@ -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,
};
}
Expand Down
81 changes: 81 additions & 0 deletions packages/atxp-server/src/paymentSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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);
});
});
50 changes: 38 additions & 12 deletions packages/atxp-server/src/paymentSession.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -149,9 +170,9 @@ export async function settlePaymentSession(
appName: string | undefined,
logger: Logger,
): Promise<void> {
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,
Expand All @@ -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 ?? '<already-settled>'}, 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;
}
}
22 changes: 20 additions & 2 deletions packages/atxp-server/src/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand All @@ -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 () => {
Expand Down
Loading
Loading