diff --git a/.changelog/fallback-to-charge-for-legacy-sessions.md b/.changelog/fallback-to-charge-for-legacy-sessions.md new file mode 100644 index 0000000..140a06e --- /dev/null +++ b/.changelog/fallback-to-charge-for-legacy-sessions.md @@ -0,0 +1,5 @@ +--- +wallet-cli: patch +--- + +Fall back to compatible charge challenges when servers also offer legacy Tempo sessions. diff --git a/src/commands/request.ts b/src/commands/request.ts index b29d5dd..0a5c0ad 100644 --- a/src/commands/request.ts +++ b/src/commands/request.ts @@ -5,7 +5,7 @@ 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 { Challenge, Constants, Credential, PaymentRequest } from "mppx"; import { Mppx, session as tempoSession, tempo } from "mppx/client"; import { Keystore } from "accounts"; import { Session as TempoSession } from "mppx/tempo"; @@ -1229,13 +1229,18 @@ async function readOnChainChannel(record: PersistedSessionRecord) { }; } -function sessionChallengeFromHeader(header: string | null) { +export function sessionChallengeFromHeader(header: string | null) { if (!header) return undefined; try { - const challenges = Challenge.deserializeList(header).filter( - (challenge) => challenge.method === "tempo" && challenge.intent === "session", + return Challenge.deserializeList(header).find( + (challenge) => + challenge.method === "tempo" && + challenge.intent === "session" && + Constants.getMethodDetail( + challenge.request.methodDetails, + Constants.MethodDetailKeys.sessionProtocol, + ) === Constants.SessionProtocols.v2, ); - return challenges[0]; } catch { return undefined; } diff --git a/test/request.test.ts b/test/request.test.ts index aeaad0a..c68e1c0 100644 --- a/test/request.test.ts +++ b/test/request.test.ts @@ -15,6 +15,7 @@ import { parseRequestArgs, resolvePaymentIdentity, runRequest, + sessionChallengeFromHeader, storedAccessKeyIdentity, tempoPaymentChallengeResponse, } from "../src/commands/request.js"; @@ -316,6 +317,51 @@ describe("request command", () => { expect(Credential.deserialize(credential).payload).toEqual({ ok: true }); }); + it.each([ + { name: "unversioned", protocols: [undefined], selectedId: undefined }, + { name: "v1", protocols: ["v1"], selectedId: undefined }, + { name: "v2", protocols: ["v2"], selectedId: "session-v2" }, + { name: "mixed v1 and v2", protocols: ["v1", "v2"], selectedId: "session-v2" }, + ])( + "uses session-first routing only for $name session challenges", + ({ protocols, selectedId }) => { + const charge = Challenge.from({ + id: "charge", + intent: "charge", + method: "tempo", + realm: "example", + request: { + amount: "1", + currency: "0x20c000000000000000000000b9537d11c60e8b50", + methodDetails: { chainId: 4217 }, + recipient: "0x0000000000000000000000000000000000000001", + }, + }); + const sessions = protocols.map((protocol) => + Challenge.from({ + id: `session-${protocol ?? "unversioned"}`, + intent: "session", + method: "tempo", + realm: "example", + request: { + amount: "1", + currency: "0x20c000000000000000000000b9537d11c60e8b50", + methodDetails: { + chainId: 4217, + ...(protocol ? { sessionProtocol: protocol } : {}), + }, + recipient: "0x0000000000000000000000000000000000000001", + }, + }), + ); + const header = [charge, ...sessions] + .map((challenge) => Challenge.serialize(challenge)) + .join(", "); + + expect(sessionChallengeFromHeader(header)?.id).toBe(selectedId); + }, + ); + it("returns E_PAYMENT for non-dry-run 402 responses", async () => { const server = await testServer((_request, response) => { response.statusCode = 402;