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
96 changes: 95 additions & 1 deletion x402_facilitator/test/facilitator_instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ vi.mock("../x402_fee.js", () => ({
getFacilitatorAddress: vi.fn(),
}));

import { createFacilitator, resetFacilitator } from "../facilitator_instance.js";
import { createFacilitator, resetFacilitator, getFacilitator } from "../facilitator_instance.js";
import { checkMerchantAllowance, getFeeAmount, getFacilitatorAddress } from "../x402_fee.js";

// ═══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -253,3 +253,97 @@ describe("facilitator_instance onAfterVerify hook (fee model)", () => {
expect(args.result.invalidReason).toBe("invalid_payload");
});
});

// ═══════════════════════════════════════════════════════════════
// Private key validation & error paths
// ═══════════════════════════════════════════════════════════════

describe("createFacilitator — private key validation", () => {
const originalEnv = { ...process.env };

afterEach(() => {
process.env = { ...originalEnv };
resetFacilitator();
});

it("throws when private key is missing and requirePrivateKey=true", () => {
delete process.env.FACILITATOR_WALLET_PRIVATE_KEY;

expect(() => createFacilitator(true)).toThrow(
"FACILITATOR_WALLET_PRIVATE_KEY not configured",
);
});

it("returns read-only facilitator when private key is missing and requirePrivateKey=false", () => {
delete process.env.FACILITATOR_WALLET_PRIVATE_KEY;

const facilitator = createFacilitator(false);
expect(facilitator).toBeDefined();
// Read-only facilitator was created (no throw)
});

it("throws when private key has invalid length and requirePrivateKey=true", () => {
process.env.FACILITATOR_WALLET_PRIVATE_KEY = "0xdeadbeef"; // too short

expect(() => createFacilitator(true)).toThrow(
"Invalid FACILITATOR_WALLET_PRIVATE_KEY: Expected 64 hex characters",
);
});

it("returns read-only facilitator when private key has invalid length and requirePrivateKey=false", () => {
process.env.FACILITATOR_WALLET_PRIVATE_KEY = "0xdeadbeef";

const facilitator = createFacilitator(false);
expect(facilitator).toBeDefined();
// Falls back to read-only (no throw)
});

it("normalizes private key without 0x prefix", () => {
// Valid 64-char hex without 0x prefix
process.env.FACILITATOR_WALLET_PRIVATE_KEY =
"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";

const facilitator = createFacilitator(true);
expect(facilitator).toBeDefined();
});

it("trims whitespace from private key", () => {
process.env.FACILITATOR_WALLET_PRIVATE_KEY =
" 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 ";

const facilitator = createFacilitator(true);
expect(facilitator).toBeDefined();
});
});

// ═══════════════════════════════════════════════════════════════
// Singleton pattern
// ═══════════════════════════════════════════════════════════════

describe("getFacilitator — singleton", () => {
const originalEnv = { ...process.env };

beforeEach(() => {
process.env.FACILITATOR_WALLET_PRIVATE_KEY =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
resetFacilitator();
});

afterEach(() => {
process.env = { ...originalEnv };
resetFacilitator();
});

it("returns the same instance on subsequent calls", () => {
const first = getFacilitator();
const second = getFacilitator();
expect(first).toBe(second);
});

it("creates new instance after resetFacilitator", () => {
const first = getFacilitator();
resetFacilitator();
const second = getFacilitator();
expect(first).not.toBe(second);
});
});
37 changes: 37 additions & 0 deletions x402_facilitator/test/x402_facilitator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,43 @@ describe("x402_facilitator handlers", () => {
expect(body.transaction).toBe("0xabc123");
});

