diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index a27b7075..5c086175 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1566,6 +1566,77 @@ describe('cross-route credential replay via scope binding flaw', () => { // The result should be 200 (matched to cheap), not routed to expensive. expect(result.status).toBe(200) }) + + test('rejects no-splits credential replayed at splits route', async () => { + // Method whose schema transform moves splits into methodDetails. + const splitsMethod = Method.from({ + name: 'mock', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.pipe( + z.object({ + amount: z.string(), + currency: z.string(), + decimals: z.number(), + recipient: z.string(), + splits: z.optional(z.array(z.object({ amount: z.string(), recipient: z.string() }))), + }), + z.transform(({ amount, currency, decimals, recipient, splits }) => ({ + methodDetails: { + amount: String(Number(amount) * 10 ** decimals), + currency, + recipient, + ...(splits && { splits }), + }, + })), + ), + }, + }) + + const splitsServerMethod = Method.toServer(splitsMethod, { + async verify() { + return mockReceipt() + }, + }) + + const handler = Mppx.create({ methods: [splitsServerMethod], realm, secretKey }) + + // Get a challenge from a route with no splits + const noSplitsHandle = handler.charge({ + amount: '1', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + expires: new Date(Date.now() + 60_000).toISOString(), + recipient: '0x0000000000000000000000000000000000000002', + }) + const noSplitsResult = await noSplitsHandle(new Request('https://example.com/no-splits')) + expect(noSplitsResult.status).toBe(402) + if (noSplitsResult.status !== 402) throw new Error() + + const noSplitsChallenge = Challenge.fromResponse(noSplitsResult.challenge) + const credential = Credential.from({ + challenge: noSplitsChallenge, + payload: { token: 'valid' }, + }) + + // Present at a route that requires splits + const splitsHandle = handler.charge({ + amount: '1', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + expires: new Date(Date.now() + 60_000).toISOString(), + recipient: '0x0000000000000000000000000000000000000002', + splits: [{ amount: '0.2', recipient: '0x0000000000000000000000000000000000000003' }], + }) + const result = await splitsHandle( + new Request('https://example.com/with-splits', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(result.status).toBe(402) + }) }) describe('withReceipt', () => { diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 9884b3c4..869bb931 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -389,6 +389,29 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R return { challenge: response, status: 402 } } } + + // Compare payment-relevant methodDetails fields (memo, splits). + // These are excluded from the top-level field check above but + // affect verification semantics — a credential issued for a + // no-splits route must not be accepted on a splits route. + for (const field of ['memo', 'splits'] as const) { + const routeVal = routeDetails[field] + const echoedVal = echoedDetails[field] + if ( + routeVal !== undefined && + JSON.stringify(routeVal) !== JSON.stringify(echoedVal) + ) { + const response = await transport.respondChallenge({ + challenge, + input, + error: new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: `credential ${field} does not match this route's requirements`, + }), + }) + return { challenge: response, status: 402 } + } + } } }