Skip to content
Merged
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
66 changes: 64 additions & 2 deletions __tests__/unit/api/v1-pay-l402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,16 @@ jest.mock('@/lib/supabase/public', () => ({ createPublicClient: () => ({ _kind:
jest.mock('@/lib/rate-limit', () => ({
rateLimitWriteAsync: jest.fn().mockResolvedValue({ success: true }),
rateLimitPaymentRecipient: jest.fn().mockResolvedValue({ success: true }),
rateLimitL402Verify: jest.fn().mockResolvedValue({ success: true }),
retryAfterSeconds: () => 1,
}));

import { GET } from '@/app/api/v1/pay/[entity_type]/[entity_id]/route';
import { rateLimitPaymentRecipient } from '@/lib/rate-limit';
import {
rateLimitL402Verify,
rateLimitPaymentRecipient,
rateLimitWriteAsync,
} from '@/lib/rate-limit';

const ENTITY_ID = '11111111-2222-3333-4444-555555555555';
const params = Promise.resolve({ entity_type: 'product', entity_id: ENTITY_ID });
Expand Down Expand Up @@ -246,7 +251,64 @@ describe('per-recipient invoice-spam limit', () => {

it('refuses with 429 when that recipient is already being hammered', async () => {
(rateLimitPaymentRecipient as jest.Mock).mockResolvedValue({ success: false });
const res = await GET(makeRequest('https://x.test/api/v1/pay/product/' + ENTITY_ID), { params });
const res = await GET(makeRequest('https://x.test/api/v1/pay/product/' + ENTITY_ID), {
params,
});
expect(res.status).toBe(429);
});
});

/**
* The verify branch deliberately sits OUTSIDE the per-IP challenge budget: a
* payer who has genuinely paid must be able to retry until settlement is seen,
* and making them share the challenge budget would lock them out of their own
* purchase.
*
* But unbounded is not the same as unbudgeted. Every check on a non-terminal
* intent drives an outbound call to the recipient's LNURL/NWC relay or to
* mempool, so a single valid token bought unlimited traffic aimed at someone
* else's infrastructure — and the per-IP limiter could not see it, by design.
*
* bitbaum/orangecat#563 finding 7.
*/
describe('L402 verify-retry limit', () => {
const AUTH = { authorization: `L402 ${ENTITY_ID}.tok_abc:${'a'.repeat(64)}` };

beforeEach(() => {
(rateLimitL402Verify as jest.Mock).mockResolvedValue({ success: true });
(rateLimitWriteAsync as jest.Mock).mockResolvedValue({ success: true });
mockVerify.mockResolvedValue({ ok: false, status: 'pending' });
});

it('keys the budget on the payment intent, which a caller cannot rotate', async () => {
await GET(makeRequest('https://x.test/api/v1/pay/product/' + ENTITY_ID, AUTH), { params });
expect(rateLimitL402Verify).toHaveBeenCalledWith(ENTITY_ID);
});

it('refuses with 429 once one token has hammered the relay', async () => {
(rateLimitL402Verify as jest.Mock).mockResolvedValue({ success: false });
const res = await GET(makeRequest('https://x.test/api/v1/pay/product/' + ENTITY_ID, AUTH), {
params,
});
expect(res.status).toBe(429);
// Refused BEFORE the outbound check — the whole point is not making it.
expect(mockVerify).not.toHaveBeenCalled();
});

it('still does not spend the challenge budget on a verify', async () => {
await GET(makeRequest('https://x.test/api/v1/pay/product/' + ENTITY_ID, AUTH), { params });
expect(rateLimitWriteAsync).not.toHaveBeenCalled();
expect(rateLimitPaymentRecipient).not.toHaveBeenCalled();
});

it('leaves the challenge branch on the per-IP budget, not the verify one', async () => {
mockCreateChallenge.mockResolvedValue({
header: 'L402 token="i.t", invoice="lnbc1"',
payment_intent_id: 'i',
status_token: 't',
});
await GET(makeRequest('https://x.test/api/v1/pay/product/' + ENTITY_ID), { params });
expect(rateLimitL402Verify).not.toHaveBeenCalled();
expect(rateLimitWriteAsync).toHaveBeenCalled();
});
});
130 changes: 8 additions & 122 deletions src/app/api/v1/pay/[entity_type]/[entity_id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,5 @@
import {
apiBadRequest,
apiInternalError,
apiNotFound,
apiPaymentRequired,
apiRateLimited,
apiSuccess,
} from '@/lib/api/standardResponse';
import { createL402Challenge, verifyL402Payment } from '@/domain/payments/l402';
import { parseL402Authorization } from '@/domain/payments/l402-codec';
import { publicSupportCreateSchema } from '@/lib/validation/finance';
import { createPublicClient } from '@/lib/supabase/public';
import { rateLimitPaymentRecipient, rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit';
import { logger } from '@/utils/logger';
import { clientIpKey } from '@/lib/client-ip';
import { handleL402Challenge, handleL402Verify } from '@/lib/api/l402-handlers';

/**
* GET /api/v1/pay/{entity_type}/{entity_id}?amount_btc=X — HTTP 402 inline payment.
Expand All @@ -29,120 +16,19 @@ import { clientIpKey } from '@/lib/client-ip';
* The JSON-contract flow (POST /api/v1/payments + polling) remains the
* account-based sibling; this one exists so foreign agents that only speak
* 402 can pay without learning our dialect.
*
* Both branches live in lib/api/l402-handlers — they carry different rate
* budgets and different failure surfaces, and neither is the route's business.
*/

function requestKey(request: Request): string {
return `l402:${clientIpKey(request)}`;
}

export async function GET(
request: Request,
{ params }: { params: Promise<{ entity_type: string; entity_id: string }> }
) {
const { entity_type, entity_id } = await params;
const target = { entityType: entity_type, entityId: entity_id };

// Retry-with-proof branch first: verification is cheap and shouldn't share
// the challenge-creation rate budget.
// Retry-with-proof first: a caller holding credentials is answering a
// challenge we already issued, not asking for a new one.
const creds = parseL402Authorization(request.headers.get('authorization'));
if (creds) {
try {
const result = await verifyL402Payment(creds, {
entityType: entity_type,
entityId: entity_id,
});
if (result.ok) {
return apiSuccess(
{
status: result.status,
paid_at: result.paid_at,
verified_by: result.verified_by,
entity_type,
entity_id,
},
{ cache: 'NONE' }
);
}
// Not paid yet — 402 again. The client already holds the invoice from
// the original challenge, so no new intent is created here.
return apiPaymentRequired('Invoice not paid yet — pay it, then retry with the preimage.', {
status: result.status,
});
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (message.includes('not found')) {
return apiNotFound('Payment not found');
}
logger.error('L402 verification failed', { error });
return apiInternalError('Could not verify the payment.');
}
}

// Challenge branch: create an account-less intent and answer 402.
const limit = await rateLimitWriteAsync(requestKey(request));
if (!limit.success) {
return apiRateLimited(
'Too many payment requests. Try again shortly.',
retryAfterSeconds(limit)
);
}

// Per-IP is not enough here. Every challenge mints a REAL invoice through the
// recipient's own LNURL/NWC relay, so an attacker rotating IPs can still get
// a seller rate-limited or banned by their wallet provider. This bounds the
// damage to one recipient however many addresses it arrives from.
const recipientLimit = await rateLimitPaymentRecipient(entity_type, entity_id);
if (!recipientLimit.success) {
return apiRateLimited(
'This page is receiving too many payment requests right now. Try again shortly.',
retryAfterSeconds(recipientLimit)
);
}

const url = new URL(request.url);
const parsed = publicSupportCreateSchema.safeParse({
entity_type,
entity_id,
amount_btc: Number(url.searchParams.get('amount_btc')),
});
if (!parsed.success) {
return apiBadRequest(
'Invalid payment request — pass ?amount_btc= between 0.000001 and 1.',
parsed.error.errors
);
}

try {
const challenge = await createL402Challenge(createPublicClient(), parsed.data);
return apiPaymentRequired(
'Pay the invoice, then retry with Authorization: L402 <token>:<preimage>.',
{
payment_intent_id: challenge.payment_intent_id,
token: `${challenge.payment_intent_id}.${challenge.status_token}`,
bolt11: challenge.bolt11,
onchain_address: challenge.onchain_address,
amount_btc: challenge.amount_btc,
method_label: challenge.method_label,
expires_in_seconds: challenge.expires_in_seconds,
},
challenge.header
);
} catch (error) {
const message = error instanceof Error ? error.message : 'Payment initiation failed';
logger.error('L402 challenge creation failed', { error, entity_type, entity_id });
// Same safe-error surface as the public support route — never leak internals.
const safeErrors: Array<[string, string]> = [
['not publicly available', 'This entity is not available for public payment.'],
['cannot receive', 'This kind of entity cannot receive payments.'],
['owner not found', 'This entity no longer has a receiving owner.'],
['no wallet', 'The owner has not connected a Bitcoin wallet yet.'],
['outside allowed range', 'That amount is outside the wallet provider’s allowed range.'],
['LNURL', 'The Lightning Address could not create an invoice. Try again later.'],
];
for (const [pattern, safe] of safeErrors) {
if (message.toLowerCase().includes(pattern.toLowerCase())) {
return apiBadRequest(safe);
}
}
return apiInternalError('Could not create the payment challenge.');
}
return creds ? handleL402Verify(creds, target) : handleL402Challenge(request, target);
}
158 changes: 158 additions & 0 deletions src/lib/api/l402-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {
apiBadRequest,
apiInternalError,
apiNotFound,
apiPaymentRequired,
apiRateLimited,
apiSuccess,
} from '@/lib/api/standardResponse';
import { createL402Challenge, verifyL402Payment } from '@/domain/payments/l402';
import type { L402Credentials } from '@/domain/payments/l402-codec';
import { publicSupportCreateSchema } from '@/lib/validation/finance';
import { createPublicClient } from '@/lib/supabase/public';
import {
rateLimitL402Verify,
rateLimitPaymentRecipient,
rateLimitWriteAsync,
retryAfterSeconds,
} from '@/lib/rate-limit';
import { logger } from '@/utils/logger';
import { clientIpKey } from '@/lib/client-ip';

/**
* The two branches of GET /api/v1/pay/{type}/{id}, kept out of the route file
* so the route stays what it should be: parse, pick a branch, delegate.
*/

interface PayTarget {
entityType: string;
entityId: string;
}

/**
* Retry-with-proof. Deliberately OFF the challenge budget — a payer who has
* paid must be able to retry until settlement is seen, and sharing the
* challenge budget would lock them out of their own purchase.
*
* It gets a budget of its own instead. Each check on a non-terminal intent
* drives an outbound call to the recipient's relay or to mempool, so one valid
* token would otherwise buy unbounded traffic against someone else's
* infrastructure. Keyed on the INTENT, which a caller cannot vary without a
* valid status token for some other payment — so unlike the per-IP budget on
* the challenge branch, rotating addresses buys nothing here.
*/
export async function handleL402Verify(
creds: L402Credentials,
{ entityType, entityId }: PayTarget
): Promise<Response> {
const verifyLimit = await rateLimitL402Verify(creds.paymentIntentId);
if (!verifyLimit.success) {
return apiRateLimited(
'Too many verification checks for this payment. Try again shortly.',
retryAfterSeconds(verifyLimit)
);
}

try {
const result = await verifyL402Payment(creds, { entityType, entityId });
if (result.ok) {
return apiSuccess(
{
status: result.status,
paid_at: result.paid_at,
verified_by: result.verified_by,
entity_type: entityType,
entity_id: entityId,
},
{ cache: 'NONE' }
);
}
// Not paid yet — 402 again. The client already holds the invoice from the
// original challenge, so no new intent is created here.
return apiPaymentRequired('Invoice not paid yet — pay it, then retry with the preimage.', {
status: result.status,
});
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (message.includes('not found')) {
return apiNotFound('Payment not found');
}
logger.error('L402 verification failed', { error });
return apiInternalError('Could not verify the payment.');
}
}

/** Never leak internals — same safe-error surface as the public support route. */
const SAFE_CHALLENGE_ERRORS: Array<[string, string]> = [
['not publicly available', 'This entity is not available for public payment.'],
['cannot receive', 'This kind of entity cannot receive payments.'],
['owner not found', 'This entity no longer has a receiving owner.'],
['no wallet', 'The owner has not connected a Bitcoin wallet yet.'],
['outside allowed range', 'That amount is outside the wallet provider’s allowed range.'],
['LNURL', 'The Lightning Address could not create an invoice. Try again later.'],
];

/** Create an account-less intent and answer 402 with the invoice. */
export async function handleL402Challenge(
request: Request,
{ entityType, entityId }: PayTarget
): Promise<Response> {
const limit = await rateLimitWriteAsync(`l402:${clientIpKey(request)}`);
if (!limit.success) {
return apiRateLimited(
'Too many payment requests. Try again shortly.',
retryAfterSeconds(limit)
);
}

// Per-IP is not enough here. Every challenge mints a REAL invoice through the
// recipient's own LNURL/NWC relay, so an attacker rotating IPs can still get a
// seller rate-limited or banned by their wallet provider. This bounds the
// damage to one recipient however many addresses it arrives from.
const recipientLimit = await rateLimitPaymentRecipient(entityType, entityId);
if (!recipientLimit.success) {
return apiRateLimited(
'This page is receiving too many payment requests right now. Try again shortly.',
retryAfterSeconds(recipientLimit)
);
}

const url = new URL(request.url);
const parsed = publicSupportCreateSchema.safeParse({
entity_type: entityType,
entity_id: entityId,
amount_btc: Number(url.searchParams.get('amount_btc')),
});
if (!parsed.success) {
return apiBadRequest(
'Invalid payment request — pass ?amount_btc= between 0.000001 and 1.',
parsed.error.errors
);
}

try {
const challenge = await createL402Challenge(createPublicClient(), parsed.data);
return apiPaymentRequired(
'Pay the invoice, then retry with Authorization: L402 <token>:<preimage>.',
{
payment_intent_id: challenge.payment_intent_id,
token: `${challenge.payment_intent_id}.${challenge.status_token}`,
bolt11: challenge.bolt11,
onchain_address: challenge.onchain_address,
amount_btc: challenge.amount_btc,
method_label: challenge.method_label,
expires_in_seconds: challenge.expires_in_seconds,
},
challenge.header
);
} catch (error) {
const message = error instanceof Error ? error.message : 'Payment initiation failed';
logger.error('L402 challenge creation failed', { error, entityType, entityId });
for (const [pattern, safe] of SAFE_CHALLENGE_ERRORS) {
if (message.toLowerCase().includes(pattern.toLowerCase())) {
return apiBadRequest(safe);
}
}
return apiInternalError('Could not create the payment challenge.');
}
}
Loading
Loading