it("should include extensions in settle response when present", async () => {
settlePayment.mockResolvedValue({
success: true,
payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
transaction: "0xabc123",
network: "eip155:10",
fee: { collected: true, txHash: "0xfee123" },
extensions: {
facilitatorFees: {
info: {
version: "1",
facilitatorFeePaid: "10000",
asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
model: "flat",
},
},
},
});

const event = {
httpMethod: "POST",
body: JSON.stringify({
paymentPayload: { accepted: { network: "eip155:10" } },
paymentRequirements: { amount: "1000000" },
}),
};
const result = await handleSettle(event, {});

expect(result.statusCode).toBe(200);
const body = JSON.parse(result.body);
expect(body.success).toBe(true);
expect(body.extensions).toBeDefined();
expect(body.extensions.facilitatorFees.info.version).toBe("1");
expect(body.extensions.facilitatorFees.info.facilitatorFeePaid).toBe("10000");
expect(body.extensions.facilitatorFees.info.model).toBe("flat");
});

it("should return failed settlement with error reason", async () => {
settlePayment.mockResolvedValue({
success: false,
Expand Down
18 changes: 17 additions & 1 deletion x402_facilitator/test/x402_settle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,11 @@ describe("x402_settle with mocked facilitator", () => {
const originalEnv = { ...process.env };

beforeEach(() => {
vi.clearAllMocks();
process.env.FACILITATOR_WALLET_PRIVATE_KEY =
"0x1234567890123456789012345678901234567890123456789012345678901234";
vi.clearAllMocks();
// Pin fee amount for deterministic assertions on facilitatorFeePaid
vi.spyOn(feeModule, "getFeeAmount").mockReturnValue(10000n);
});

afterEach(() => {
Expand Down Expand Up @@ -536,6 +538,16 @@ describe("x402_settle with mocked facilitator", () => {
expect(result.fee).toBeDefined();
expect(result.fee.collected).toBe(true);
expect(result.fee.txHash).toBe("0xfeetxhash123");
// Verify facilitatorFees extension (per x402 Fee Disclosure proposal #1016)
expect(result.extensions).toBeDefined();
expect(result.extensions.facilitatorFees).toBeDefined();
expect(result.extensions.facilitatorFees.info.version).toBe("1");
expect(result.extensions.facilitatorFees.info.facilitatorFeePaid).toBe("10000");
// Asset uses CAIP-19 format: {network}/erc20:{address}
expect(result.extensions.facilitatorFees.info.asset).toBe(
"eip155:11155420/erc20:0x5fd84259d66Cd46123540766Be93DFE6D43130D7",
);
expect(result.extensions.facilitatorFees.info.model).toBe("flat");
Comment on lines 541 to 550
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new assertions depend on the default FACILITATOR_FEE_AMOUNT being 10000 via getFeeAmount(). If FACILITATOR_FEE_AMOUNT is set differently in the environment running tests, this becomes flaky. Prefer explicitly setting process.env.FACILITATOR_FEE_AMOUNT in the test setup (or mocking getFeeAmount) before asserting facilitatorFeePaid.

Copilot uses AI. Check for mistakes.
expect(feeModule.collectFee).toHaveBeenCalledWith(
"0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
"eip155:11155420",
Expand Down Expand Up @@ -572,6 +584,10 @@ describe("x402_settle with mocked facilitator", () => {
expect(result.fee).toBeDefined();
expect(result.fee.collected).toBe(false);
expect(result.fee.error).toBe("insufficient_fee_allowance");
// Verify facilitatorFees extension shows 0 when fee collection failed
expect(result.extensions).toBeDefined();
expect(result.extensions.facilitatorFees.info.facilitatorFeePaid).toBe("0");
expect(result.extensions.facilitatorFees.info.model).toBe("flat");
});

it("does not collect fee when feeRequired is not set", async () => {
Expand Down
26 changes: 22 additions & 4 deletions x402_facilitator/test/x402_splitter_supported.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getSplitterCapabilities } from "../x402_splitter_supported.js";

describe("x402 Splitter /supported endpoint", () => {
const originalEnv = process.env.FACILITATOR_WALLET_PRIVATE_KEY;
const originalFixedFee = process.env.FIXED_FEE;

afterEach(() => {
// Restore original env
Expand All @@ -23,6 +24,11 @@ describe("x402 Splitter /supported endpoint", () => {
} else {
delete process.env.FACILITATOR_WALLET_PRIVATE_KEY;
}
if (originalFixedFee) {
process.env.FIXED_FEE = originalFixedFee;
} else {
delete process.env.FIXED_FEE;
}
});

test("returns supported capabilities", () => {
Expand Down Expand Up @@ -120,10 +126,21 @@ describe("x402 Splitter /supported endpoint", () => {
});
});

test("extensions array is empty (no whitelist extension)", () => {
test("extensions includes facilitatorFees for fee-aware routing (#1016)", () => {
// FIXED_FEE is read at module-load time as a top-level const (default "10000").
// This assertion relies on that default; afterEach restores FIXED_FEE to prevent leaks.
const capabilities = getSplitterCapabilities();

expect(capabilities.extensions).toEqual([]);
expect(capabilities.extensions.length).toBeGreaterThan(0);
const feesExtension = capabilities.extensions.find((e) => e.name === "facilitatorFees");
expect(feesExtension).toBeDefined();
expect(feesExtension.version).toBe("1");
expect(feesExtension.model).toBe("flat");
expect(feesExtension.asset).toBe("USDC");
expect(feesExtension.flatFee).toBe("10000");
expect(feesExtension.decimals).toBe(6);
expect(feesExtension.collection).toBe("on_chain_split");
expect(feesExtension.networks).toContain("eip155:11155420");
Comment on lines 129 to 143
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test expects flatFee to be "10000" but doesn’t set process.env.FIXED_FEE. If the env var is set differently in the test environment, this will fail. Set process.env.FIXED_FEE = "10000" within the test (and restore in afterEach) to keep it deterministic.

Copilot uses AI. Check for mistakes.
});

test("signers is empty (payments signed by payers, not facilitator)", () => {
Expand All @@ -134,10 +151,11 @@ describe("x402 Splitter /supported endpoint", () => {
expect(capabilities.signers).toEqual({});
});

test("returns empty extensions (no recipient whitelist)", () => {
test("returns facilitatorFees extension (no recipient whitelist)", () => {
const capabilities = getSplitterCapabilities();

expect(capabilities.extensions).toEqual([]);
// Has facilitatorFees but no whitelist
expect(capabilities.extensions.length).toBeGreaterThan(0);

// Explicitly verify no whitelist extension exists
const whitelistExtension = capabilities.extensions.find(
Expand Down
19 changes: 19 additions & 0 deletions x402_facilitator/test/x402_supported.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ describe("x402 /supported endpoint", () => {
expect(feeExtension.setup.spender).not.toBeNull();
});

test("includes facilitatorFees extension for fee-aware routing (#1016)", () => {
process.env.FACILITATOR_WALLET_PRIVATE_KEY =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";

const capabilities = getSupportedCapabilities();

const feesExtension = capabilities.extensions.find((e) => e.name === "facilitatorFees");
expect(feesExtension).toBeDefined();
expect(feesExtension.version).toBe("1");
expect(feesExtension.model).toBe("flat");
expect(feesExtension.asset).toBe("USDC");
expect(feesExtension.flatFee).toBe("10000");
expect(feesExtension.decimals).toBe(6);
Comment on lines +141 to +153
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts a hard-coded fee amount ("10000") but doesn’t set FACILITATOR_FEE_AMOUNT. If the test runner environment overrides that env var, this will fail. Set process.env.FACILITATOR_FEE_AMOUNT = "10000" (and restore it in afterEach) or mock getFeeAmount() for determinism.

Copilot uses AI. Check for mistakes.
expect(feesExtension.networks).toContain("eip155:10");
expect(feesExtension.networks).toContain("eip155:8453");
expect(feesExtension.networks).toContain("eip155:11155420");
expect(feesExtension.networks).toContain("eip155:84532");
});

test("omits facilitator_fee extension when private key is missing", () => {
delete process.env.FACILITATOR_WALLET_PRIVATE_KEY;
const capabilities = getSupportedCapabilities();
Expand Down
3 changes: 3 additions & 0 deletions x402_facilitator/x402_facilitator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ async function handlePaymentRequest(
if (result.fee) {
responseBody.fee = result.fee;
}
if (result.extensions) {
responseBody.extensions = result.extensions;
}
return {
statusCode: 200,
headers: CORS_HEADERS,
Expand Down
5 changes: 4 additions & 1 deletion x402_facilitator/x402_fee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ export function getFeeAmount(): bigint {
}
return parsed;
} catch {
logger.warn({ envFee }, "Invalid FACILITATOR_FEE_AMOUNT (not a valid integer), using default");
logger.warn(
{ envFee },
"Invalid FACILITATOR_FEE_AMOUNT (not a valid integer), using default",
);
return DEFAULT_FEE_AMOUNT;
}
}
Expand Down
32 changes: 31 additions & 1 deletion x402_facilitator/x402_settle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@

import { getFacilitator } from "./facilitator_instance";
import { verifyPayment } from "./x402_verify";
import { collectFee } from "./x402_fee";
import { collectFee, getFeeAmount } from "./x402_fee";
import { getChainConfig } from "./chain_utils";
import type { Address } from "viem";
import pino from "pino";

const logger = pino({ level: process.env.LOG_LEVEL || "info" });

/** Facilitator fee receipt per x402 Fee Disclosure proposal (coinbase/x402#1016) */
export interface FacilitatorFeePaid {
version: string;
facilitatorFeePaid: string;
asset: string;
model: string;
}

export interface SettleResult {
success: boolean;
payer?: string;
Expand All @@ -24,6 +33,12 @@ export interface SettleResult {
txHash?: string;
error?: string;
};
/** x402 v2 extensions (facilitatorFees receipt per #1016 proposal) */
extensions?: {
facilitatorFees?: {
info: FacilitatorFeePaid;
};
};
}

/**
Expand Down Expand Up @@ -95,6 +110,20 @@ export async function settlePayment(
);
}

// Build facilitatorFees receipt (per x402 Fee Disclosure proposal #1016)
const feeAmountStr = getFeeAmount().toString();
const chainConfig = getChainConfig(network);
const facilitatorFeesExtension: SettleResult["extensions"] = {
facilitatorFees: {
info: {
version: "1",
facilitatorFeePaid: feeResult.success ? feeAmountStr : "0",
asset: `${network}/erc20:${chainConfig.USDC_ADDRESS}`,
model: "flat",
},
},
};

return {
success: true,
payer: verifyResult.payer,
Expand All @@ -105,6 +134,7 @@ export async function settlePayment(
txHash: feeResult.txHash,
error: feeResult.error,
},
extensions: facilitatorFeesExtension,
};
}

Expand Down
1 change: 1 addition & 0 deletions x402_facilitator/x402_splitter_facilitator.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export async function handleSettle(event) {
payer: result.payer,
transaction: result.transaction,
network: result.network,
...(result.extensions ? { extensions: result.extensions } : {}),
}),
};
} else {
Expand Down
12 changes: 12 additions & 0 deletions x402_facilitator/x402_splitter_settle.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { verifySplitterPayment } from "./x402_splitter_verify.js";
// Alias for backward compatibility
const SPLITTER_ABI = EIP3009SplitterV1ABI;
const getSplitterAddress = getEIP3009SplitterAddress;
const FIXED_FEE = process.env.FIXED_FEE || "10000"; // 0.01 USDC
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces/relies on a FIXED_FEE environment variable to populate the fee receipt. The repository’s .env.example only documents FACILITATOR_FEE_AMOUNT, so it’s easy to misconfigure the splitter fee in deployments. Consider documenting FIXED_FEE (or renaming it to something more specific like SPLITTER_FIXED_FEE) and keeping it consistent across supported/verify/settle.

Copilot uses AI. Check for mistakes.

const logger = pino({ level: process.env.LOG_LEVEL || "info" });

Expand Down Expand Up @@ -222,6 +223,17 @@ export async function settleSplitterPayment(paymentPayload, paymentRequirements)
payer: buyer,
transaction: hash,
network,
// x402 Fee Disclosure receipt (coinbase/x402#1016)
extensions: {
facilitatorFees: {
info: {
version: "1",
facilitatorFeePaid: FIXED_FEE,
asset: `${network}/erc20:${usdcAddress}`,
model: "flat",
},
},
},
};
} else {
logger.warn({ hash, status: receipt.status }, "Transaction failed on-chain");
Expand Down
Loading
Loading