diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..6840367 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,41 @@ +name: Integration Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + integration-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Build SDK + run: pnpm build --filter=@openfacilitator/sdk + + - name: Run SDK unit tests + run: pnpm --filter=@openfacilitator/sdk test run + + - name: Run integration tests + run: pnpm --filter=@openfacilitator/integration-tests test + env: + TEST_CUSTOM_DOMAIN: ${{ secrets.TEST_CUSTOM_DOMAIN }} + # Note: Real Solana transaction tests are not run in CI + # They require manual execution with: pnpm test:solana + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d8677ef --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,62 @@ +name: Publish Packages + +on: + push: + branches: [main] + paths: + - 'packages/sdk/package.json' + - 'packages/core/package.json' + - 'packages/server/package.json' + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: pnpm install + + - name: Build all packages + run: pnpm build + + - name: Run SDK unit tests + run: pnpm --filter=@openfacilitator/sdk test run + + - name: Publish packages + run: | + for pkg in packages/core packages/sdk packages/server; do + if [ -f "$pkg/package.json" ]; then + name=$(node -p "require('./$pkg/package.json').name") + local_version=$(node -p "require('./$pkg/package.json').version") + is_private=$(node -p "require('./$pkg/package.json').private || false") + + if [ "$is_private" = "true" ]; then + echo "Skipping $name (private)" + continue + fi + + published_version=$(npm view "$name" version 2>/dev/null || echo "0.0.0") + + if [ "$local_version" != "$published_version" ]; then + echo "Publishing $name@$local_version (was $published_version)" + pnpm --filter="$name" publish --no-git-checks --access public + else + echo "Skipping $name@$local_version (already published)" + fi + fi + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 87645fd..f655d16 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@openfacilitator/sdk", - "version": "0.7.2", + "version": "1.0.0", "description": "TypeScript SDK for x402 payment facilitation", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/packages/sdk/src/middleware.test.ts b/packages/sdk/src/middleware.test.ts new file mode 100644 index 0000000..312c7e8 --- /dev/null +++ b/packages/sdk/src/middleware.test.ts @@ -0,0 +1,439 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createPaymentMiddleware, honoPaymentMiddleware } from './middleware.js'; +import type { PaymentContext } from './middleware.js'; +import type { OpenFacilitator } from './client.js'; +import type { PaymentRequirements } from './types.js'; + +// ============ Helpers ============ + +function createMockFacilitator(overrides: { + verify?: OpenFacilitator['verify']; + settle?: OpenFacilitator['settle']; +} = {}) { + return { + url: 'https://test.facilitator.xyz', + verify: overrides.verify ?? vi.fn().mockResolvedValue({ isValid: true }), + settle: overrides.settle ?? vi.fn().mockResolvedValue({ + success: true, + transaction: '0xtxhash', + payer: '0xpayer', + network: 'base', + }), + } as unknown as OpenFacilitator; +} + +/** Build a v1 payment requirements object (uses maxAmountRequired). */ +function v1Requirements(overrides: Partial = {}): PaymentRequirements { + return { + scheme: 'exact', + network: 'base', + maxAmountRequired: '1000000', + asset: '0xUSDC', + payTo: '0xRecipient', + ...overrides, + } as PaymentRequirements; +} + +/** Build a v2 payment requirements object (uses amount). */ +function v2Requirements(overrides: Partial = {}): PaymentRequirements { + return { + scheme: 'exact', + network: 'base', + amount: '2000000', + asset: '0xUSDC', + payTo: '0xRecipient', + maxTimeoutSeconds: 600, + ...overrides, + } as PaymentRequirements; +} + +/** Create a base64-encoded x-payment header string from a v2 payload. */ +function makePaymentHeader(network = 'base') { + const payload = { + x402Version: 2, + accepted: { + scheme: 'exact', + network, + asset: '0xUSDC', + amount: '1000000', + payTo: '0xRecipient', + maxTimeoutSeconds: 300, + }, + payload: { signature: '0xsig' }, + }; + return Buffer.from(JSON.stringify(payload)).toString('base64'); +} + +// ============ Express helpers ============ + +function createExpressReq(headers: Record = {}) { + return { + headers: headers as Record, + url: '/test', + paymentContext: undefined as PaymentContext | undefined, + }; +} + +function createExpressRes() { + const jsonFn = vi.fn(); + const statusFn = vi.fn().mockReturnValue({ json: jsonFn }); + return { + status: statusFn, + locals: {} as Record, + _statusFn: statusFn, + _jsonFn: jsonFn, + }; +} + +// ============ Hono helpers ============ + +function createHonoContext(paymentHeader?: string) { + const store = new Map(); + const jsonFn = vi.fn().mockImplementation((body: unknown, status?: number) => ({ + _body: body, + _status: status, + })); + + return { + req: { + header: vi.fn().mockImplementation((name: string) => { + if (name === 'x-payment') return paymentHeader; + return undefined; + }), + url: 'https://example.com/test', + }, + json: jsonFn, + get: vi.fn().mockImplementation((key: string) => store.get(key)), + set: vi.fn().mockImplementation((key: string, value: unknown) => store.set(key, value)), + _jsonFn: jsonFn, + _store: store, + }; +} + +// ============ Express: createPaymentMiddleware ============ + +describe('createPaymentMiddleware (Express)', () => { + let facilitator: OpenFacilitator; + + beforeEach(() => { + facilitator = createMockFacilitator(); + }); + + it('returns 402 with x402Version 2 and accepts when no X-PAYMENT header', async () => { + const middleware = createPaymentMiddleware({ + facilitator, + getRequirements: () => v1Requirements(), + }); + + const req = createExpressReq({}); + const res = createExpressRes(); + const next = vi.fn(); + + await middleware(req, res, next); + + expect(res._statusFn).toHaveBeenCalledWith(402); + const body = res._jsonFn.mock.calls[0][0]; + expect(body.x402Version).toBe(2); + expect(body.accepts).toBeDefined(); + expect(Array.isArray(body.accepts)).toBe(true); + expect(body.accepts[0]).toMatchObject({ + scheme: 'exact', + network: 'base', + amount: '1000000', + asset: '0xUSDC', + payTo: '0xRecipient', + maxTimeoutSeconds: 300, + }); + expect(body.accepts[0]).not.toHaveProperty('maxAmountRequired'); + expect(next).not.toHaveBeenCalled(); + }); + + it('normalizes v1 maxAmountRequired to v2 amount in accepts', async () => { + const middleware = createPaymentMiddleware({ + facilitator, + getRequirements: () => v1Requirements({ maxAmountRequired: '5000000' } as Record), + }); + + const req = createExpressReq({}); + const res = createExpressRes(); + const next = vi.fn(); + + await middleware(req, res, next); + + const body = res._jsonFn.mock.calls[0][0]; + expect(body.accepts[0].amount).toBe('5000000'); + expect(body.accepts[0]).not.toHaveProperty('maxAmountRequired'); + expect(body.accepts[0].maxTimeoutSeconds).toBe(300); + }); + + it('passes v2 requirements through correctly', async () => { + const middleware = createPaymentMiddleware({ + facilitator, + getRequirements: () => v2Requirements({ amount: '9999', maxTimeoutSeconds: 600 } as Record), + }); + + const req = createExpressReq({}); + const res = createExpressRes(); + const next = vi.fn(); + + await middleware(req, res, next); + + const body = res._jsonFn.mock.calls[0][0]; + expect(body.accepts[0].amount).toBe('9999'); + expect(body.accepts[0].maxTimeoutSeconds).toBe(600); + }); + + it('includes x402Version and accepts on verification failure', async () => { + facilitator = createMockFacilitator({ + verify: vi.fn().mockResolvedValue({ isValid: false, invalidReason: 'bad sig' }), + }); + + const middleware = createPaymentMiddleware({ + facilitator, + getRequirements: () => v1Requirements(), + }); + + const req = createExpressReq({ 'x-payment': makePaymentHeader() }); + const res = createExpressRes(); + const next = vi.fn(); + + await middleware(req, res, next); + + expect(res._statusFn).toHaveBeenCalledWith(402); + const body = res._jsonFn.mock.calls[0][0]; + expect(body.x402Version).toBe(2); + expect(body.accepts).toBeDefined(); + expect(body.error).toBe('Payment verification failed'); + expect(body.reason).toBe('bad sig'); + expect(next).not.toHaveBeenCalled(); + }); + + it('includes x402Version and accepts on settlement failure', async () => { + facilitator = createMockFacilitator({ + verify: vi.fn().mockResolvedValue({ isValid: true }), + settle: vi.fn().mockResolvedValue({ success: false, errorReason: 'insufficient funds', transaction: '', payer: '', network: 'base' }), + }); + + const middleware = createPaymentMiddleware({ + facilitator, + getRequirements: () => v1Requirements(), + }); + + const req = createExpressReq({ 'x-payment': makePaymentHeader() }); + const res = createExpressRes(); + const next = vi.fn(); + + await middleware(req, res, next); + + expect(res._statusFn).toHaveBeenCalledWith(402); + const body = res._jsonFn.mock.calls[0][0]; + expect(body.x402Version).toBe(2); + expect(body.accepts).toBeDefined(); + expect(body.error).toBe('Payment settlement failed'); + expect(body.reason).toBe('insufficient funds'); + expect(next).not.toHaveBeenCalled(); + }); + + it('adds supportsRefunds to extra when refundProtection is enabled', async () => { + const middleware = createPaymentMiddleware({ + facilitator, + getRequirements: () => v1Requirements(), + refundProtection: { + apiKey: 'test-key', + }, + }); + + const req = createExpressReq({}); + const res = createExpressRes(); + const next = vi.fn(); + + await middleware(req, res, next); + + const body = res._jsonFn.mock.calls[0][0]; + expect(body.accepts[0].extra).toBeDefined(); + expect(body.accepts[0].extra.supportsRefunds).toBe(true); + }); + + it('supports multi-network accepts array', async () => { + const middleware = createPaymentMiddleware({ + facilitator, + getRequirements: () => [ + v1Requirements({ network: 'base' }), + v2Requirements({ network: 'solana', amount: '500000' } as Record), + ], + }); + + const req = createExpressReq({}); + const res = createExpressRes(); + const next = vi.fn(); + + await middleware(req, res, next); + + const body = res._jsonFn.mock.calls[0][0]; + expect(body.accepts).toHaveLength(2); + expect(body.accepts[0].network).toBe('base'); + expect(body.accepts[1].network).toBe('solana'); + expect(body.accepts[1].amount).toBe('500000'); + }); +}); + +// ============ Hono: honoPaymentMiddleware ============ + +describe('honoPaymentMiddleware (Hono)', () => { + let facilitator: OpenFacilitator; + + beforeEach(() => { + facilitator = createMockFacilitator(); + }); + + it('returns 402 with x402Version 2 and accepts when no X-PAYMENT header', async () => { + const middleware = honoPaymentMiddleware({ + facilitator, + getRequirements: () => v1Requirements(), + }); + + const c = createHonoContext(undefined); + const next = vi.fn().mockResolvedValue(undefined); + + const result = await middleware(c as Parameters[0], next); + + expect(c._jsonFn).toHaveBeenCalled(); + const [body, status] = c._jsonFn.mock.calls[0]; + expect(status).toBe(402); + expect(body.x402Version).toBe(2); + expect(body.accepts).toBeDefined(); + expect(Array.isArray(body.accepts)).toBe(true); + expect(body.accepts[0]).toMatchObject({ + scheme: 'exact', + network: 'base', + amount: '1000000', + asset: '0xUSDC', + payTo: '0xRecipient', + maxTimeoutSeconds: 300, + }); + expect(body.accepts[0]).not.toHaveProperty('maxAmountRequired'); + expect(next).not.toHaveBeenCalled(); + }); + + it('normalizes v1 maxAmountRequired to v2 amount in accepts', async () => { + const middleware = honoPaymentMiddleware({ + facilitator, + getRequirements: () => v1Requirements({ maxAmountRequired: '5000000' } as Record), + }); + + const c = createHonoContext(undefined); + const next = vi.fn().mockResolvedValue(undefined); + + await middleware(c as Parameters[0], next); + + const [body] = c._jsonFn.mock.calls[0]; + expect(body.accepts[0].amount).toBe('5000000'); + expect(body.accepts[0]).not.toHaveProperty('maxAmountRequired'); + expect(body.accepts[0].maxTimeoutSeconds).toBe(300); + }); + + it('passes v2 requirements through correctly', async () => { + const middleware = honoPaymentMiddleware({ + facilitator, + getRequirements: () => v2Requirements({ amount: '9999', maxTimeoutSeconds: 600 } as Record), + }); + + const c = createHonoContext(undefined); + const next = vi.fn().mockResolvedValue(undefined); + + await middleware(c as Parameters[0], next); + + const [body] = c._jsonFn.mock.calls[0]; + expect(body.accepts[0].amount).toBe('9999'); + expect(body.accepts[0].maxTimeoutSeconds).toBe(600); + }); + + it('includes x402Version and accepts on verification failure', async () => { + facilitator = createMockFacilitator({ + verify: vi.fn().mockResolvedValue({ isValid: false, invalidReason: 'bad sig' }), + }); + + const middleware = honoPaymentMiddleware({ + facilitator, + getRequirements: () => v1Requirements(), + }); + + const c = createHonoContext(makePaymentHeader()); + const next = vi.fn().mockResolvedValue(undefined); + + await middleware(c as Parameters[0], next); + + const [body, status] = c._jsonFn.mock.calls[0]; + expect(status).toBe(402); + expect(body.x402Version).toBe(2); + expect(body.accepts).toBeDefined(); + expect(body.error).toBe('Payment verification failed'); + expect(body.reason).toBe('bad sig'); + expect(next).not.toHaveBeenCalled(); + }); + + it('includes x402Version and accepts on settlement failure', async () => { + facilitator = createMockFacilitator({ + verify: vi.fn().mockResolvedValue({ isValid: true }), + settle: vi.fn().mockResolvedValue({ success: false, errorReason: 'insufficient funds', transaction: '', payer: '', network: 'base' }), + }); + + const middleware = honoPaymentMiddleware({ + facilitator, + getRequirements: () => v1Requirements(), + }); + + const c = createHonoContext(makePaymentHeader()); + const next = vi.fn().mockResolvedValue(undefined); + + await middleware(c as Parameters[0], next); + + const [body, status] = c._jsonFn.mock.calls[0]; + expect(status).toBe(402); + expect(body.x402Version).toBe(2); + expect(body.accepts).toBeDefined(); + expect(body.error).toBe('Payment settlement failed'); + expect(body.reason).toBe('insufficient funds'); + expect(next).not.toHaveBeenCalled(); + }); + + it('adds supportsRefunds to extra when refundProtection is enabled', async () => { + const middleware = honoPaymentMiddleware({ + facilitator, + getRequirements: () => v1Requirements(), + refundProtection: { + apiKey: 'test-key', + }, + }); + + const c = createHonoContext(undefined); + const next = vi.fn().mockResolvedValue(undefined); + + await middleware(c as Parameters[0], next); + + const [body] = c._jsonFn.mock.calls[0]; + expect(body.accepts[0].extra).toBeDefined(); + expect(body.accepts[0].extra.supportsRefunds).toBe(true); + }); + + it('supports multi-network accepts array', async () => { + const middleware = honoPaymentMiddleware({ + facilitator, + getRequirements: () => [ + v1Requirements({ network: 'base' }), + v2Requirements({ network: 'solana', amount: '500000' } as Record), + ], + }); + + const c = createHonoContext(undefined); + const next = vi.fn().mockResolvedValue(undefined); + + await middleware(c as Parameters[0], next); + + const [body] = c._jsonFn.mock.calls[0]; + expect(body.accepts).toHaveLength(2); + expect(body.accepts[0].network).toBe('base'); + expect(body.accepts[1].network).toBe('solana'); + expect(body.accepts[1].amount).toBe('500000'); + }); +}); diff --git a/packages/sdk/src/middleware.ts b/packages/sdk/src/middleware.ts index ce5836e..55a6360 100644 --- a/packages/sdk/src/middleware.ts +++ b/packages/sdk/src/middleware.ts @@ -412,6 +412,27 @@ export function createPaymentMiddleware(config: PaymentMiddlewareConfig) { const rawRequirements = await config.getRequirements(req); const requirementsArray = Array.isArray(rawRequirements) ? rawRequirements : [rawRequirements]; + // Build x402 v2 accepts array once for all 402 responses (normalizes v1 → v2) + const accepts = requirementsArray.map((requirements) => { + const extra: Record = { + ...requirements.extra, + }; + if (config.refundProtection) { + extra.supportsRefunds = true; + } + + return { + scheme: requirements.scheme, + network: requirements.network, + // Normalize v1 maxAmountRequired → v2 amount + amount: 'maxAmountRequired' in requirements ? requirements.maxAmountRequired : requirements.amount, + asset: requirements.asset, + payTo: requirements.payTo, + maxTimeoutSeconds: requirements.maxTimeoutSeconds || 300, + ...(Object.keys(extra).length > 0 ? { extra } : {}), + }; + }); + // Check for X-PAYMENT header const paymentHeader = req.headers['x-payment']; const paymentString = Array.isArray(paymentHeader) ? paymentHeader[0] : paymentHeader; @@ -421,42 +442,6 @@ export function createPaymentMiddleware(config: PaymentMiddlewareConfig) { if (config.on402) { await config.on402(req, res, requirementsArray); } else { - // Build accepts array with extra metadata - const accepts = requirementsArray.map((requirements) => { - const extra: Record = { - ...requirements.extra, - }; - if (config.refundProtection) { - extra.supportsRefunds = true; - } - - // Handle both v1 and v2 payment requirements - if ('maxAmountRequired' in requirements) { - // PaymentRequirementsV1 - return { - scheme: requirements.scheme, - network: requirements.network, - maxAmountRequired: requirements.maxAmountRequired, - asset: requirements.asset, - payTo: requirements.payTo, - resource: requirements.resource || req.url, - description: requirements.description, - ...(Object.keys(extra).length > 0 ? { extra } : {}), - }; - } else { - // PaymentRequirementsV2 - return { - scheme: requirements.scheme, - network: requirements.network, - amount: requirements.amount, - asset: requirements.asset, - payTo: requirements.payTo, - maxTimeoutSeconds: requirements.maxTimeoutSeconds, - extra: { ...extra, ...requirements.extra }, - }; - } - }); - res.status(402).json({ x402Version: 2, error: 'Payment Required', @@ -490,8 +475,10 @@ export function createPaymentMiddleware(config: PaymentMiddlewareConfig) { const verifyResult = await facilitator.verify(paymentPayload, requirements); if (!verifyResult.isValid) { res.status(402).json({ + x402Version: 2, error: 'Payment verification failed', reason: verifyResult.invalidReason, + accepts, }); return; } @@ -500,8 +487,10 @@ export function createPaymentMiddleware(config: PaymentMiddlewareConfig) { const settleResult = await facilitator.settle(paymentPayload, requirements); if (!settleResult.success) { res.status(402).json({ + x402Version: 2, error: 'Payment settlement failed', reason: settleResult.errorReason, + accepts, }); return; } @@ -620,46 +609,31 @@ export function honoPaymentMiddleware(config: HonoPaymentConfig) { const rawRequirements = await config.getRequirements(c); const requirementsArray = Array.isArray(rawRequirements) ? rawRequirements : [rawRequirements]; + // Build x402 v2 accepts array once for all 402 responses (normalizes v1 → v2) + const accepts = requirementsArray.map((requirements) => { + const extra: Record = { + ...requirements.extra, + }; + if (config.refundProtection) { + extra.supportsRefunds = true; + } + + return { + scheme: requirements.scheme, + network: requirements.network, + // Normalize v1 maxAmountRequired → v2 amount + amount: 'maxAmountRequired' in requirements ? requirements.maxAmountRequired : requirements.amount, + asset: requirements.asset, + payTo: requirements.payTo, + maxTimeoutSeconds: requirements.maxTimeoutSeconds || 300, + ...(Object.keys(extra).length > 0 ? { extra } : {}), + }; + }); + // Check for X-PAYMENT header const paymentString = c.req.header('x-payment'); if (!paymentString) { - // Build accepts array with extra metadata - const accepts = requirementsArray.map((requirements) => { - const extra: Record = { - ...requirements.extra, - }; - if (config.refundProtection) { - extra.supportsRefunds = true; - } - - // Handle both v1 and v2 payment requirements - if ('maxAmountRequired' in requirements) { - // PaymentRequirementsV1 - return { - scheme: requirements.scheme, - network: requirements.network, - maxAmountRequired: requirements.maxAmountRequired, - asset: requirements.asset, - payTo: requirements.payTo, - resource: requirements.resource || c.req.url, - description: requirements.description, - ...(Object.keys(extra).length > 0 ? { extra } : {}), - }; - } else { - // PaymentRequirementsV2 - return { - scheme: requirements.scheme, - network: requirements.network, - amount: requirements.amount, - asset: requirements.asset, - payTo: requirements.payTo, - maxTimeoutSeconds: requirements.maxTimeoutSeconds, - extra: { ...extra, ...requirements.extra }, - }; - } - }); - return c.json({ x402Version: 2, error: 'Payment Required', @@ -690,8 +664,10 @@ export function honoPaymentMiddleware(config: HonoPaymentConfig) { const verifyResult = await facilitator.verify(paymentPayload, requirements); if (!verifyResult.isValid) { return c.json({ + x402Version: 2, error: 'Payment verification failed', reason: verifyResult.invalidReason, + accepts, }, 402); } @@ -699,8 +675,10 @@ export function honoPaymentMiddleware(config: HonoPaymentConfig) { const settleResult = await facilitator.settle(paymentPayload, requirements); if (!settleResult.success) { return c.json({ + x402Version: 2, error: 'Payment settlement failed', reason: settleResult.errorReason, + accepts, }, 402); } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index a49ad28..1e599cc 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -140,8 +140,14 @@ export interface PaymentRequirementsV2 { payTo: string; /** Maximum timeout in seconds */ maxTimeoutSeconds: number; + /** Resource URL being paid for */ + resource?: string; + /** Human-readable description */ + description?: string; + /** Output schema for structured responses */ + outputSchema?: Record; /** Extra data */ - extra: Record; + extra?: Record; } /** diff --git a/packages/server/src/routes/facilitator.ts b/packages/server/src/routes/facilitator.ts index eef7d39..51df8df 100644 --- a/packages/server/src/routes/facilitator.ts +++ b/packages/server/src/routes/facilitator.ts @@ -734,14 +734,14 @@ router.get('/pay/:productId', async (req: Request, res: Response) => { // Build payment requirements (x402 v2 with CAIP-2 network identifier) const caip2Network = networkToCaip2[product.network] || product.network; + const productResourceUrl = `https://${record.custom_domain || record.subdomain + '.openfacilitator.io'}/pay/${product.id}`; const paymentRequirements: Record = { scheme: 'exact', network: caip2Network, - maxAmountRequired: product.amount, + amount: product.amount, asset: product.asset, payTo: product.pay_to_address, - description: product.description || product.name, - resource: `https://${record.custom_domain || record.subdomain + '.openfacilitator.io'}/pay/${product.id}`, + maxTimeoutSeconds: 300, }; // For Solana, add fee payer @@ -807,6 +807,7 @@ router.get('/pay/:productId', async (req: Request, res: Response) => { res.status(402).json({ x402Version: 2, accepts: [paymentRequirements], + resource: { url: productResourceUrl }, error: 'Payment Required', message: product.description || product.name, requiredFields: productRequiredFields.length > 0 ? productRequiredFields : undefined, @@ -885,6 +886,7 @@ router.get('/pay/:productId', async (req: Request, res: Response) => { if (!verifyResult.valid) { res.status(402).json({ + x402Version: 2, error: 'Payment verification failed', reason: verifyResult.invalidReason, accepts: [paymentRequirements], @@ -912,6 +914,7 @@ router.get('/pay/:productId', async (req: Request, res: Response) => { if (!settleResult.success) { res.status(402).json({ + x402Version: 2, error: 'Payment settlement failed', reason: settleResult.errorReason, accepts: [paymentRequirements], @@ -1951,10 +1954,10 @@ router.get('/pay/:productId/requirements', async (req: Request, res: Response) = const paymentRequirements: Record = { scheme: 'exact', network: caip2Network, - maxAmountRequired: product.amount, + amount: product.amount, asset: product.asset, payTo: product.pay_to_address, // Payments go to user-specified address - description: product.description || product.name, + maxTimeoutSeconds: 300, }; // For Solana, we also need the fee payer (facilitator's Solana wallet pays gas) @@ -2096,11 +2099,10 @@ function buildProxyUrlPaymentRequirements( const requirements: Record = { scheme: 'exact', network: caip2Network, - maxAmountRequired: proxyUrl.price_amount, + amount: proxyUrl.price_amount, asset: proxyUrl.price_asset, payTo: proxyUrl.pay_to_address, - description: proxyUrl.name, - resource: `${facilitatorUrl}/u/${proxyUrl.slug}`, + maxTimeoutSeconds: 300, }; if (isSolana && record.encrypted_solana_private_key) { @@ -2698,8 +2700,8 @@ function generateProxyUrlPaymentPage( const deadline = Math.floor(Date.now() / 1000) + 3600; const nonce = '0x' + [...crypto.getRandomValues(new Uint8Array(32))].map(b => b.toString(16).padStart(2, '0')).join(''); - // Support both v1 (maxAmountRequired) and v2 (amount) formats - const paymentAmount = paymentRequirements.maxAmountRequired || paymentRequirements.amount; + // Support both v2 (amount) and v1 (maxAmountRequired) formats + const paymentAmount = paymentRequirements.amount || paymentRequirements.maxAmountRequired; const authorization = { from: userAddress, to: paymentRequirements.payTo, @@ -2892,6 +2894,7 @@ router.all('/u/:slug', async (req: Request, res: Response) => { res.status(402).json({ x402Version: 2, accepts: [paymentRequirements], + resource: { url: `${facilitatorUrl}/u/${proxyUrl.slug}` }, error: 'Payment Required', message: proxyUrl.name, }); @@ -2927,6 +2930,7 @@ router.all('/u/:slug', async (req: Request, res: Response) => { if (!verifyResult.valid) { res.status(402).json({ + x402Version: 2, error: 'Payment verification failed', reason: verifyResult.invalidReason, accepts: [paymentRequirements], @@ -2953,6 +2957,7 @@ router.all('/u/:slug', async (req: Request, res: Response) => { if (!settleResult.success) { res.status(402).json({ + x402Version: 2, error: 'Payment settlement failed', reason: settleResult.errorReason, accepts: [paymentRequirements], diff --git a/packages/server/src/routes/public.ts b/packages/server/src/routes/public.ts index 8a89f70..b83c795 100644 --- a/packages/server/src/routes/public.ts +++ b/packages/server/src/routes/public.ts @@ -439,9 +439,10 @@ async function getDemoRequirements(): Promise { requirements.push({ scheme: 'exact', network: 'eip155:8453', - maxAmountRequired: DEMO_PRICE, + amount: DEMO_PRICE, asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', payTo: process.env.TREASURY_BASE, + maxTimeoutSeconds: 300, resource: DEMO_RESOURCE, description: 'Demo endpoint - Base USDC ($0.10)', extra: baseFeePayer ? { feePayer: baseFeePayer } : undefined, @@ -452,9 +453,10 @@ async function getDemoRequirements(): Promise { requirements.push({ scheme: 'exact', network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', - maxAmountRequired: DEMO_PRICE, + amount: DEMO_PRICE, asset: SOLANA_USDC_MINT, payTo: process.env.TREASURY_SOLANA, + maxTimeoutSeconds: 300, resource: DEMO_RESOURCE, description: 'Demo endpoint - Solana USDC ($0.10)', extra: solanaFeePayer ? { feePayer: solanaFeePayer } : undefined, diff --git a/packages/server/src/routes/stats.ts b/packages/server/src/routes/stats.ts index ba0f383..eec7136 100644 --- a/packages/server/src/routes/stats.ts +++ b/packages/server/src/routes/stats.ts @@ -83,20 +83,22 @@ const BASE_REQUIREMENTS = { solana: { scheme: 'exact', network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // CAIP-2 Solana mainnet - maxAmountRequired: STATS_PRICE_ATOMIC, + amount: STATS_PRICE_ATOMIC, resource: `${API_URL}/stats/solana`, asset: USDC_SOLANA_MINT, payTo: SOLANA_TREASURY, + maxTimeoutSeconds: 300, description: 'OpenFacilitator Platform Statistics - $5 per request', outputSchema: OUTPUT_SCHEMA, }, base: { scheme: 'exact', network: 'eip155:8453', // CAIP-2 Base mainnet - maxAmountRequired: STATS_PRICE_ATOMIC, + amount: STATS_PRICE_ATOMIC, resource: `${API_URL}/stats/base`, asset: USDC_BASE, payTo: BASE_TREASURY, + maxTimeoutSeconds: 300, description: 'OpenFacilitator Platform Statistics - $5 per request', outputSchema: OUTPUT_SCHEMA, },