diff --git a/CHANGELOG.md b/CHANGELOG.md index cc7396c0a..ce5dc2b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Fixed +- Sellers no longer serve paid requests for free with a channel-less response signature. Paid requests now fail closed (`503 payment_unavailable`) when the payment stack is not initialized, admission requires the in-memory session and the channel store to agree (returning `402` otherwise so the buyer renegotiates), and billing plus `ResponseAuth` are bound to the channel admitted before provider execution instead of re-reading state afterwards. Seller startup also no longer silently disables payments for the whole process after a single failed RPC probe; `ANTSEED_ENABLE_SETTLEMENT=false` remains the explicit opt-out. - KBF audits now classify successful authenticated batches with no parseable final answers as unavailable instead of counting every empty answer as a model mismatch. Fully malformed sellers therefore remain `UNDETERMINED` and excluded from reference voting, while wrong numeric answers and selective omissions in otherwise parseable batches still count as discrepancies. - Phones browsing the website in "Desktop site" mode no longer download desktop installers they can't run. Mobile detection for download CTAs previously relied on viewport width alone, so a phone requesting the desktop site (which widens the layout viewport and, in Samsung Internet, spoofs an `X11; Linux` user agent) was handed the Linux AppImage. The reroute to the `/get-started` flow now also checks touch-only hardware (`pointer: coarse` + `hover: none`) and the UA-CH mobile signal — neither of which desktop-site mode changes — and platform detection treats such devices as unknown, so an installer is never resolved for them. Analytics counts these taps as `get_started` funnel entries instead of download conversions, matching the behavior. Touchscreen laptops keep a fine, hover-capable primary pointer and still get the direct download. - Seller and transport failures returned through the buyer protocol now clearly explain that the selected peer failed, suggest choosing another peer or Auto routing, preserve the seller's original response and status for diagnostics, and identify pinned-peer failures so clients can surface them immediately without retrying the same peer. Buyer-side failures, payment-required responses, and actionable request errors remain unchanged. diff --git a/apps/cli/src/cli/commands/seller/start.ts b/apps/cli/src/cli/commands/seller/start.ts index 5595ece1e..912cb3ba8 100644 --- a/apps/cli/src/cli/commands/seller/start.ts +++ b/apps/cli/src/cli/commands/seller/start.ts @@ -184,34 +184,6 @@ export async function assertSellerPrerequisites(input: { throw new Error('seller prerequisites not met') } -async function isRpcReachable(rpcUrl: string, timeoutMs = 1500): Promise { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), timeoutMs) - - try { - const response = await fetch(rpcUrl, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'eth_chainId', - params: [], - }), - signal: controller.signal, - }) - - if (!response.ok) return false - - const payload = await response.json() as { result?: unknown } - return typeof payload.result === 'string' && payload.result.startsWith('0x') - } catch { - return false - } finally { - clearTimeout(timeout) - } -} - function toUSDCBaseUnits(value: string | undefined, fallbackBaseUnits: string): string { if (value === undefined) return fallbackBaseUnits const parsed = Number.parseFloat(value.trim()) @@ -521,17 +493,10 @@ export function registerSellerStartCommand(sellerCmd: Command): void { } const settlementEnv = parseOptionalBoolEnv(process.env['ANTSEED_ENABLE_SETTLEMENT']) - let paymentsEnabled = settlementEnv ?? paymentConfig !== null - const cryptoRpcUrl = paymentConfig?.crypto?.rpcUrl - - if (paymentsEnabled && cryptoRpcUrl && settlementEnv !== true) { - const rpcUp = await isRpcReachable(cryptoRpcUrl) - if (!rpcUp) { - paymentsEnabled = false - console.log(chalk.yellow(`Payments disabled: RPC node unreachable at ${cryptoRpcUrl}`)) - console.log(chalk.dim('Start your chain node or set ANTSEED_ENABLE_SETTLEMENT=true to force-enable payments.')) - } - } + // Never auto-disable payments on a transient RPC probe failure: a seller + // without its payment stack would serve paid requests for free and sign + // channel-less ResponseAuths. ANTSEED_ENABLE_SETTLEMENT=false stays explicit. + const paymentsEnabled = settlementEnv ?? paymentConfig !== null const primaryProviderName = selectedProviderNames[0] ?? providers[0]?.name ?? 'unknown' diff --git a/packages/node/src/seller-request-handler.ts b/packages/node/src/seller-request-handler.ts index cf13b8afb..506585c5d 100644 --- a/packages/node/src/seller-request-handler.ts +++ b/packages/node/src/seller-request-handler.ts @@ -238,9 +238,31 @@ export class SellerRequestHandler { const isFreeService = isZeroTokenPricing(requestPricing) && (!unitBillingModel || isFreeUnitBillingModel(unitBillingModel)); - // Reject with 402 if no active payment session and channels client is configured. const spm = this._deps.sellerPaymentManager; - const spmAuthorized = spm?.hasSession(buyerPeerId) ?? false; + // Paid requests need the payment stack; without it, fail closed instead of + // serving for free with a channel-less ResponseAuth. + if (!isFreeService && (!spm || !this._deps.channelsClient)) { + debugWarn(`[SellerHandler] Paid request but payment infrastructure is not initialized — returning 503`); + mux.sendProxyResponse({ + requestId: request.requestId, + statusCode: 503, + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + error: { + message: 'Seller payment infrastructure is unavailable. Try again later.', + type: 'payment_unavailable', + code: 'payment_unavailable', + }, + })), + }); + return; + } + + // Reject with 402 if no active payment session and channels client is configured. + // Both the in-memory session set and the channel store must agree; the store + // row is what billing and ResponseAuth are bound to below. + const activeSession = spm?.getChannelByPeer(buyerPeerId) ?? null; + const spmAuthorized = (spm?.hasSession(buyerPeerId) ?? false) && activeSession !== null; if (this._deps.channelsClient && !spmAuthorized) { // Free services skip the payment channel handshake entirely — no 402, // no ReserveAuth, no on-chain reserve. @@ -286,8 +308,11 @@ export class SellerRequestHandler { // Check budget before routing — reject if buyer hasn't authorized enough. // Free requests must not be blocked by an existing exhausted/blocked paid // payment channel for the same buyer. + // The channel admitted here is what the rest of the request is bound to; + // never re-read it after the provider call. + let admittedSession: { sessionId: string; authMax?: string | null } | null = null; if (spm && !isFreeService) { - const initialSession = spm.getChannelByPeer(buyerPeerId); + const initialSession = activeSession; if (initialSession) { // Drain any in-flight SpendingAuth processing (e.g. an on-chain top-up // that has queued later auths behind its per-buyer mutex) so we don't @@ -451,6 +476,7 @@ export class SellerRequestHandler { } return; } + admittedSession = session; } } @@ -481,7 +507,7 @@ export class SellerRequestHandler { // Hold the channel open for the whole billable span — provider call, // spend recording, and NeedAuth — so a buyer-requested close can't land // between serving the request and claiming its cost. - const isBillable = !isFreeService && (spm?.hasSession(buyerPeerId) ?? false); + const isBillable = admittedSession !== null; if (isBillable) spm!.beginBillableRequest(buyerPeerId); this.adjustProviderLoad(provider.name, 1); try { @@ -602,7 +628,7 @@ export class SellerRequestHandler { // Record spend and send NeedAuth with cost data after every request. // The buyer validates the cost independently and responds with SpendingAuth. - if (!isFreeService && spm?.hasSession(buyerPeerId)) { + if (spm && admittedSession) { const usage = responseUsage; const tokenCostUsdc = computeCostUsdc( usage.freshInputTokens, @@ -611,7 +637,7 @@ export class SellerRequestHandler { usage.cachedInputTokens, ); const costUsdc = tokenCostUsdc + unitCostUsdc; - const session = spm.getChannelByPeer(buyerPeerId); + const session = admittedSession; if (session) { spm.recordSpend(session.sessionId, costUsdc); const cumulativeSpend = spm.getCumulativeSpend(session.sessionId); @@ -647,7 +673,7 @@ export class SellerRequestHandler { const buyerSupportsResponseAuth = conn.hasRemoteCapability(CONNECTION_CAPABILITY_RESPONSE_AUTH_V1); if (responseForAuth && buyerSupportsResponseAuth) { - const channelId = spm?.getChannelByPeer(buyerPeerId)?.sessionId ?? null; + const channelId = admittedSession?.sessionId ?? null; this._sendResponseAuthBestEffort( verificationMux, responseAuthRequest, diff --git a/packages/node/tests/seller-response-auth-compat.test.ts b/packages/node/tests/seller-response-auth-compat.test.ts index 32023bf71..e55c24389 100644 --- a/packages/node/tests/seller-response-auth-compat.test.ts +++ b/packages/node/tests/seller-response-auth-compat.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it, vi } from 'vitest'; import { identityFromPrivateKeyHex } from '../src/p2p/identity.js'; import { decodeFrame } from '../src/p2p/message-protocol.js'; -import { encodeHttpRequest } from '../src/proxy/request-codec.js'; +import { decodeHttpResponse, encodeHttpRequest } from '../src/proxy/request-codec.js'; import { SellerRequestHandler } from '../src/seller-request-handler.js'; import { CONNECTION_CAPABILITY_RESPONSE_AUTH_V1, MessageType, } from '../src/types/protocol.js'; import type { Provider } from '../src/interfaces/seller-provider.js'; -import { VerificationMux } from '../src/verification/index.js'; +import { decodeResponseAuth, VerificationMux } from '../src/verification/index.js'; -function makeProvider(): Provider { +function makeProvider(inputUsdPerMillion = 0, outputUsdPerMillion = 0): Provider { return { name: 'test-provider', services: ['test-model'], pricing: { - defaults: { inputUsdPerMillion: 0, outputUsdPerMillion: 0 }, + defaults: { inputUsdPerMillion, outputUsdPerMillion }, }, maxConcurrency: 1, async handleRequest(req) { @@ -32,6 +32,55 @@ function makeProvider(): Provider { }; } +function makePaymentManager(overrides: Record = {}): any { + return { + hasSession: vi.fn(() => true), + getChannelByPeer: vi.fn(() => ({ sessionId: 'channel-1', authMax: '1000000' })), + getPaymentRequirements: vi.fn(() => ({ minBudgetPerRequest: '10000', suggestedAmount: '100000' })), + waitForPendingAuths: vi.fn(async () => {}), + getAcceptedCumulative: vi.fn(() => 0n), + getCumulativeSpend: vi.fn(() => 0n), + getEffectiveReserveMax: vi.fn(() => 1_000_000n), + isChannelBlocked: vi.fn(() => false), + awaitAcceptedAtLeast: vi.fn(async () => false), + settleSession: vi.fn(async () => {}), + beginBillableRequest: vi.fn(), + endBillableRequest: vi.fn(), + recordSpend: vi.fn(), + ...overrides, + }; +} + +async function sendRequest(input: { + handler: SellerRequestHandler; + conn: { send: ReturnType; hasRemoteCapability: (capability: string) => boolean }; + paymentMux?: { sendNeedAuth: ReturnType; sendPaymentRequired: ReturnType }; + requestId?: string; +}): Promise[]> { + const paymentMux = input.paymentMux ?? { sendNeedAuth: vi.fn(), sendPaymentRequired: vi.fn() }; + const verificationMux = new VerificationMux(input.conn as any); + const { mux } = input.handler.handleConnection( + input.conn as any, + '22'.repeat(20), + paymentMux as any, + verificationMux, + ); + + await mux.handleFrame({ + type: MessageType.HttpRequest, + messageId: 1, + payload: encodeHttpRequest({ + requestId: input.requestId ?? 'req-response-auth-compat', + method: 'POST', + path: '/v1/messages', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ model: 'test-model' })), + }), + }); + + return input.conn.send.mock.calls.map(([frame]) => decodeFrame(frame)); +} + function makeHandler(): SellerRequestHandler { return new SellerRequestHandler({ identity: identityFromPrivateKeyHex('11'.repeat(32)), @@ -49,23 +98,8 @@ async function serveRequest(conn: { hasRemoteCapability: (capability: string) => boolean; }): Promise { const handler = makeHandler(); - const paymentMux = { sendNeedAuth: vi.fn(), sendPaymentRequired: vi.fn() } as any; - const verificationMux = new VerificationMux(conn as any); - const { mux } = handler.handleConnection(conn as any, '22'.repeat(20), paymentMux, verificationMux); - - await mux.handleFrame({ - type: MessageType.HttpRequest, - messageId: 1, - payload: encodeHttpRequest({ - requestId: 'req-response-auth-compat', - method: 'POST', - path: '/v1/messages', - headers: { 'content-type': 'application/json' }, - body: new TextEncoder().encode(JSON.stringify({ model: 'test-model' })), - }), - }); - - return conn.send.mock.calls.map(([frame]) => decodeFrame(frame)!.message.type); + const frames = await sendRequest({ handler, conn }); + return frames.map((frame) => frame!.message.type); } describe('Seller response auth compatibility', () => { @@ -92,4 +126,93 @@ describe('Seller response auth compatibility', () => { expect(frameTypes).toContain(MessageType.HttpResponse); expect(frameTypes).toContain(MessageType.VerificationResponseAuth); }); + + it('rejects paid requests when the payment manager and channel store disagree', async () => { + const provider = makeProvider(1, 1); + provider.handleRequest = vi.fn(provider.handleRequest); + const sellerPaymentManager = makePaymentManager({ + getChannelByPeer: vi.fn(() => null), + }); + const handler = new SellerRequestHandler({ + identity: identityFromPrivateKeyHex('11'.repeat(32)), + providers: [provider], + sellerPaymentManager, + sessionTracker: null, + channelsClient: {} as any, + announcer: null, + emit: () => false, + }); + const conn = { + send: vi.fn(), + hasRemoteCapability: vi.fn(() => true), + }; + const paymentMux = { sendNeedAuth: vi.fn(), sendPaymentRequired: vi.fn() }; + + const frames = await sendRequest({ handler, conn, paymentMux }); + + const httpFrame = frames.find((frame) => frame?.message.type === MessageType.HttpResponse)!; + expect(decodeHttpResponse(httpFrame.message.payload).statusCode).toBe(402); + expect(paymentMux.sendPaymentRequired).toHaveBeenCalledOnce(); + expect(provider.handleRequest).not.toHaveBeenCalled(); + expect(frames.some((frame) => frame?.message.type === MessageType.VerificationResponseAuth)).toBe(false); + }); + + it('rejects paid requests when payment infrastructure is unavailable', async () => { + const provider = makeProvider(1, 1); + provider.handleRequest = vi.fn(provider.handleRequest); + const handler = new SellerRequestHandler({ + identity: identityFromPrivateKeyHex('11'.repeat(32)), + providers: [provider], + sellerPaymentManager: null, + sessionTracker: null, + channelsClient: null, + announcer: null, + emit: () => false, + }); + const conn = { + send: vi.fn(), + hasRemoteCapability: vi.fn(() => true), + }; + + const frames = await sendRequest({ handler, conn }); + + const httpFrame = frames.find((frame) => frame?.message.type === MessageType.HttpResponse)!; + const response = decodeHttpResponse(httpFrame.message.payload); + expect(response.statusCode).toBe(503); + expect(JSON.parse(new TextDecoder().decode(response.body)).error.code).toBe('payment_unavailable'); + expect(provider.handleRequest).not.toHaveBeenCalled(); + expect(frames.some((frame) => frame?.message.type === MessageType.VerificationResponseAuth)).toBe(false); + }); + + it('binds response auth to the channel admitted before provider execution', async () => { + const provider = makeProvider(1, 1); + const activeChannel = { sessionId: 'channel-1', authMax: '1000000' }; + const sellerPaymentManager = makePaymentManager({ + getChannelByPeer: vi.fn() + .mockReturnValueOnce(activeChannel) + .mockReturnValueOnce(activeChannel) + .mockReturnValue(null), + }); + const handler = new SellerRequestHandler({ + identity: identityFromPrivateKeyHex('11'.repeat(32)), + providers: [provider], + sellerPaymentManager, + sessionTracker: null, + channelsClient: {} as any, + announcer: null, + emit: () => false, + }); + const conn = { + send: vi.fn(), + hasRemoteCapability: vi.fn((capability: string) => capability === CONNECTION_CAPABILITY_RESPONSE_AUTH_V1), + }; + + const frames = await sendRequest({ handler, conn }); + + const authFrame = frames.find((frame) => frame?.message.type === MessageType.VerificationResponseAuth)!; + expect(decodeResponseAuth(authFrame.message.payload).channelId).toBe('channel-1'); + expect(sellerPaymentManager.recordSpend).toHaveBeenCalledWith('channel-1', expect.any(BigInt)); + expect(sellerPaymentManager.beginBillableRequest).toHaveBeenCalledOnce(); + expect(sellerPaymentManager.endBillableRequest).toHaveBeenCalledOnce(); + }); });