diff --git a/typescript/src/compat/mpp.ts b/typescript/src/compat/mpp.ts index bdd114d..9925670 100644 --- a/typescript/src/compat/mpp.ts +++ b/typescript/src/compat/mpp.ts @@ -5,10 +5,26 @@ * Payment Protocol (MPP). MPP uses `WWW-Authenticate: Payment` with auth-params * and a method/intent pair — not a flat scheme token like s402 or x402. * - * Spec references (tempoxyz/mpp-specs HEAD as of 2026-05-12: e731a13): - * - Core: specs/core/draft-httpauth-payment-00.md - * - Charge: specs/intents/draft-payment-intent-charge-00.md - * - EVM: specs/methods/evm/draft-evm-charge-00.md + * Spec references (tempoxyz/mpp-specs). Cited per area rather than as one + * repo-wide baseline: the previous header declared a single commit (e731a13, + * 2026-05-12) for the whole module, which was wrong in both directions — the + * file already implemented #285 (2026-06-19, newer than the baseline) while + * lagging the method drafts by 22 commits. A global baseline asserts uniform + * conformance that a partial implementation never has. + * + * - Core: specs/core/draft-httpauth-payment-00.md @ e731a13 + * - Charge: specs/intents/draft-payment-intent-charge-00.md @ e731a13 (+ #285) + * - EVM: specs/methods/evm/draft-evm-charge-00.md @ e731a13 + * - Solana: specs/methods/solana/draft-solana-charge-00.md @ f9506cd — network only + * - Lightning: specs/methods/lightning/draft-lightning-charge-00.md @ f9506cd — network only + * - Stellar: specs/methods/stellar/draft-stellar-charge-00.md @ f9506cd — network only + * - Tempo: specs/methods/tempo/draft-tempo-charge-00.md @ f9506cd — chainId only + * + * Known NOT synced (DAN-848, criteria 3–4): `hedera`, `nearintents` and `usdc` + * are specified blockchain charge methods that this module rejects. `usdc` + * nests its chain id per chain family (`methodDetails.evm.chainId`) and + * `nearintents` is cross-chain with a CAIP-2 `originNetwork`, so neither fits + * the flat translation below without a design decision. * * Scope (v0.3 DAN-339, read-path): * - Parse `WWW-Authenticate: Payment` challenges @@ -530,6 +546,23 @@ export function decodeMppCredential( // Translation: MPP Charge → s402 requirements // ══════════════════════════════════════════════════════════════ +/** Options for {@link fromMppChargeChallenge}. */ +export interface FromMppChargeOptions { + /** Injected clock for the expiry check. Defaults to `Date.now()`. */ + now?: number; + /** + * The network this client is configured to pay on. When supplied, a challenge + * resolving to any other network is rejected. + * + * Method specs put the obligation on the client, not the server: Solana's + * charge draft says clients MUST reject challenges whose network does not + * match their configured cluster, and Lightning's says SHOULD. Supply this + * wherever the configured network is known — the check is opt-in only because + * the translator cannot discover it. + */ + expectedNetwork?: string; +} + /** * Known-mappable MPP methods. The set is deliberately conservative: * a method is only listed here if its Charge request shape reliably carries @@ -539,25 +572,106 @@ export function decodeMppCredential( */ const BLOCKCHAIN_CHARGE_METHODS = new Set(['tempo', 'evm', 'solana', 'lightning', 'stellar']); +/** + * Specified blockchain Charge methods this translator does not map. They are + * named so the rejection can say why: each has a published method draft and a + * real payTo, so calling them "processor-based" — as the single catch-all + * message used to — sends the reader looking for a problem that is not there. + * + * - hedera: EIP-155 chainId (295 mainnet / 296 testnet); would fit, unbuilt. + * - usdc: nests chain id per family (`methodDetails.evm.chainId`). + * - nearintents: cross-chain — a CAIP-2 `originNetwork` plus a distinct + * destination asset, so a single `network` field is ambiguous. + */ +const UNMAPPED_BLOCKCHAIN_CHARGE_METHODS = new Set(['hedera', 'usdc', 'nearintents']); + +/** + * Methods that name their network with an enumerated string under + * `methodDetails.network`, rather than a numeric chain id. + * + * Solana and Lightning are the same shape: an OPTIONAL `network` field, a + * closed enumeration, a spec'd default of `"mainnet"`, and a clause obliging + * clients to reject a challenge whose network differs from the one they are + * configured for (MUST for Solana, SHOULD for Lightning). + * + * - solana: draft-solana-charge-00 §Method Details + * - lightning: draft-lightning-charge-00 §Method Details + * + * **Architecture Invariant:** every member of {@link BLOCKCHAIN_CHARGE_METHODS} + * must have a resolution arm in {@link resolveNetwork}. The resolver has no + * fallback — a method added to the mappable set without one throws on every + * challenge. This is deliberate: the previous fallback returned + * `` `${method}:unknown` ``, which made two different networks compare equal + * and silently disarmed the reject-on-mismatch clause above. + */ +const ENUMERATED_NETWORK_METHODS: Record> = { + solana: new Set(['mainnet', 'devnet', 'localnet']), + lightning: new Set(['mainnet', 'regtest', 'signet']), +}; + +/** Spec'd default when a Tempo Charge omits `chainId` (draft-tempo-charge-00 §Request Schema). */ +const TEMPO_DEFAULT_CHAIN_ID = 42431; + +/** Both spec'd network enumerations default to this when the field is absent. */ +const DEFAULT_ENUMERATED_NETWORK = 'mainnet'; + +/** Normalize a numeric-or-decimal-string chain id, or `undefined` if malformed. */ +function parseChainId(value: unknown): string | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return String(value); + if (typeof value === 'string' && /^[0-9]+$/.test(value)) return value; + return undefined; +} + /** * Network identifier resolution for MPP Charge requests per method. * - * The core spec leaves network naming to individual method specs. This helper - * encodes the conventions from the published drafts — `evm:{chainId}` and - * `tempo:{chainId}` follow EIP-155-style identifiers; Solana/Lightning/Stellar - * fall back to a method-qualified default since their chain is implicit. + * The core spec leaves network naming to individual method specs, and they do + * not agree on a field: EVM and Tempo carry a numeric `chainId`, Solana and + * Lightning an enumerated `network` string, Stellar a CAIP-2 identifier already + * in its final form. This resolver reads whichever field the owning method spec + * names, and throws when it cannot — it never invents a placeholder. + * + * @throws {s402Error} `INVALID_PAYLOAD` when the method's network field is + * absent where REQUIRED, or present but outside the spec's enumeration. */ function resolveNetwork(method: string, methodDetails: Record | undefined): string { - const chainId = methodDetails?.chainId; - if (typeof chainId === 'number' && Number.isInteger(chainId) && chainId >= 0) { - if (method === 'evm') return `eip155:${chainId}`; - if (method === 'tempo') return `tempo:${chainId}`; + const enumerated = ENUMERATED_NETWORK_METHODS[method]; + if (enumerated) { + const network = methodDetails?.network ?? DEFAULT_ENUMERATED_NETWORK; + if (typeof network === 'string' && enumerated.has(network)) return `${method}:${network}`; + throw new s402Error('INVALID_PAYLOAD', + `MPP ${method} Charge "methodDetails.network" must be one of ` + + `${[...enumerated].map((n) => `"${n}"`).join(', ')} — got ${JSON.stringify(network)}`); } - if (typeof chainId === 'string' && /^[0-9]+$/.test(chainId)) { - if (method === 'evm') return `eip155:${chainId}`; - if (method === 'tempo') return `tempo:${chainId}`; + + if (method === 'stellar') { + // REQUIRED for stellar, and already a CAIP-2 identifier (`stellar:pubnet`, + // `stellar:testnet`) — the resolver's job is to validate and pass through, + // not to re-derive. The reference is left open rather than enumerated: the + // CAIP-2 Stellar namespace, not this module, decides what is valid. + const network = methodDetails?.network; + if (typeof network === 'string' && /^stellar:[-a-zA-Z0-9]{1,32}$/.test(network)) return network; + throw new s402Error('INVALID_PAYLOAD', + `MPP stellar Charge requires a CAIP-2 "methodDetails.network" ` + + `(e.g. "stellar:pubnet") — got ${JSON.stringify(network)}`); + } + + if (method === 'evm' || method === 'tempo') { + const raw = method === 'tempo' + ? methodDetails?.chainId ?? TEMPO_DEFAULT_CHAIN_ID // OPTIONAL, spec'd default + : methodDetails?.chainId; // REQUIRED for evm + const chainId = parseChainId(raw); + if (chainId === undefined) { + throw new s402Error('INVALID_PAYLOAD', + `MPP ${method} Charge "methodDetails.chainId" must be a non-negative integer ` + + `— got ${JSON.stringify(raw)}`); + } + return method === 'evm' ? `eip155:${chainId}` : `tempo:${chainId}`; } - return `${method}:unknown`; + + // Unreachable for members of BLOCKCHAIN_CHARGE_METHODS — see the invariant above. + throw new s402Error('INVALID_PAYLOAD', + `MPP method "${method}" has no network resolution rule`); } /** @@ -572,21 +686,29 @@ function resolveNetwork(method: string, methodDetails: Record | * * @throws {s402Error} `INVALID_PAYLOAD` if the method is not a known * blockchain-style Charge method, if the request is missing a recipient - * (REQUIRED for blockchain methods per charge spec), or if the challenge - * has expired at `now`. + * (REQUIRED for blockchain methods per charge spec), if the challenge + * has expired at `now`, if its network cannot be resolved, or if that + * network differs from `options.expectedNetwork`. */ export function fromMppChargeChallenge( challenge: MppChallenge, - now?: number, + optionsOrNow?: number | FromMppChargeOptions, ): s402PaymentRequirements { + const options: FromMppChargeOptions = + typeof optionsOrNow === 'number' ? { now: optionsOrNow } : optionsOrNow ?? {}; + const now = options.now; if (challenge.intent !== 'charge') { throw new s402Error('INVALID_PAYLOAD', `fromMppChargeChallenge requires intent="charge", got "${challenge.intent}"`); } if (!BLOCKCHAIN_CHARGE_METHODS.has(challenge.method)) { throw new s402Error('INVALID_PAYLOAD', - `MPP method "${challenge.method}" is not mappable to s402 requirements — ` + - `processor-based methods (stripe, card) have no payTo/asset exposed in the Charge request`); + UNMAPPED_BLOCKCHAIN_CHARGE_METHODS.has(challenge.method) + ? `MPP method "${challenge.method}" is a blockchain Charge method that this ` + + `translator does not yet map — its Charge request does not fit the flat ` + + `network/asset/payTo shape (see DAN-848)` + : `MPP method "${challenge.method}" is not mappable to s402 requirements — ` + + `processor-based methods (stripe, card) have no payTo/asset exposed in the Charge request`); } const request = decodeMppChargeRequest(challenge); @@ -609,10 +731,20 @@ export function fromMppChargeChallenge( } } + const network = resolveNetwork(challenge.method, request.methodDetails); + if (options.expectedNetwork !== undefined && network !== options.expectedNetwork) { + // Solana's spec makes this a MUST and Lightning's a SHOULD. Enforcing it at + // the lift means a caller that supplies its configured network cannot + // forget the comparison downstream. + throw new s402Error('INVALID_PAYLOAD', + `MPP challenge is for network "${network}" but this client is configured ` + + `for "${options.expectedNetwork}"`); + } + return { s402Version: S402_VERSION, accepts: ['exact'], - network: resolveNetwork(challenge.method, request.methodDetails), + network, asset: request.currency, amount: request.amount, payTo: request.recipient, diff --git a/typescript/test/compat-mpp-network-resolution.test.ts b/typescript/test/compat-mpp-network-resolution.test.ts new file mode 100644 index 0000000..bb3a344 --- /dev/null +++ b/typescript/test/compat-mpp-network-resolution.test.ts @@ -0,0 +1,306 @@ +/** + * Network-resolution conformance for s402/compat/mpp — DAN-848. + * + * Separate from `compat-mpp.test.ts` on purpose. That file greps positive for + * `solana`, but every hit is inside a `parseMppAcceptPayment` test — no Solana + * challenge has ever reached `fromMppChargeChallenge`. The gap was camouflaged + * rather than absent, which is how it survived review. This file's name states + * the axis it covers so the distinction is visible from the directory listing. + * + * Every fixture is the method spec's own example, cited inline: + * - Solana: specs/methods/solana/draft-solana-charge-00.md §Method Details + * - Lightning: specs/methods/lightning/draft-lightning-charge-00.md §Method Details + * - Stellar: specs/methods/stellar/draft-stellar-charge-00.md §Method Details + * - Tempo: specs/methods/tempo/draft-tempo-charge-00.md §Request Schema + * - EVM: specs/methods/evm/draft-evm-charge-00.md §Method Details + * (tempoxyz/mpp-specs @ f9506cd) + */ +import { describe, it, expect } from 'vitest'; +import { fromMppChargeChallenge, type MppChallenge } from '../src/compat/mpp.js'; +import { s402Error } from '../src/errors.js'; + +function base64url(input: string): string { + const b64 = Buffer.from(input, 'utf-8').toString('base64'); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +const FUTURE = new Date(Date.UTC(2099, 0, 15, 12, 5, 0)).toISOString(); + +/** Build a charge challenge for `method` carrying `request` as its JCS payload. */ +function challenge(method: string, request: Record): MppChallenge { + return { + id: 'ch_1', + realm: 'api.example.com', + method, + intent: 'charge', + request: base64url(JSON.stringify(request)), + expires: FUTURE, + }; +} + +// Solana native-SOL example, spec §Native SOL Example. `network` lives under +// methodDetails and is the field the spec obliges a client to compare against +// its configured cluster. +const solana = (methodDetails: Record) => + challenge('solana', { + amount: '10000000', + currency: 'sol', + recipient: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + description: 'Weather API access', + methodDetails, + }); + +// Lightning example, spec §Decoded request. NOTE: the spec's own example omits +// `recipient` (OPTIONAL for lightning — "the invoice payee is implied by the +// BOLT11 invoice"), and `fromMppChargeChallenge` currently rejects that shape. +// That is a separate defect from network resolution; these fixtures supply a +// recipient so this file tests exactly one axis. +const lightning = (methodDetails: Record) => + challenge('lightning', { + amount: '100', + currency: 'sat', + recipient: '03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90', + description: 'Weather report for 94107', + methodDetails: { invoice: 'lnbc1u1p...', ...methodDetails }, + }); + +const stellar = (methodDetails: Record) => + challenge('stellar', { + amount: '10000000', + currency: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4W', + recipient: 'GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKU', + methodDetails, + }); + +const tempo = (methodDetails: Record) => + challenge('tempo', { + amount: '1000000', + currency: '0x20c0000000000000000000000000000000000000', + recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00', + methodDetails, + }); + +const evm = (methodDetails: Record) => + challenge('evm', { + amount: '1000', + currency: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00', + methodDetails, + }); + +// ══════════════════════════════════════════════════════════════ +// Finding 1 — the whole of it, in one assertion +// ══════════════════════════════════════════════════════════════ + +describe('Solana cluster discriminator', () => { + it('does not collapse devnet and mainnet into the same network', () => { + // The spec: "Clients MUST reject challenges whose network does not match + // their configured cluster." A comparison is impossible while both sides + // stringify identically. + expect(fromMppChargeChallenge(solana({ network: 'devnet' })).network) + .not.toBe(fromMppChargeChallenge(solana({ network: 'mainnet' })).network); + }); + + it('resolves each spec-enumerated cluster', () => { + expect(fromMppChargeChallenge(solana({ network: 'mainnet' })).network).toBe('solana:mainnet'); + expect(fromMppChargeChallenge(solana({ network: 'devnet' })).network).toBe('solana:devnet'); + expect(fromMppChargeChallenge(solana({ network: 'localnet' })).network).toBe('solana:localnet'); + }); + + it('applies the spec default "mainnet" when network is omitted', () => { + // `network` is OPTIONAL and defaults to mainnet — omission is the common + // case, so a default that is not applied is the reachable bug. + expect(fromMppChargeChallenge(solana({ decimals: 6 })).network).toBe('solana:mainnet'); + expect(fromMppChargeChallenge(solana({})).network).toBe('solana:mainnet'); + }); + + it('rejects a cluster outside the spec enumeration rather than relabelling it', () => { + expect(() => fromMppChargeChallenge(solana({ network: 'testnet' }))).toThrow(s402Error); + expect(() => fromMppChargeChallenge(solana({ network: 'testnet' }))).toThrow(/solana/i); + expect(() => fromMppChargeChallenge(solana({ network: 42 }))).toThrow(s402Error); + }); +}); + +// ══════════════════════════════════════════════════════════════ +// Finding 1's twin — same class, milder consequence, hidden by the ranking +// ══════════════════════════════════════════════════════════════ + +describe('Lightning network discriminator', () => { + it('does not collapse regtest and mainnet into the same network', () => { + expect(fromMppChargeChallenge(lightning({ network: 'regtest' })).network) + .not.toBe(fromMppChargeChallenge(lightning({ network: 'mainnet' })).network); + }); + + it('resolves each spec-enumerated network', () => { + expect(fromMppChargeChallenge(lightning({ network: 'mainnet' })).network).toBe('lightning:mainnet'); + expect(fromMppChargeChallenge(lightning({ network: 'regtest' })).network).toBe('lightning:regtest'); + expect(fromMppChargeChallenge(lightning({ network: 'signet' })).network).toBe('lightning:signet'); + }); + + it('applies the spec default "mainnet" when network is omitted', () => { + expect(fromMppChargeChallenge(lightning({})).network).toBe('lightning:mainnet'); + }); + + it('rejects a network outside the spec enumeration (Lightning has no "devnet")', () => { + expect(() => fromMppChargeChallenge(lightning({ network: 'devnet' }))).toThrow(s402Error); + }); +}); + +// ══════════════════════════════════════════════════════════════ +// Methods whose discriminator was already CAIP-2 or already defaulted +// ══════════════════════════════════════════════════════════════ + +describe('Stellar network discriminator', () => { + it('passes the CAIP-2 identifier through unchanged', () => { + // methodDetails.network is REQUIRED for stellar and is *already* a CAIP-2 + // identifier — the value the resolver needs was on the wire the whole time. + expect(fromMppChargeChallenge(stellar({ network: 'stellar:testnet' })).network) + .toBe('stellar:testnet'); + expect(fromMppChargeChallenge(stellar({ network: 'stellar:pubnet', feePayer: true })).network) + .toBe('stellar:pubnet'); + }); + + it('rejects a challenge missing the REQUIRED network field', () => { + expect(() => fromMppChargeChallenge(stellar({ feePayer: true }))).toThrow(s402Error); + }); +}); + +describe('Tempo chain-id default', () => { + it('applies the spec default 42431 when chainId is omitted', () => { + expect(fromMppChargeChallenge(tempo({ feePayer: true })).network).toBe('tempo:42431'); + }); + + it('still honours an explicit chainId', () => { + expect(fromMppChargeChallenge(tempo({ chainId: 4217 })).network).toBe('tempo:4217'); + expect(fromMppChargeChallenge(tempo({ chainId: '4217' })).network).toBe('tempo:4217'); + }); + + it('rejects a malformed chainId rather than falling back to the default', () => { + // Falling back would mean a challenge naming an unparseable chain silently + // becomes a mainnet payment. + expect(() => fromMppChargeChallenge(tempo({ chainId: 'mainnet' }))).toThrow(s402Error); + expect(() => fromMppChargeChallenge(tempo({ chainId: -1 }))).toThrow(s402Error); + }); +}); + +describe('EVM chain-id', () => { + it('resolves via eip155:{chainId}', () => { + expect(fromMppChargeChallenge(evm({ chainId: 8453 })).network).toBe('eip155:8453'); + }); + + it('rejects a challenge missing the REQUIRED chainId', () => { + // evm's chainId is REQUIRED and the spec obliges clients to reject chains + // they do not support — a requirement carrying an unnamed chain cannot be + // checked against anything. + expect(() => fromMppChargeChallenge(evm({}))).toThrow(s402Error); + }); +}); + +// ══════════════════════════════════════════════════════════════ +// The set is closed — `${method}:unknown` has no producer left +// ══════════════════════════════════════════════════════════════ + +describe('no challenge lifts with an unresolved network', () => { + const everyMappableMethod: Array<[string, MppChallenge]> = [ + ['solana', solana({})], + ['lightning', lightning({})], + ['stellar', stellar({ network: 'stellar:pubnet' })], + ['tempo', tempo({})], + ['evm', evm({ chainId: 1 })], + ]; + + it.each(everyMappableMethod)('%s resolves to a named network', (_method, ch) => { + expect(fromMppChargeChallenge(ch).network).not.toMatch(/:unknown$/); + }); + + it('never emits ":unknown" for a challenge it accepts', () => { + // Criterion 5 is satisfied by construction rather than by a guard: every + // member of the mappable set now resolves or throws, so the sentinel has + // no producer. An unreachable branch cannot regress a caller; a reachable + // one turned into an error can. + for (const [, ch] of everyMappableMethod) { + expect(fromMppChargeChallenge(ch).network).toMatch(/^[a-z0-9]+:[a-zA-Z0-9-]+$/); + } + }); +}); + +// ══════════════════════════════════════════════════════════════ +// Rejection messages name the actual reason +// ══════════════════════════════════════════════════════════════ + +describe('unmapped method rejection', () => { + it('does not describe blockchain methods as processor-based', () => { + // hedera, usdc and nearintents are specified blockchain Charge methods with + // real payTo fields. The old catch-all told the reader they "have no + // payTo/asset exposed", sending them to look for a problem that isn't there. + for (const method of ['hedera', 'usdc', 'nearintents']) { + const ch = challenge(method, { + amount: '1000', + currency: 'usdc', + recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00', + methodDetails: {}, + }); + expect(() => fromMppChargeChallenge(ch)).toThrow(/blockchain Charge method/); + expect(() => fromMppChargeChallenge(ch)).not.toThrow(/processor-based/); + } + }); + + it('still names processor routing for genuine processor methods', () => { + const ch = challenge('stripe', { + amount: '5000', + currency: 'usd', + methodDetails: { networkId: 'profile_123' }, + }); + expect(() => fromMppChargeChallenge(ch)).toThrow(/processor-based/); + }); +}); + +// ══════════════════════════════════════════════════════════════ +// The MUST-reject clause, enforced in the translator +// ══════════════════════════════════════════════════════════════ + +describe('expectedNetwork enforcement', () => { + it('accepts a challenge whose network matches the configured one', () => { + const req = fromMppChargeChallenge(solana({ network: 'devnet' }), { + expectedNetwork: 'solana:devnet', + }); + expect(req.network).toBe('solana:devnet'); + }); + + it('rejects a challenge whose cluster does not match the configured one', () => { + // The spec's MUST, enforced where the lift happens rather than deferred to + // every downstream caller. + expect(() => + fromMppChargeChallenge(solana({ network: 'devnet' }), { expectedNetwork: 'solana:mainnet' }), + ).toThrow(/solana:mainnet/); + }); + + it('catches the exact confusion finding 1 describes', () => { + // A devnet challenge presented to a mainnet-configured client. Before this + // change both sides read "solana:unknown" and the comparison passed. + expect(() => + fromMppChargeChallenge(solana({}), { expectedNetwork: 'solana:devnet' }), + ).toThrow(s402Error); + }); + + it('remains opt-in — no expectedNetwork means no comparison', () => { + expect(fromMppChargeChallenge(solana({ network: 'devnet' })).network).toBe('solana:devnet'); + }); + + it('still accepts a bare number as the legacy `now` argument', () => { + // Back-compat: the second parameter was `now?: number` before this change. + const now = Date.parse('2099-01-01T00:00:00Z'); + expect(fromMppChargeChallenge(solana({}), now).expiresAt).toBeGreaterThan(now); + }); + + it('accepts now inside the options object', () => { + const now = Date.parse('2099-01-01T00:00:00Z'); + expect(fromMppChargeChallenge(solana({}), { now }).expiresAt).toBeGreaterThan(now); + }); + + it('applies expiry and network checks together', () => { + const expired = { ...solana({ network: 'devnet' }), expires: '2020-01-01T00:00:00Z' }; + expect(() => fromMppChargeChallenge(expired, { expectedNetwork: 'solana:devnet' })) + .toThrow(/expired/); + }); +});