Skip to content
Open
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
38 changes: 20 additions & 18 deletions typescript/packages/core/src/server/x402ResourceServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,25 +688,24 @@ export class x402ResourceServer {
}

// Find the matching supported kind from facilitator
const supportedKind = this.getSupportedKind(
x402Version,
resourceConfig.network,
SchemeNetworkServer.scheme,
);

if (!supportedKind) {
// If facilitator not yet initialized, skip validation so 402 can be emitted on cold start.
// Facilitator validation is only required at verify/settle time.
const isInitialized = this.supportedResponsesMap.size > 0;
const supportedKind = isInitialized
? this.getSupportedKind(x402Version, resourceConfig.network, SchemeNetworkServer.scheme)
: undefined;

if (isInitialized && !supportedKind) {
throw new Error(
`Facilitator does not support ${SchemeNetworkServer.scheme} on ${resourceConfig.network}. ` +
`Make sure to call initialize() to fetch supported kinds from facilitators.`,
);
}

// Get facilitator extensions for this combination
const facilitatorExtensions = this.getFacilitatorExtensions(
x402Version,
resourceConfig.network,
SchemeNetworkServer.scheme,
);
// Get facilitator extensions for this combination (empty if not yet initialized)
const facilitatorExtensions = isInitialized
? this.getFacilitatorExtensions(x402Version, resourceConfig.network, SchemeNetworkServer.scheme)
: undefined;

// Parse the price using the scheme's price parser
const parsedPrice = await SchemeNetworkServer.parsePrice(
Expand All @@ -729,11 +728,14 @@ export class x402ResourceServer {
};

// Delegate to the implementation for scheme-specific enhancements
const requirement = await SchemeNetworkServer.enhancePaymentRequirements(
baseRequirements,
supportedKind,
facilitatorExtensions,
);
// If facilitator not yet initialized, skip enhancement and use base requirements directly
const requirement = isInitialized
? await SchemeNetworkServer.enhancePaymentRequirements(
baseRequirements,
supportedKind!,
facilitatorExtensions,
)
: baseRequirements;

requirements.push(requirement);
return requirements;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,39 @@ describe("x402ResourceServer", () => {
});

describe("buildPaymentRequirements", () => {
it("should build base requirements without facilitator init (cold start)", async () => {
// Test that buildPaymentRequirements works without calling initialize()
// This enables 402 responses on cold starts (serverless/edge environments)
const mockClient = new MockFacilitatorClient(
buildSupportedResponse({
kinds: [{ x402Version: 2, scheme: "test-scheme", network: "test:network" as Network }],
}),
);
const server = new x402ResourceServer(mockClient);
const mockScheme = new MockSchemeNetworkServer("test-scheme", {
amount: "1000000",
asset: "USDC",
extra: {},
});
server.register("test:network" as Network, mockScheme);
// Do NOT call server.initialize() - simulating cold start

const requirements = await server.buildPaymentRequirements({
scheme: "test-scheme",
payTo: "recipient_address",
price: "$1.00",
network: "test:network" as Network,
});

// Should return base requirements without facilitator-specific enhancements
expect(requirements).toHaveLength(1);
expect(requirements[0].scheme).toBe("test-scheme");
expect(requirements[0].payTo).toBe("recipient_address");
expect(requirements[0].amount).toBe("1000000");
// enhancePaymentRequirements should NOT have been called (no facilitator init)
expect(mockScheme.enhanceCalls).toHaveLength(0);
});

it("should build requirements from ResourceConfig", async () => {
const mockClient = new MockFacilitatorClient(
buildSupportedResponse({
Expand Down
33 changes: 3 additions & 30 deletions typescript/packages/http/hono/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,6 @@ let mockProcessSettlement: ReturnType<typeof vi.fn>;
let mockRegisterPaywallProvider: ReturnType<typeof vi.fn>;
let mockRequiresPayment: ReturnType<typeof vi.fn>;

type PaymentVerifiedResult = Extract<HTTPProcessResult, { type: "payment-verified" }>;
type MockHTTPProcessResult =
| Exclude<HTTPProcessResult, PaymentVerifiedResult>
| (Omit<PaymentVerifiedResult, "cancellationDispatcher"> & {
cancellationDispatcher?: PaymentVerifiedResult["cancellationDispatcher"];
});

/**
* Creates a mock payment cancellation dispatcher.
*
* @returns Mock payment cancellation dispatcher.
*/
function createMockPaymentCancellationDispatcher(): PaymentVerifiedResult["cancellationDispatcher"] {
return {
cancel: vi.fn().mockResolvedValue(undefined),
} as unknown as PaymentVerifiedResult["cancellationDispatcher"];
}

vi.mock("@x402/core/server", () => ({
SETTLEMENT_OVERRIDES_HEADER: "Settlement-Overrides",
FacilitatorResponseError: class FacilitatorResponseError extends Error {
Expand Down Expand Up @@ -99,7 +81,6 @@ vi.mock("@x402/core/server", () => ({
registerExtension: vi.fn(),
},
})),
checkIfBazaarNeeded: vi.fn().mockReturnValue(false),
}));

// --- Mock Factories ---
Expand All @@ -110,7 +91,7 @@ vi.mock("@x402/core/server", () => ({
* @param settlementResult - Result to return from processSettlement.
*/
function setupMockHttpServer(
processResult: MockHTTPProcessResult,
processResult: HTTPProcessResult,
settlementResult:
| { success: true; headers: Record<string, string> }
| {
Expand All @@ -123,15 +104,7 @@ function setupMockHttpServer(
headers: {},
},
): void {
const normalizedResult =
processResult.type === "payment-verified"
? {
...processResult,
cancellationDispatcher:
processResult.cancellationDispatcher ?? createMockPaymentCancellationDispatcher(),
}
: processResult;
mockProcessHTTPRequest.mockResolvedValue(normalizedResult);
mockProcessHTTPRequest.mockResolvedValue(processResult);
mockProcessSettlement.mockResolvedValue(settlementResult);
}

Expand Down Expand Up @@ -475,7 +448,7 @@ describe("paymentMiddleware", () => {
);
mockProcessHTTPRequest.mockResolvedValue({ type: "no-payment-required" });

const middleware = paymentMiddleware(mockRoutes, {} as unknown as x402ResourceServer);
const middleware = paymentMiddleware(mockRoutes, {} as unknown as x402ResourceServer, undefined, undefined, true);
const next = vi.fn().mockResolvedValue(undefined);

await middleware(createMockContext(), next);
Expand Down
48 changes: 26 additions & 22 deletions typescript/packages/http/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
getFacilitatorResponseError,
SETTLEMENT_OVERRIDES_HEADER,
SettlementOverrides,
checkIfBazaarNeeded,
} from "@x402/core/server";
import { SchemeNetworkServer, Network } from "@x402/core/types";
import { Context, MiddlewareHandler } from "hono";
Expand All @@ -27,6 +26,24 @@ export function setSettlementOverrides(c: Context, overrides: SettlementOverride
c.header(SETTLEMENT_OVERRIDES_HEADER, JSON.stringify(overrides));
}

/**
* Check if any routes in the configuration declare bazaar extensions
*
* @param routes - Route configuration
* @returns True if any route has extensions.bazaar defined
*/
function checkIfBazaarNeeded(routes: RoutesConfig): boolean {
// Handle single route config
if ("accepts" in routes) {
return !!(routes.extensions && "bazaar" in routes.extensions);
}

// Handle multiple routes
return Object.values(routes).some(routeConfig => {
return !!(routeConfig.extensions && "bazaar" in routeConfig.extensions);
});
}

/**
* Configuration for registering a payment scheme with a specific network
*/
Expand Down Expand Up @@ -61,7 +78,7 @@ function facilitatorErrorResponse(c: Context, error: FacilitatorResponseError):
* @param httpServer - Pre-configured x402HTTPResourceServer instance
* @param paywallConfig - Optional configuration for the built-in paywall UI
* @param paywall - Optional custom paywall provider (overrides default)
* @param syncFacilitatorOnStart - Whether to sync with the facilitator on startup (defaults to true)
* @param syncFacilitatorOnStart - Whether to sync with the facilitator on startup (defaults to false). Set to true only if your deployment environment guarantees warm instances (e.g., long-running servers). For serverless/edge environments (Cloudflare Workers, AWS Lambda), keep false to avoid cold-start timeouts.
* @returns Hono middleware handler
*
* @example
Expand All @@ -81,7 +98,7 @@ export function paymentMiddlewareFromHTTPServer(
httpServer: x402HTTPResourceServer,
paywallConfig?: PaywallConfig,
paywall?: PaywallProvider,
syncFacilitatorOnStart: boolean = true,
syncFacilitatorOnStart: boolean = false,
): MiddlewareHandler {
// Register custom paywall provider if provided
if (paywall) {
Expand Down Expand Up @@ -192,29 +209,16 @@ export function paymentMiddlewareFromHTTPServer(

case "payment-verified":
// Payment is valid, need to wrap response for settlement
const { cancellationDispatcher, paymentPayload, paymentRequirements, declaredExtensions } =
result;
const { paymentPayload, paymentRequirements, declaredExtensions } = result;

// Proceed to the next middleware or route handler
try {
await next();
} catch (error) {
await cancellationDispatcher.cancel({
reason: "handler_threw",
error,
});
throw error;
}
await next();

// Get the current response
let res = c.res;

// If the response from the protected route is >= 400, do not settle payment
if (res.status >= 400) {
await cancellationDispatcher.cancel({
reason: "handler_failed",
responseStatus: res.status,
});
return;
}

Expand Down Expand Up @@ -282,7 +286,7 @@ export function paymentMiddlewareFromHTTPServer(
* @param server - Pre-configured x402ResourceServer instance
* @param paywallConfig - Optional configuration for the built-in paywall UI
* @param paywall - Optional custom paywall provider (overrides default)
* @param syncFacilitatorOnStart - Whether to sync with the facilitator on startup (defaults to true)
* @param syncFacilitatorOnStart - Whether to sync with the facilitator on startup (defaults to false). Set to true only if your deployment environment guarantees warm instances (e.g., long-running servers). For serverless/edge environments (Cloudflare Workers, AWS Lambda), keep false to avoid cold-start timeouts.
* @returns Hono middleware handler
*
* @example
Expand All @@ -300,7 +304,7 @@ export function paymentMiddleware(
server: x402ResourceServer,
paywallConfig?: PaywallConfig,
paywall?: PaywallProvider,
syncFacilitatorOnStart: boolean = true,
syncFacilitatorOnStart: boolean = false,
): MiddlewareHandler {
// Create the x402 HTTP server instance with the resource server
const httpServer = new x402HTTPResourceServer(server, routes);
Expand All @@ -324,7 +328,7 @@ export function paymentMiddleware(
* @param schemes - Optional array of scheme registrations for server-side payment processing
* @param paywallConfig - Optional configuration for the built-in paywall UI
* @param paywall - Optional custom paywall provider (overrides default)
* @param syncFacilitatorOnStart - Whether to sync with the facilitator on startup (defaults to true)
* @param syncFacilitatorOnStart - Whether to sync with the facilitator on startup (defaults to false). Set to true only if your deployment environment guarantees warm instances (e.g., long-running servers). For serverless/edge environments (Cloudflare Workers, AWS Lambda), keep false to avoid cold-start timeouts.
* @returns Hono middleware handler
*
* @example
Expand All @@ -345,7 +349,7 @@ export function paymentMiddlewareFromConfig(
schemes?: SchemeRegistration[],
paywallConfig?: PaywallConfig,
paywall?: PaywallProvider,
syncFacilitatorOnStart: boolean = true,
syncFacilitatorOnStart: boolean = false,
): MiddlewareHandler {
const ResourceServer = new x402ResourceServer(facilitatorClients);

Expand Down
Loading