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
5 changes: 5 additions & 0 deletions .changeset/bazaar-automatic-cataloging-130.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'lens': minor
---

Automatic cataloging of resources from discovery extension upon settlement (#130)
46 changes: 46 additions & 0 deletions src/__tests__/facilitatorSettle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() => ({
Expand Down Expand Up @@ -68,6 +69,7 @@ function settleBody(
transaction?: string
network?: string
payer?: string
extensions?: Record<string, unknown>
} = {},
) {
return {
Expand All @@ -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: {
Expand Down Expand Up @@ -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', () => {
Expand Down
22 changes: 22 additions & 0 deletions src/routes/facilitator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>
const extensions = (rawPayload.extensions as Record<string, unknown>) ?? {}
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) {
Expand Down