diff --git a/.changeset/bazaar-automatic-cataloging-130.md b/.changeset/bazaar-automatic-cataloging-130.md new file mode 100644 index 00000000..643aae2f --- /dev/null +++ b/.changeset/bazaar-automatic-cataloging-130.md @@ -0,0 +1,5 @@ +--- +'lens': minor +--- + +Automatic cataloging of resources from discovery extension upon settlement (#130) diff --git a/src/__tests__/facilitatorSettle.test.ts b/src/__tests__/facilitatorSettle.test.ts index 3d449fa6..dafbad54 100644 --- a/src/__tests__/facilitatorSettle.test.ts +++ b/src/__tests__/facilitatorSettle.test.ts @@ -8,6 +8,7 @@ import { Operation, TransactionBuilder, } from '@stellar/stellar-sdk' +import * as catalogModule from '../bazaar/catalog' const { mockCreate, mockFindUnique, mockUpdate, mockGetFacilitator, mockSettle, mockGetTransaction } = vi.hoisted( () => ({ @@ -68,6 +69,7 @@ function settleBody( transaction?: string network?: string payer?: string + extensions?: Record } = {}, ) { return { @@ -77,6 +79,7 @@ function settleBody( scheme: 'exact', network: overrides.network ?? 'stellar:testnet', ...(overrides.payer ? { payer: overrides.payer } : {}), + ...(overrides.extensions ? { extensions: overrides.extensions } : {}), payload: { transaction: overrides.transaction ?? TESTNET_ENVELOPE }, }, paymentRequirements: { @@ -361,6 +364,49 @@ describe('POST /settle — failure semantics', () => { expect(body.errorReason).toBe('settle_exact_stellar_transaction_submission_failed') }) + + it('catalogs the resource automatically on successful settle and sets EXTENSION-RESPONSES header (#130)', async () => { + const submitSpy = vi.spyOn(catalogModule, 'submitCatalogListing').mockResolvedValue({ + accepted: true, + drops: [], + }) + mockSettle.mockResolvedValue({ success: true, transaction: 'onchain-hash', network: 'stellar:testnet' }) + const app = await buildApp() + + const payload = settleBody({ + extensions: { + bazaar: { + resource: { url: 'https://api.seller.com/price', serviceName: 'Seller Price API' }, + bazaar: { schema: { type: 'object' }, info: { input: {} } }, + }, + }, + }) + + const res = await app.inject({ method: 'POST', url: '/settle', payload }) + expect(res.statusCode).toBe(200) + expect(submitSpy).toHaveBeenCalled() + expect(res.headers['extension-responses']).toBeDefined() + const extHeader = JSON.parse(res.headers['extension-responses'] as string) + expect(extHeader.bazaar.catalog.accepted).toBe(true) + }) + + it('does not delay or fail settlement if cataloging throws (#130)', async () => { + vi.spyOn(catalogModule, 'submitCatalogListing').mockRejectedValue(new Error('Catalog storage down')) + mockSettle.mockResolvedValue({ success: true, transaction: 'onchain-hash', network: 'stellar:testnet' }) + const app = await buildApp() + + const payload = settleBody({ + extensions: { + bazaar: { + resource: { url: 'https://api.seller.com/price' }, + }, + }, + }) + + const res = await app.inject({ method: 'POST', url: '/settle', payload }) + expect(res.statusCode).toBe(200) + expect(res.json().success).toBe(true) + }) }) describe('deriveIdempotencyKey', () => { diff --git a/src/routes/facilitator.ts b/src/routes/facilitator.ts index 7f8ecaee..e8637b5d 100644 --- a/src/routes/facilitator.ts +++ b/src/routes/facilitator.ts @@ -4,6 +4,8 @@ import { rpc } from '@stellar/stellar-sdk' import { prisma } from '../db' import { getNetworkConfig, type NetworkName } from '../config' import { CAIP2_BY_NETWORK, getFacilitator, type SettleResponseShape } from '../x402/facilitator' +import { submitCatalogListing } from '../bazaar/catalog' +import { toExtensionResponses } from '../bazaar/validation' /** * `POST /settle` — the facilitator endpoint that actually submits a payment. @@ -235,6 +237,26 @@ export async function registerSettleRoute(app: FastifyInstance) { ...(response.success ? {} : { errorReason: response.errorReason ?? SETTLE_ERROR_REASONS.unexpected }), } + // Automatic cataloging from discovery extension (#130) + if (normalised.success && body.paymentPayload) { + try { + const rawPayload = body.paymentPayload as Record + const extensions = (rawPayload.extensions as Record) ?? {} + const bazaarExtension = extensions.bazaar ?? rawPayload.discovery + if (bazaarExtension && body.paymentRequirements?.payTo) { + const authority = { + payTo: String(body.paymentRequirements.payTo), + network, + } + const writeResult = await submitCatalogListing(bazaarExtension as any, authority) + const extHeader = toExtensionResponses(writeResult.drops) + reply.header('EXTENSION-RESPONSES', JSON.stringify(extHeader)) + } + } catch { + // Failure in cataloging must never fail or delay settlement (#130) + } + } + await finalise(attemptId, normalised.success ? 'settled' : 'failed', normalised) return normalised } catch (err) {