From ff2feccdfb18c2991fa2b8682e6c2821a3460cb9 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:44:33 +0200 Subject: [PATCH] fix(payments): budget claims per recipient, stop caching the 402 credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the tail of the #563 audit. FINDING 3 — an acknowledge flips an intent to buyer_confirmed and fires a "someone says they paid you" card into the seller's queue. One card is a prompt to check a wallet; a hundred is a denial-of-attention attack whose payoff is ship-the-goods-for-no-money. The budget in front of it was keyed on the caller, and the abuse shape is many addresses aimed at ONE seller. rateLimitPaymentClaim bounds it at 10/hour per recipient, asked once on the real transition — the idempotent re-claim paths return before it, cost an attacker nothing, and so must not spend a genuine payer's allowance either. The guard is INJECTED, not imported. Importing the limiter into the payments domain dragged Upstash's ESM build into eight domain test suites that have no business knowing about Redis — the layering violation announced itself. The HTTP layer owns the policy; the domain owns when it is asked. SUGGESTION 15 — the 402 body embeds `token` and WWW-Authenticate repeats it. That token is what a payer later exchanges for a receipt, so anything caching the response cached a credential for someone else's payment. Every other payment response set no-store; this one did not. SUGGESTION 14 — refreshPaymentStatus, reconcilePaymentIntent and resolveSellerWallet each took a supabase client they had stopped using; all three call getAdminClient() internally, so the signature advertised caller-scoped RLS behaviour that no longer existed. Removing it from resolveSellerWallet made resolveSellerReceiveInfo's client dead the same way, so that goes too. buyerConfirmPayment genuinely uses its client and is untouched — which is why the audit did not list it. SUGGESTION 17 — the semantic fallback and its 0.45 floor had no coverage at all: every forget test disables embeddings, so a silent revert to the old 0.75 would have changed which memories "forget that" reaches with nothing failing. Added a stem-sharing-innocents corpus too, since INCIDENT_CORPUS holds only targets and obvious strangers — a shape that flatters the matcher. One of those tests documents rather than changes behaviour: a single generic word is treated as a TOPIC, so "skills" clears every memory phrased with it. That is the deliberate containment rule (memory-forget.test.ts:138), not an over-match, and #838 makes it visible by naming each deleted memory. Refs bitbaum/orangecat#563 finding 3, suggestions 14, 15, 17. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018waGt1ieA9TjpscqrbrnGb --- .../api/standardResponse.contract.test.ts | 26 +++++ .../cat/forget-generic-stem-innocents.test.ts | 94 +++++++++++++++++++ .../unit/cat/forget-semantic-floor.test.ts | 87 +++++++++++++++++ .../payments/publicPaymentStatus.test.ts | 76 ++++++++++++--- .../payments/resolveSellerWallet.test.ts | 8 +- .../sellerIdentityAndReceiveInfo.test.ts | 10 +- .../payments/settlementRailDispatch.test.ts | 48 ++++++---- .../undetectableIntentLifecycle.test.ts | 12 +-- .../payments/walletResolutionQueries.test.ts | 20 ++-- .../api/bookings/[id]/receive-info/route.ts | 1 - src/app/api/payments/receive-info/route.ts | 2 +- src/app/api/v1/payments/public/[id]/route.ts | 37 ++++---- src/app/projects/[id]/page.tsx | 2 +- .../public/PublicEntityDetailPage.tsx | 2 +- src/domain/payments/paymentInitiation.ts | 4 +- src/domain/payments/paymentStatusFlow.ts | 44 ++++++--- .../payments/walletResolutionService.ts | 4 +- src/domain/wallets/walletUsage.ts | 2 +- src/lib/api/standardResponse.ts | 5 + src/lib/rate-limit.ts | 36 +++++++ src/services/cat/handlers/payments.ts | 6 +- src/services/payments/reconcile.ts | 2 +- 22 files changed, 425 insertions(+), 103 deletions(-) create mode 100644 __tests__/unit/cat/forget-generic-stem-innocents.test.ts create mode 100644 __tests__/unit/cat/forget-semantic-floor.test.ts diff --git a/__tests__/unit/api/standardResponse.contract.test.ts b/__tests__/unit/api/standardResponse.contract.test.ts index f96efb225..10ebb9b27 100644 --- a/__tests__/unit/api/standardResponse.contract.test.ts +++ b/__tests__/unit/api/standardResponse.contract.test.ts @@ -37,6 +37,7 @@ jest.mock('@/utils/logger', () => ({ })); import { + apiPaymentRequired, apiSuccess, apiError, apiBadRequest, @@ -222,3 +223,28 @@ describe('apiRateLimited — Retry-After header', () => { expect(headers.get('Retry-After')).toBeNull(); }); }); + +/** + * A 402 carries a bearer credential, so it must never be cached. + * + * The body embeds `token` (`.`) and WWW-Authenticate + * repeats it — that token is what a payer later exchanges for a receipt. So + * anything caching this response caches a credential for someone else's + * payment. Every other payment response sets no-store; this one did not. + * bitbaum/orangecat#563 suggestion 15. + */ +describe('apiPaymentRequired — never cached', () => { + const headersOf = (r: unknown) => (r as { headers: Headers }).headers; + + it('sets no-store', () => { + expect( + headersOf(apiPaymentRequired('Pay up', { token: 'pi-1.tok' })).get('Cache-Control') + ).toBe('no-store, must-revalidate'); + }); + + it('sets it on the challenge form, where the header repeats the token', () => { + const res = apiPaymentRequired('Pay up', { token: 'pi-1.tok' }, 'L402 token="pi-1.tok"'); + expect(headersOf(res).get('Cache-Control')).toBe('no-store, must-revalidate'); + expect(headersOf(res).get('WWW-Authenticate')).toContain('L402'); + }); +}); diff --git a/__tests__/unit/cat/forget-generic-stem-innocents.test.ts b/__tests__/unit/cat/forget-generic-stem-innocents.test.ts new file mode 100644 index 000000000..e861eae0b --- /dev/null +++ b/__tests__/unit/cat/forget-generic-stem-innocents.test.ts @@ -0,0 +1,94 @@ +/** + * Innocent memories that merely SHARE A STEM with the target must survive. + * + * INCIDENT_CORPUS was built from one real over-deletion, so every row in it is + * either a target or obviously unrelated. That shape flatters the matcher: the + * dangerous case is a memory that shares a common stem with the forget phrase + * and is nonetheless a different fact — "cooking skills" beside "photography + * skills", "documentary work" beside "weekend work". + * + * bitbaum/orangecat#563 suggestion 17: the corpus lacked these, so nothing + * proved the word-boundary fix (#831 / finding 9) held against them. + */ + +import { forgetMemoriesMatching } from '@/services/cat/memory'; +import type { AnySupabaseClient } from '@/lib/supabase/types'; + +jest.mock('@/utils/logger', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/services/ai/embeddings', () => ({ + embeddingsEnabled: () => false, + embedText: jest.fn(), + embedTexts: jest.fn(), +})); + +interface Row { + id: string; + content: string; +} + +function clientFor(corpus: Row[]) { + return { + from: () => ({ + select: () => ({ eq: async () => ({ data: corpus, error: null }) }), + delete: () => ({ eq: () => ({ in: async () => ({ error: null }) }) }), + insert: async () => ({ error: null }), + upsert: async () => ({ error: null }), + }), + rpc: async () => ({ data: [], error: null }), + } as unknown as AnySupabaseClient; +} + +/** Targets plus innocents that share a stem with each target. */ +const CORPUS: Row[] = [ + { id: 'a1', content: 'Has photography skills from years of freelance work' }, + { id: 'a2', content: 'Has strong cooking skills' }, + { id: 'a3', content: 'Can only work on weekends.' }, + { id: 'a4', content: 'Has a documentary photography background' }, + { id: 'a5', content: 'Prefers Lightning over on-chain payments' }, + { id: 'a6', content: 'Owns a drone.' }, +]; + +const forget = (facts: string[]) => forgetMemoriesMatching(clientFor(CORPUS), 'u1', facts); + +describe('forget leaves stem-sharing innocents alone', () => { + it('"cooking skills" does not take the photography skills with it', async () => { + const result = await forget(['cooking skills']); + expect(result.deleted).toEqual(['Has strong cooking skills']); + }); + + it('"photography skills" does not take the cooking skills with it', async () => { + const result = await forget(['photography skills']); + expect(result.deleted).not.toContain('Has strong cooking skills'); + // Both photography rows are legitimately about photography. + for (const kept of result.deleted) { + expect(kept.toLowerCase()).toContain('photograph'); + } + }); + + /** + * Single-word containment is DELIBERATE — memory-forget.test.ts:138 pins it + * as how a user removes a whole topic ("forget photography" clears every + * photography memory). The consequence, pinned here so it is a decision and + * not a surprise: a generic word like "skills" is treated as a topic too, so + * it clears every memory phrased with it, across unrelated subjects. + * + * That is the accepted cost of topic removal, and #838 makes it visible — + * the reply names each memory it deleted, so an over-broad word is caught by + * the user reading the receipt rather than discovered later. + */ + it('treats a generic word as a topic, clearing every memory phrased with it', async () => { + const result = await forget(['skills']); + expect(result.deleted).toEqual([ + 'Has photography skills from years of freelance work', + 'Has strong cooking skills', + ]); + }); + + it('"weekend work" does not reach "freelance work"', async () => { + const result = await forget(['weekend work']); + expect(result.deleted).not.toContain('Has photography skills from years of freelance work'); + }); +}); diff --git a/__tests__/unit/cat/forget-semantic-floor.test.ts b/__tests__/unit/cat/forget-semantic-floor.test.ts new file mode 100644 index 000000000..f47d46a06 --- /dev/null +++ b/__tests__/unit/cat/forget-semantic-floor.test.ts @@ -0,0 +1,87 @@ +/** + * The semantic fallback and its similarity floor, actually executed. + * + * Every other forget test disables embeddings, so the branch that runs when + * text containment finds NOTHING — and the 0.45 floor it passes to + * match_cat_memories — was never exercised by any test. The floor was moved + * from 0.75 to 0.45 on measured production data; a silent revert (or a typo) + * would have changed which memories a user's "forget that" reaches, and no + * test would have noticed. + * + * bitbaum/orangecat#563 suggestion 17. + */ + +import { forgetMemoriesMatching } from '@/services/cat/memory'; +import type { AnySupabaseClient } from '@/lib/supabase/types'; + +jest.mock('@/utils/logger', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const mockEmbedText = jest.fn(); +jest.mock('@/services/ai/embeddings', () => ({ + embeddingsEnabled: () => true, + embedText: (...args: unknown[]) => mockEmbedText(...args), + embedTexts: jest.fn().mockResolvedValue([]), +})); + +/** Corpus deliberately shares NO word with the phrase, forcing the RPC path. */ +const STORED = { id: 's1', content: 'Knows French' }; + +function makeClient(rpc: jest.Mock) { + return { + from: () => ({ + select: () => ({ eq: async () => ({ data: [STORED], error: null }) }), + delete: () => ({ eq: () => ({ in: async () => ({ error: null }) }) }), + insert: async () => ({ error: null }), + upsert: async () => ({ error: null }), + }), + rpc, + } as unknown as AnySupabaseClient; +} + +describe('forget semantic fallback', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEmbedText.mockResolvedValue([0.1, 0.2, 0.3]); + }); + + it('passes the measured 0.45 floor to match_cat_memories', async () => { + const rpc = jest.fn().mockResolvedValue({ data: [], error: null }); + await forgetMemoriesMatching(makeClient(rpc), 'u1', ['speaking a second language']); + + expect(rpc).toHaveBeenCalledWith( + 'match_cat_memories', + expect.objectContaining({ p_user_id: 'u1', min_similarity: 0.45 }) + ); + }); + + it('deletes what the semantic match returns when no word matched', async () => { + const rpc = jest.fn().mockResolvedValue({ data: [STORED], error: null }); + const result = await forgetMemoriesMatching(makeClient(rpc), 'u1', [ + 'speaking a second language', + ]); + + expect(result.deleted).toEqual(['Knows French']); + expect(result.notFound).toEqual([]); + }); + + it('is a FALLBACK — a word match never reaches the RPC', async () => { + const rpc = jest.fn().mockResolvedValue({ data: [], error: null }); + await forgetMemoriesMatching(makeClient(rpc), 'u1', ['French']); + + expect(rpc).not.toHaveBeenCalled(); + }); + + it('reports no-match rather than failing when the RPC errors', async () => { + // A dead embedding path must degrade to "nothing matched", never to a + // half-truth about what was removed. + const rpc = jest.fn().mockRejectedValue(new Error('vector index offline')); + const result = await forgetMemoriesMatching(makeClient(rpc), 'u1', [ + 'speaking a second language', + ]); + + expect(result.deleted).toEqual([]); + expect(result.notFound).toEqual(['speaking a second language']); + }); +}); diff --git a/__tests__/unit/domain/payments/publicPaymentStatus.test.ts b/__tests__/unit/domain/payments/publicPaymentStatus.test.ts index ca5cb195a..8d1b603e1 100644 --- a/__tests__/unit/domain/payments/publicPaymentStatus.test.ts +++ b/__tests__/unit/domain/payments/publicPaymentStatus.test.ts @@ -138,17 +138,13 @@ describe('a claim is only accepted where no rail can answer', () => { paid_at: null, requires_recipient_confirmation: true, }); - expect(fake.updates.map(u => u.patch.status)).toEqual([ - STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED, - ]); + expect(fake.updates.map(u => u.patch.status)).toEqual([STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED]); }); it('refuses a claim when the Lightning address can be verified automatically', async () => { const fake = world(intent({ lnurl_verify_url: 'https://ln.example/verify/1' })); - await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow( - 'confirmed automatically' - ); + await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow('confirmed automatically'); expect(fake.updates).toEqual([]); expect(dispatchMock).not.toHaveBeenCalled(); }); @@ -156,18 +152,14 @@ describe('a claim is only accepted where no rail can answer', () => { it('refuses a claim on an on-chain payment — the mempool answers, not the payer', async () => { const fake = world(intent({ payment_method: 'onchain', onchain_address: 'bc1qexample' })); - await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow( - 'confirmed automatically' - ); + await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow('confirmed automatically'); expect(fake.updates).toEqual([]); }); it('refuses a claim on an NWC payment', async () => { const fake = world(intent({ payment_method: 'nwc', payment_hash: 'hash-1' })); - await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow( - 'confirmed automatically' - ); + await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow('confirmed automatically'); expect(fake.updates).toEqual([]); }); }); @@ -226,9 +218,7 @@ describe('expiry bounds when a payment can be MADE, not when it can be REPORTED' expect(result.status).toBe(STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED); expect(dispatchMock).toHaveBeenCalledTimes(1); - expect(fake.updates.map(u => u.patch.status)).toEqual([ - STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED, - ]); + expect(fake.updates.map(u => u.patch.status)).toEqual([STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED]); }); it('refuses a claim once the claim window has also closed, and records the expiry', async () => { @@ -255,3 +245,59 @@ describe('expiry bounds when a payment can be MADE, not when it can be REPORTED' expect(fake.updates).toEqual([]); }); }); + +/** + * A claim costs the recipient attention, so it is budgeted per recipient. + * + * The route in front of this only knows the caller's IP, and the abuse shape + * is many addresses aimed at ONE seller's confirmation queue — free intents + * fanned into plausible "someone paid you" cards, whose payoff is + * ship-the-goods-for-no-money. bitbaum/orangecat#563 finding 3. + */ +describe('claims are budgeted per recipient', () => { + const allow = jest.fn(async () => true); + const refuse = jest.fn(async () => false); + + beforeEach(() => { + allow.mockClear(); + refuse.mockClear(); + }); + + it('asks about the recipient entity, not the caller', async () => { + world(intent()); + await acknowledgePublicPayment(PI_ID, TOKEN, allow); + expect(allow).toHaveBeenCalledWith('product', 'prod-1'); + }); + + it('refuses the claim once that recipient has been flooded', async () => { + world(intent()); + await expect(acknowledgePublicPayment(PI_ID, TOKEN, refuse)).rejects.toThrow( + /Too many payment claims/ + ); + }); + + it('sends no card and moves no intent when the claim is refused', async () => { + const fake = world(intent()); + + await expect(acknowledgePublicPayment(PI_ID, TOKEN, refuse)).rejects.toThrow(); + + // The whole point: nothing reaches the seller, and the intent stays put. + expect(dispatchMock).not.toHaveBeenCalled(); + expect(fake.updates).toEqual([]); + }); + + it('does not spend the budget on an idempotent re-claim', async () => { + // Already buyer_confirmed: this returns early, costs the attacker nothing, + // and so must not consume a genuine payer's allowance either. + world(intent({ status: STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED })); + + await acknowledgePublicPayment(PI_ID, TOKEN, allow); + expect(allow).not.toHaveBeenCalled(); + }); + + it('is unbudgeted when no guard is supplied — the caller owns the policy', async () => { + const fake = world(intent()); + await acknowledgePublicPayment(PI_ID, TOKEN); + expect(fake.updates.map(u => u.patch.status)).toEqual([STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED]); + }); +}); diff --git a/__tests__/unit/domain/payments/resolveSellerWallet.test.ts b/__tests__/unit/domain/payments/resolveSellerWallet.test.ts index 5230c98cc..91923a1bc 100644 --- a/__tests__/unit/domain/payments/resolveSellerWallet.test.ts +++ b/__tests__/unit/domain/payments/resolveSellerWallet.test.ts @@ -110,7 +110,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => { }; getAdminClientMock.mockReturnValue(makeAdmin(fx)); - const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id); + const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id); expect(resolved).toEqual({ method: 'onchain', @@ -132,7 +132,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => { }; getAdminClientMock.mockReturnValue(makeAdmin(fx)); - const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id); + const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id); expect(resolved).toEqual({ method: 'lightning_address', @@ -145,7 +145,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => { const fx = baseFixtures(); // entityWallets empty getAdminClientMock.mockReturnValue(makeAdmin(fx)); - const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id); + const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id); expect(resolved).toEqual({ method: 'onchain', @@ -160,7 +160,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => { fx.walletsById = {}; // linked wallet not active → single() returns null getAdminClientMock.mockReturnValue(makeAdmin(fx)); - const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id); + const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id); expect(resolved).toEqual({ method: 'onchain', diff --git a/__tests__/unit/domain/payments/sellerIdentityAndReceiveInfo.test.ts b/__tests__/unit/domain/payments/sellerIdentityAndReceiveInfo.test.ts index 627ad86e8..f790e9475 100644 --- a/__tests__/unit/domain/payments/sellerIdentityAndReceiveInfo.test.ts +++ b/__tests__/unit/domain/payments/sellerIdentityAndReceiveInfo.test.ts @@ -70,7 +70,7 @@ describe('resolveSellerReceiveInfo — what the owner is shown', () => { const { client } = world({ wallets: [wallet({ lightning_address: 'me@orangecat.ch' })] }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerReceiveInfo({} as never, 'product', ENTITY_ID)).toEqual({ + expect(await resolveSellerReceiveInfo('product', ENTITY_ID)).toEqual({ method: 'lightning_address', address: 'me@orangecat.ch', }); @@ -80,7 +80,7 @@ describe('resolveSellerReceiveInfo — what the owner is shown', () => { const { client } = world({ wallets: [wallet({ address_or_xpub: 'bc1qexample' })] }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerReceiveInfo({} as never, 'product', ENTITY_ID)).toEqual({ + expect(await resolveSellerReceiveInfo('product', ENTITY_ID)).toEqual({ method: 'onchain', address: 'bc1qexample', }); @@ -92,7 +92,7 @@ describe('resolveSellerReceiveInfo — what the owner is shown', () => { const { client } = world({ wallets: [wallet({ nwc_connection_uri: secret })] }); getAdminClientMock.mockReturnValue(client); - const info = await resolveSellerReceiveInfo({} as never, 'product', ENTITY_ID); + const info = await resolveSellerReceiveInfo('product', ENTITY_ID); expect(info).toEqual({ method: 'nwc', address: null }); expect(JSON.stringify(info)).not.toContain('hunter2'); @@ -106,7 +106,7 @@ describe('resolveSellerReceiveInfo — what the owner is shown', () => { const { client } = world({ wallets: [wallet({ wallet_type: 'xpub', address_or_xpub: zpub })] }); getAdminClientMock.mockReturnValue(client); - const info = await resolveSellerReceiveInfo({} as never, 'product', ENTITY_ID); + const info = await resolveSellerReceiveInfo('product', ENTITY_ID); expect(info).toEqual({ method: 'onchain', address: null }); }); @@ -115,7 +115,7 @@ describe('resolveSellerReceiveInfo — what the owner is shown', () => { const { client } = world({ wallets: [] }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerReceiveInfo({} as never, 'product', ENTITY_ID)).toBeNull(); + expect(await resolveSellerReceiveInfo('product', ENTITY_ID)).toBeNull(); }); }); diff --git a/__tests__/unit/domain/payments/settlementRailDispatch.test.ts b/__tests__/unit/domain/payments/settlementRailDispatch.test.ts index 4884ae190..0a960dd65 100644 --- a/__tests__/unit/domain/payments/settlementRailDispatch.test.ts +++ b/__tests__/unit/domain/payments/settlementRailDispatch.test.ts @@ -91,7 +91,8 @@ beforeEach(() => { describe('each method asks its own authority and no other', () => { it('asks the NWC relay for an NWC payment', async () => { const pi = intent(); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(nwcMock).toHaveBeenCalledTimes(1); expect(lnurlMock).not.toHaveBeenCalled(); @@ -104,7 +105,8 @@ describe('each method asks its own authority and no other', () => { payment_hash: null, lnurl_verify_url: 'https://ln.example/verify/1', }); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(lnurlMock).toHaveBeenCalledTimes(1); expect(nwcMock).not.toHaveBeenCalled(); @@ -117,7 +119,8 @@ describe('each method asks its own authority and no other', () => { payment_hash: null, onchain_address: 'bc1qexample', }); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(onchainMock).toHaveBeenCalledTimes(1); expect(nwcMock).not.toHaveBeenCalled(); @@ -128,21 +131,24 @@ describe('each method asks its own authority and no other', () => { describe('a rail is never asked without what it needs to answer', () => { it('does not query the relay for an NWC intent with no payment hash', async () => { const pi = intent({ payment_hash: null }); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(nwcMock).not.toHaveBeenCalled(); }); it('does not query the mempool for an on-chain intent with no address', async () => { const pi = intent({ payment_method: 'onchain', payment_hash: null, onchain_address: null }); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(onchainMock).not.toHaveBeenCalled(); }); it('does not query a verify URL a bare Lightning address does not have', async () => { const pi = intent({ payment_method: 'lightning_address', payment_hash: null }); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(lnurlMock).not.toHaveBeenCalled(); }); @@ -153,7 +159,9 @@ describe('only the authority can settle a payment', () => { nwcMock.mockResolvedValue(true); const pi = intent(); - const result = await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.PAID); expect(result.paid_at).not.toBeNull(); @@ -167,7 +175,9 @@ describe('only the authority can settle a payment', () => { lnurl_verify_url: 'https://ln.example/verify/1', }); - const result = await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.PAID); }); @@ -180,7 +190,9 @@ describe('only the authority can settle a payment', () => { onchain_address: 'bc1qexample', }); - const result = await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.PAID); }); @@ -194,7 +206,7 @@ describe('only the authority can settle a payment', () => { }); const fake = world(pi); - const result = await reconcilePaymentIntent(fake.client, pi); + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.PENDING_CONFIRMATION); expect(result.paid_at).toBeNull(); @@ -213,7 +225,7 @@ describe('only the authority can settle a payment', () => { }); const fake = world(pi); - await reconcilePaymentIntent(fake.client, pi); + await reconcilePaymentIntent(pi); expect(fake.updates).toEqual([]); }); @@ -222,7 +234,7 @@ describe('only the authority can settle a payment', () => { const pi = intent(); const fake = world(pi); - const result = await reconcilePaymentIntent(fake.client, pi); + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.INVOICE_READY); expect(fake.updates).toEqual([]); @@ -236,7 +248,9 @@ describe('expiry is only the truth after the rail has said no', () => { nwcMock.mockResolvedValue(true); const pi = intent({ expires_at: DEAD }); - const result = await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.PAID); }); @@ -245,7 +259,7 @@ describe('expiry is only the truth after the rail has said no', () => { const pi = intent({ expires_at: DEAD }); const fake = world(pi); - const result = await reconcilePaymentIntent(fake.client, pi); + const result = await reconcilePaymentIntent(pi); expect(nwcMock).toHaveBeenCalled(); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.EXPIRED); @@ -262,7 +276,7 @@ describe('expiry is only the truth after the rail has said no', () => { }); const fake = world(pi); - const result = await reconcilePaymentIntent(fake.client, pi); + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.INVOICE_READY); expect(fake.updates).toEqual([]); @@ -271,7 +285,9 @@ describe('expiry is only the truth after the rail has said no', () => { it('returns a terminal status without asking any rail', async () => { const pi = intent({ status: STATUS.PAYMENT_INTENTS.PAID, paid_at: '2026-08-01T10:00:00Z' }); - const result = await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + + const result = await reconcilePaymentIntent(pi); expect(result).toEqual({ status: STATUS.PAYMENT_INTENTS.PAID, diff --git a/__tests__/unit/domain/payments/undetectableIntentLifecycle.test.ts b/__tests__/unit/domain/payments/undetectableIntentLifecycle.test.ts index 10eeb517c..598b07c4b 100644 --- a/__tests__/unit/domain/payments/undetectableIntentLifecycle.test.ts +++ b/__tests__/unit/domain/payments/undetectableIntentLifecycle.test.ts @@ -69,7 +69,8 @@ beforeEach(() => { describe('undetectable intent around expiry', () => { it('stays OPEN after invoice expiry while the claim window runs — the payer can still say "I paid"', async () => { const expiredAnHourAgo = new Date(Date.now() - 3_600_000).toISOString(); - const res = await reconcilePaymentIntent(makeSupabase(), bareLightningIntent(expiredAnHourAgo)); + makeSupabase(); + const res = await reconcilePaymentIntent(bareLightningIntent(expiredAnHourAgo)); expect(res.status).toBe(STATUS.PAYMENT_INTENTS.INVOICE_READY); expect(updates).toHaveLength(0); @@ -77,7 +78,8 @@ describe('undetectable intent around expiry', () => { it('terminalizes honestly once the invoice is dead AND the claim window has closed', async () => { const longDead = new Date(Date.now() - BUYER_CLAIM_GRACE_MS - 3_600_000).toISOString(); - const res = await reconcilePaymentIntent(makeSupabase(), bareLightningIntent(longDead)); + makeSupabase(); + const res = await reconcilePaymentIntent(bareLightningIntent(longDead)); expect(res.status).toBe(STATUS.PAYMENT_INTENTS.EXPIRED); expect(updates.some(u => u.status === STATUS.PAYMENT_INTENTS.EXPIRED)).toBe(true); @@ -85,10 +87,8 @@ describe('undetectable intent around expiry', () => { it('never asks any rail about an undetectable intent — there is nothing to ask', async () => { const statusService = jest.requireMock('@/domain/payments/paymentStatusService'); - await reconcilePaymentIntent( - makeSupabase(), - bareLightningIntent(new Date(Date.now() + 600_000).toISOString()) - ); + makeSupabase(); + await reconcilePaymentIntent(bareLightningIntent(new Date(Date.now() + 600_000).toISOString())); expect(statusService.checkNWCPaymentStatus).not.toHaveBeenCalled(); expect(statusService.checkLnurlVerifyPaymentStatus).not.toHaveBeenCalled(); diff --git a/__tests__/unit/domain/payments/walletResolutionQueries.test.ts b/__tests__/unit/domain/payments/walletResolutionQueries.test.ts index d7b32a401..5fe7f0d22 100644 --- a/__tests__/unit/domain/payments/walletResolutionQueries.test.ts +++ b/__tests__/unit/domain/payments/walletResolutionQueries.test.ts @@ -245,7 +245,7 @@ describe('resolveSellerWallet — a wallet tied to one specific entity', () => { // Falls through to the owner's profile default rather than paying a wallet // that is no longer in use. - expect(await resolveSellerWallet({} as never, 'product', 'prod-1')).toMatchObject({ + expect(await resolveSellerWallet('product', 'prod-1')).toMatchObject({ wallet_id: 'w-default', }); }); @@ -263,7 +263,7 @@ describe('resolveSellerWallet — a wallet tied to one specific entity', () => { ] ); - expect(await resolveSellerWallet({} as never, 'product', 'prod-1')).toMatchObject({ + expect(await resolveSellerWallet('product', 'prod-1')).toMatchObject({ wallet_id: 'w-chosen', }); }); @@ -277,7 +277,7 @@ describe('resolveSellerWallet — a wallet tied to one specific entity', () => { ] ); - expect(await resolveSellerWallet({} as never, 'product', 'prod-1')).toMatchObject({ + expect(await resolveSellerWallet('product', 'prod-1')).toMatchObject({ wallet_id: 'w-default', }); }); @@ -308,7 +308,7 @@ describe('resolveSellerWallet — group entities', () => { }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerWallet({} as never, 'group', GROUP)).toEqual({ + expect(await resolveSellerWallet('group', GROUP)).toEqual({ method: 'lightning_address', wallet_id: 'gw-ours', lightning_address: 'ours@ln', @@ -325,7 +325,7 @@ describe('resolveSellerWallet — group entities', () => { }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerWallet({} as never, 'group', GROUP)).toEqual({ + expect(await resolveSellerWallet('group', GROUP)).toEqual({ method: 'onchain', wallet_id: 'gw-live', onchain_address: 'bc1qlive', @@ -346,11 +346,13 @@ describe('resolveSellerWallet — group entities', () => { [DATABASE_TABLES.WALLETS]: [ wallet({ id: 'w-founder', profile_id: 'founder-1', lightning_address: 'founder@ln' }), ], - [DATABASE_TABLES.GROUP_WALLETS]: [groupWallet({ id: 'gw-ours', lightning_address: 'ours@ln' })], + [DATABASE_TABLES.GROUP_WALLETS]: [ + groupWallet({ id: 'gw-ours', lightning_address: 'ours@ln' }), + ], }); getAdminClientMock.mockReturnValue(fake.client); - expect(await resolveSellerWallet({} as never, 'product', 'prod-1')).toEqual({ + expect(await resolveSellerWallet('product', 'prod-1')).toEqual({ method: 'lightning_address', wallet_id: 'gw-ours', lightning_address: 'ours@ln', @@ -370,7 +372,7 @@ describe('resolveSellerWallet — group entities', () => { }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerWallet({} as never, 'group', GROUP)).toMatchObject({ + expect(await resolveSellerWallet('group', GROUP)).toMatchObject({ wallet_id: 'gw-old', }); }); @@ -382,6 +384,6 @@ describe('resolveSellerWallet — group entities', () => { }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerWallet({} as never, 'group', GROUP)).toBeNull(); + expect(await resolveSellerWallet('group', GROUP)).toBeNull(); }); }); diff --git a/src/app/api/bookings/[id]/receive-info/route.ts b/src/app/api/bookings/[id]/receive-info/route.ts index d2373fe44..c3d254a48 100644 --- a/src/app/api/bookings/[id]/receive-info/route.ts +++ b/src/app/api/bookings/[id]/receive-info/route.ts @@ -56,7 +56,6 @@ export const GET = withAuth(async (request: AuthenticatedRequest, context: Route } const info = await resolveSellerReceiveInfo( - supabase, booking.bookable_type as EntityType, booking.bookable_id ); diff --git a/src/app/api/payments/receive-info/route.ts b/src/app/api/payments/receive-info/route.ts index 2f5325fb2..c7a06ee84 100644 --- a/src/app/api/payments/receive-info/route.ts +++ b/src/app/api/payments/receive-info/route.ts @@ -43,7 +43,7 @@ export const GET = withAuth(async (request: AuthenticatedRequest) => { return apiForbidden('Only the owner can view receiving info for this entity'); } - const info = await resolveSellerReceiveInfo(supabase, entityType as EntityType, entityId); + const info = await resolveSellerReceiveInfo(entityType as EntityType, entityId); return apiSuccess({ hasWallet: !!info, diff --git a/src/app/api/v1/payments/public/[id]/route.ts b/src/app/api/v1/payments/public/[id]/route.ts index 0480c6b8d..6194047d3 100644 --- a/src/app/api/v1/payments/public/[id]/route.ts +++ b/src/app/api/v1/payments/public/[id]/route.ts @@ -1,16 +1,8 @@ -import { - apiBadRequest, - apiNotFound, - apiRateLimited, - apiSuccess, -} from '@/lib/api/standardResponse'; -import { - acknowledgePublicPayment, - checkPublicPaymentStatus, -} from '@/domain/payments'; +import { apiBadRequest, apiNotFound, apiRateLimited, apiSuccess } from '@/lib/api/standardResponse'; +import { acknowledgePublicPayment, checkPublicPaymentStatus } from '@/domain/payments'; import { publicPaymentActionSchema } from '@/lib/validation/finance'; import { validateUUID, getValidationError } from '@/lib/api/validation'; -import { rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit'; +import { rateLimitPaymentClaim, rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit'; import { clientIpKey } from '@/lib/client-ip'; // Same per-IP keying as ../route.ts — anonymous callers, so IP is the only handle. @@ -23,10 +15,7 @@ function readToken(request: Request): string | null { return token && token.length >= 32 && token.length <= 128 ? token : null; } -export async function GET( - request: Request, - context: { params: Promise<{ id: string }> } -) { +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; const idError = getValidationError(validateUUID(id, 'payment ID')); if (idError) { @@ -44,10 +33,7 @@ export async function GET( } } -export async function POST( - request: Request, - context: { params: Promise<{ id: string }> } -) { +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { const limit = await rateLimitWriteAsync(requestKey(request)); if (!limit.success) { return apiRateLimited( @@ -72,7 +58,13 @@ export async function POST( } try { - return apiSuccess(await acknowledgePublicPayment(id, token), { cache: 'NONE' }); + // The recipient-side budget lives here, in the HTTP layer that owns + // policy; the domain decides only WHEN to ask (once, on the real + // transition). See ClaimGuard in paymentStatusFlow. + const claimGuard = async (entityType: string, entityId: string) => + (await rateLimitPaymentClaim(entityType, entityId)).success; + + return apiSuccess(await acknowledgePublicPayment(id, token, claimGuard), { cache: 'NONE' }); } catch (error) { const message = error instanceof Error ? error.message : ''; if (message.includes('automatically')) { @@ -81,6 +73,11 @@ export async function POST( if (message.includes('expired')) { return apiBadRequest('This payment request has expired.'); } + if (message.includes('Too many payment claims')) { + return apiRateLimited( + 'This recipient has received too many payment claims right now. Try again shortly.' + ); + } return apiNotFound('Payment not found'); } } diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 55cda881a..494eff040 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -207,7 +207,7 @@ export default async function PublicProjectPage({ params }: PageProps) { supporters_count: fundingStats?.contributorCount ?? 0, profiles: profile ?? undefined, }; - const sellerReceive = await resolveSellerReceiveInfo(supabase, 'project', id); + const sellerReceive = await resolveSellerReceiveInfo('project', id); // A draft project is invisible to everyone but its owner (the projects_public_read // RLS policy), and nothing on the page used to say so — projects were the one diff --git a/src/components/public/PublicEntityDetailPage.tsx b/src/components/public/PublicEntityDetailPage.tsx index 7fb00affb..48cbfece4 100644 --- a/src/components/public/PublicEntityDetailPage.tsx +++ b/src/components/public/PublicEntityDetailPage.tsx @@ -151,7 +151,7 @@ export default async function PublicEntityDetailPage({ let priceAmountBtc: number | undefined; const hasPaymentSurface = config.showPaymentSection !== false || meta.canReceiveSupport; if (hasPaymentSurface) { - sellerReceive = await resolveSellerReceiveInfo(supabase, config.entityType, id); + sellerReceive = await resolveSellerReceiveInfo(config.entityType, id); // Address-reuse disclosure — only worth resolving when an address will // actually be shown (NWC reveals no static address to link). if (sellerReceive?.address) { diff --git a/src/domain/payments/paymentInitiation.ts b/src/domain/payments/paymentInitiation.ts index e2dfdaa58..76f75059d 100644 --- a/src/domain/payments/paymentInitiation.ts +++ b/src/domain/payments/paymentInitiation.ts @@ -66,7 +66,7 @@ export async function initiatePayment( } // 2. Resolve seller's wallet & payment method - const wallet = await resolveSellerWallet(supabase, entity_type, entity_id); + const wallet = await resolveSellerWallet(entity_type, entity_id); if (!wallet) { throw new Error('Seller has no wallet connected. Payment not available.'); } @@ -205,7 +205,7 @@ export async function initiatePublicSupport( throw new Error('Entity owner not found'); } - const wallet = await resolveSellerWallet(publicSupabase, entityType, entityId); + const wallet = await resolveSellerWallet(entityType, entityId); if (!wallet) { throw new Error('Seller has no wallet connected. Payment not available.'); } diff --git a/src/domain/payments/paymentStatusFlow.ts b/src/domain/payments/paymentStatusFlow.ts index fa78fd54b..3c7c5c864 100644 --- a/src/domain/payments/paymentStatusFlow.ts +++ b/src/domain/payments/paymentStatusFlow.ts @@ -51,7 +51,7 @@ export async function checkPaymentStatus( throw new Error('Access denied'); } - return refreshPaymentStatus(supabase, pi as PaymentIntent); + return refreshPaymentStatus(pi as PaymentIntent); } export async function checkPublicPaymentStatus( @@ -70,16 +70,27 @@ export async function checkPublicPaymentStatus( throw new Error('Payment not found'); } - const result = await refreshPaymentStatus(admin, pi as PaymentIntent); + const result = await refreshPaymentStatus(pi as PaymentIntent); return { ...result, requires_recipient_confirmation: result.status === STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED, }; } +/** + * Asked, once, whether this recipient may receive another claim. + * + * Injected rather than imported: rate limiting is infrastructure, and pulling + * it in here would drag the Upstash client into the payments domain — wrong + * layer, and it breaks every domain test that has no business knowing about + * Redis. The HTTP layer owns the policy; the domain owns WHEN it is asked. + */ +export type ClaimGuard = (entityType: string, entityId: string) => Promise; + export async function acknowledgePublicPayment( paymentIntentId: string, - token: string + token: string, + claimGuard?: ClaimGuard ): Promise { const admin = getAdminClient() as unknown as SupabaseClient; const { data: pi } = await admin @@ -128,6 +139,21 @@ export async function acknowledgePublicPayment( }; } + // Bound claims per RECIPIENT, not per caller — the abuse shape is many + // addresses aimed at one seller's confirmation queue, which the caller-keyed + // budget in front of this cannot see. Asked only on the real transition: the + // idempotent re-claim paths above return before here, cost an attacker + // nothing, and so must not consume a genuine payer's allowance either. + if (claimGuard) { + const allowed = await claimGuard( + (pi.entity_type as string) ?? 'unknown', + (pi.entity_id as string) ?? paymentIntentId + ); + if (!allowed) { + throw new Error('Too many payment claims for this recipient'); + } + } + await updatePaymentStatus(paymentIntentId, STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED); notifyRecipientOfClaim(pi as PaymentIntent); return { @@ -159,10 +185,7 @@ function notifyRecipientOfClaim(pi: PaymentIntent): void { }); } -async function refreshPaymentStatus( - supabase: SupabaseClient, - pi: PaymentIntent -): Promise { +async function refreshPaymentStatus(pi: PaymentIntent): Promise { const terminalStatuses = new Set([ STATUS.PAYMENT_INTENTS.PAID, STATUS.PAYMENT_INTENTS.EXPIRED, @@ -242,11 +265,8 @@ async function refreshPaymentStatus( * Deliberately NOT a second implementation: a copy would drift, and then two * parts of the product would disagree about whether money arrived. */ -export async function reconcilePaymentIntent( - supabase: SupabaseClient, - pi: PaymentIntent -): Promise { - return refreshPaymentStatus(supabase, pi); +export async function reconcilePaymentIntent(pi: PaymentIntent): Promise { + return refreshPaymentStatus(pi); } /** diff --git a/src/domain/payments/walletResolutionService.ts b/src/domain/payments/walletResolutionService.ts index b7f5ec7e1..3ed260669 100644 --- a/src/domain/payments/walletResolutionService.ts +++ b/src/domain/payments/walletResolutionService.ts @@ -28,7 +28,6 @@ import { logger } from '@/utils/logger'; * Returns null if seller has no wallet connected. */ export async function resolveSellerWallet( - supabase: SupabaseClient, entityType: EntityType, entityId: string ): Promise { @@ -122,11 +121,10 @@ export interface SellerReceiveInfo { * buyers will actually pay to. Returns null when no wallet is connected. */ export async function resolveSellerReceiveInfo( - supabase: SupabaseClient, entityType: EntityType, entityId: string ): Promise { - const resolved = await resolveSellerWallet(supabase, entityType, entityId); + const resolved = await resolveSellerWallet(entityType, entityId); if (!resolved) { return null; } diff --git a/src/domain/wallets/walletUsage.ts b/src/domain/wallets/walletUsage.ts index 33e7fc5ee..321638128 100644 --- a/src/domain/wallets/walletUsage.ts +++ b/src/domain/wallets/walletUsage.ts @@ -88,7 +88,7 @@ export async function getSharedWalletUsage( entityId: string ): Promise { try { - const resolved = await resolveSellerWallet(supabase, entityType, entityId); + const resolved = await resolveSellerWallet(entityType, entityId); if (!resolved) { return null; } diff --git a/src/lib/api/standardResponse.ts b/src/lib/api/standardResponse.ts index 35b9a1170..286d8c5f8 100644 --- a/src/lib/api/standardResponse.ts +++ b/src/lib/api/standardResponse.ts @@ -195,6 +195,11 @@ export function apiPaymentRequired( }, { status: 402 } ); + // The 402 body and the WWW-Authenticate header both carry the payment + // token — a bearer credential for this intent. Every other payment response + // sets no-store; this one did not, so a shared cache or an intermediary was + // free to keep the credential and hand it to the next caller. + response.headers.set('Cache-Control', CACHE_PRESETS.NONE); if (wwwAuthenticate) { response.headers.set('WWW-Authenticate', wwwAuthenticate); } diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 1f5c8a0cd..77a419588 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -101,6 +101,7 @@ const upstashTipRecipientLimiter = createUpstashLimiter('tip-recipient', 20, '5 // Generous on purpose: a paying client polls, and refusing a real payer is // worse than the outbound traffic this bounds. const upstashL402VerifyLimiter = createUpstashLimiter('l402-verify', 60, '1 m'); +const upstashPaymentClaimLimiter = createUpstashLimiter('payment-claim', 10, '1 h'); // Public Ask-Cat / feedback endpoint: each submission costs a platform LLM // call, and the caller may be anonymous — so a tight per-IP budget on top of // the general limiter. 8 per 5 min is plenty for a real person having a @@ -173,6 +174,10 @@ const fallbackL402VerifyLimiter = new InMemoryRateLimiter({ windowMs: 60 * 1000, maxRequests: 60, }); +const fallbackPaymentClaimLimiter = new InMemoryRateLimiter({ + windowMs: 60 * 60 * 1000, + maxRequests: 10, +}); const fallbackAskCatLimiter = new InMemoryRateLimiter({ windowMs: 5 * 60 * 1000, maxRequests: 8, @@ -282,6 +287,37 @@ export async function rateLimitPaymentRecipient( return rateLimitTipRecipient(`${entityType}:${entityId}`); } +/** + * Rate limit "I paid you" claims per RECIPIENT. + * + * An acknowledge is testimony, not settlement: it flips an intent to + * buyer_confirmed and fires a "someone says they paid you" card into the + * recipient's confirmation queue. One card is a prompt to check a wallet; a + * hundred is a denial-of-attention attack, and the social-engineering primitive + * is ship-the-goods-for-no-money. + * + * The route's per-IP budget cannot bound this — the claims that matter come + * from many addresses at one seller. Creating the intents is already bounded + * per recipient (rateLimitPaymentRecipient), so this is the second half of the + * same fence: bound the claims as well as the invoices. + * + * 10 per hour per recipient. A genuine payer claims once, and retries are + * idempotent no-ops that never reach here. bitbaum/orangecat#563 finding 3. + */ +export async function rateLimitPaymentClaim( + entityType: string, + entityId: string +): Promise { + const key = `payment-claim:${entityType}:${entityId}`; + + if (upstashPaymentClaimLimiter) { + const result = await upstashPaymentClaimLimiter.limit(key); + return toRateLimitResult(result); + } + + return fallbackPaymentClaimLimiter.check(key); +} + /** * Rate limit L402 verification PER TOKEN. * diff --git a/src/services/cat/handlers/payments.ts b/src/services/cat/handlers/payments.ts index c4977f80a..935f4a057 100644 --- a/src/services/cat/handlers/payments.ts +++ b/src/services/cat/handlers/payments.ts @@ -310,11 +310,7 @@ export const paymentHandlers: Record = { } // 2. Resolve project owner's payment method (uses admin internally for cross-user lookup) - const projectWallet = await resolveSellerWallet( - supabase as unknown as SupabaseClient, - 'project', - projectId - ); + const projectWallet = await resolveSellerWallet('project', projectId); if (!projectWallet) { return { diff --git a/src/services/payments/reconcile.ts b/src/services/payments/reconcile.ts index 2c26cb430..db6934e29 100644 --- a/src/services/payments/reconcile.ts +++ b/src/services/payments/reconcile.ts @@ -249,7 +249,7 @@ export async function runPaymentReconcileSweep(): Promise // The SAME path the payer's browser runs. Settlement side-effects are // exactly-once (claimPaidTransition), so overlapping with a live poll // is safe by construction. - const result = await reconcilePaymentIntent(admin, intent); + const result = await reconcilePaymentIntent(intent); if (result.status === STATUS.PAYMENT_INTENTS.PAID) { settled += 1; logger.info(