Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 4 additions & 39 deletions apps/cli/src/cli/commands/seller/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,34 +184,6 @@ export async function assertSellerPrerequisites(input: {
throw new Error('seller prerequisites not met')
}

async function isRpcReachable(rpcUrl: string, timeoutMs = 1500): Promise<boolean> {
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())
Expand Down Expand Up @@ -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'

Expand Down
40 changes: 33 additions & 7 deletions packages/node/src/seller-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -451,6 +476,7 @@ export class SellerRequestHandler {
}
return;
}
admittedSession = session;
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
165 changes: 144 additions & 21 deletions packages/node/tests/seller-response-auth-compat.test.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -32,6 +32,55 @@ function makeProvider(): Provider {
};
}

function makePaymentManager(overrides: Record<string, unknown> = {}): 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<typeof vi.fn>; hasRemoteCapability: (capability: string) => boolean };
paymentMux?: { sendNeedAuth: ReturnType<typeof vi.fn>; sendPaymentRequired: ReturnType<typeof vi.fn> };
requestId?: string;
}): Promise<ReturnType<typeof decodeFrame>[]> {
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)),
Expand All @@ -49,23 +98,8 @@ async function serveRequest(conn: {
hasRemoteCapability: (capability: string) => boolean;
}): Promise<number[]> {
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', () => {
Expand All @@ -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();
});
});