Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
70 changes: 60 additions & 10 deletions facilitator/src/settlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,9 +602,10 @@ export async function settleWithRouter(
}
}

// 8. Defensive balance check (verify stage should have already caught this)
// 8. Defensive balance check and fee validation (applies to ALL networks)
if (balanceChecker) {
try {
// 8.1 Check user balance
const balanceCheck = await balanceChecker.checkBalance(
signer as any, // Signer has readContract method needed for balance checks
authorization.from as `0x${string}`,
Expand Down Expand Up @@ -632,29 +633,78 @@ export async function settleWithRouter(
network: paymentRequirements.network,
payer: authorization.from,
};
} else {
logger.debug(
}

// 8.2 NEW: Validate facilitatorFee <= value (critical for ALL networks)
const fee = BigInt(settlementParams.facilitatorFee);
const paymentValue = BigInt(authorization.value);

if (fee > paymentValue) {
logger.error(
{
payer: authorization.from,
network,
balance: balanceCheck.balance,
required: balanceCheck.required,
cached: balanceCheck.cached,
facilitatorFee: settlementParams.facilitatorFee,
value: authorization.value,
ratio: (Number(fee) / Number(paymentValue)).toFixed(2),
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

Converting very large BigInt values to Number using Number(fee) and Number(paymentValue) can lose precision for values larger than Number.MAX_SAFE_INTEGER (2^53 - 1). For tokens with high decimals or large amounts, this could result in incorrect ratio calculations.

Consider using BigInt arithmetic instead:

  • Calculate (fee * 100n) / paymentValue to get the ratio as a percentage
  • Only convert to Number for the final display value

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

FIXED: Fixed BigInt precision loss by using BigInt arithmetic for ratio calculations. Changed from Number(fee)/Number(paymentValue) to (fee * 10000n) / paymentValue to calculate basis points, then only converting to Number for final display. This prevents precision loss for large token values.

Action taken: Replaced Number conversion with BigInt arithmetic: (fee * 10000n) / paymentValue for basis points calculation

},
"Facilitator fee exceeds payment amount - rejecting transaction (applies to all networks)",
);

return {
success: false,
errorReason: "FACILITATOR_FEE_EXCEEDS_VALUE" as any,
transaction: "",
network: paymentRequirements.network,
payer: authorization.from,
};
}
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The fee validation is placed after the gas estimation block (lines 491-603), but that block contains an arithmetic operation at line 499 that calculates BigInt(authorization.value) - BigInt(settlementParams.facilitatorFee). This operation will throw an underflow error when fee > value, before reaching this validation.

Additionally, this validation only runs when balanceChecker is provided. To properly fix issue #200, the fee validation should:

  1. Be moved before line 491 (before gas estimation)
  2. Run unconditionally (not depend on balanceChecker)

Otherwise, the bug can still occur when gas estimation is enabled or when balanceChecker is not provided.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

FIXED: Fixed the critical bug by moving fee validation to line 326-357 in facilitator/src/settlement.ts, which now runs BEFORE: (1) Facilitator SDK verification at line 377, (2) Commitment validation at line 399, and (3) Gas estimation arithmetic at line 595 that previously threw underflow errors. The validation is now UNCONDITIONAL and runs regardless of balanceChecker availability.

Action taken: Moved fee validation before all arithmetic operations and made it unconditional (not dependent on balanceChecker)


// 8.3 NEW: Warn if fee ratio is suspicious (> 50% for any network)
const feeRatio = paymentValue > 0n ? (Number(fee) / Number(paymentValue)) * 100 : 0;
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

Converting very large BigInt values to Number using Number(fee) and Number(paymentValue) can lose precision for values larger than Number.MAX_SAFE_INTEGER (2^53 - 1). For tokens with high decimals or large amounts, this could result in incorrect ratio calculations.

Consider using BigInt arithmetic instead:

  • Calculate (fee * 100n) / paymentValue to get the ratio as a percentage
  • Only convert to Number for the final display value

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

FIXED: Fixed BigInt precision loss in fee ratio warning calculation by using BigInt arithmetic: (fee * 10000n) / paymentValue to get basis points, then converting to Number only for final percentage display.

Action taken: Replaced Number conversion with BigInt arithmetic for fee ratio calculation in warning check

if (feeRatio > 50) {
logger.warn(
{
payer: authorization.from,
network,
facilitatorFee: settlementParams.facilitatorFee,
value: authorization.value,
feeRatio: `${feeRatio.toFixed(1)}%`,
},
"Balance check passed during settlement (defensive check)",
"High facilitator fee ratio detected - possible gas price issue (network-agnostic check)",
);
}

logger.debug(
{
payer: authorization.from,
network,
balance: balanceCheck.balance,
required: balanceCheck.required,
facilitatorFee: settlementParams.facilitatorFee,
feeRatio: `${feeRatio.toFixed(2)}%`,
cached: balanceCheck.cached,
},
"Balance check and fee validation passed",
);
} catch (error) {
// FIXED: Don't proceed with transaction if validation fails (affects ALL networks)
logger.error(
{
error,
payer: authorization.from,
network,
},
"Balance check failed during settlement, proceeding with transaction",
"Balance or fee validation failed - rejecting transaction (network-agnostic)",
);
// If balance check fails, we continue with the transaction
// This ensures settlement can still work even if balance check has issues

return {
success: false,
errorReason: "VALIDATION_FAILED" as any,
transaction: "",
network: paymentRequirements.network,
payer: authorization.from,
};
}
}

Expand Down
6 changes: 6 additions & 0 deletions facilitator/test/mocks/signers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export function createMockEvmSigner(options?: {
writeContract.mockResolvedValue(options?.writeContractResolve || "0xtxhash");
}

const waitForTransactionReceipt = vi.fn().mockResolvedValue({
status: "success",
transactionHash: options?.writeContractResolve || "0xtxhash",
});

return {
account: {
address,
Expand All @@ -61,5 +66,6 @@ export function createMockEvmSigner(options?: {
walletClient: {
writeContract,
},
waitForTransactionReceipt,
} as any;
}
258 changes: 253 additions & 5 deletions facilitator/test/unit/settlement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,23 @@ vi.mock("viem", async () => {
const actual = await vi.importActual("viem");
return {
...actual,
parseErc6492Signature: vi.fn((sig: string) => ({
signature: sig,
address: "0x0000000000000000000000000000000000000000",
data: "0x",
})),
parseErc6492Signature: vi.fn((sig: string | any) => {
// Handle both hex string and old {v, r, s} format
if (typeof sig === "string") {
return {
signature: sig,
address: "0x0000000000000000000000000000000000000000",
data: "0x",
};
} else {
// Old format {v, r, s}
return {
signature: `0x${sig.r.slice(2)}${sig.s.slice(2)}${sig.v.toString(16).padStart(2, "0")}`,
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The conversion logic sig.v.toString(16).padStart(2, "0") assumes that sig.v is a number, but in the old signature format, v could be a string like "0x1c". This would cause toString(16) to fail or produce incorrect results.

Consider handling both numeric and string hex values for the v component, or add validation to ensure the input format is as expected.

Suggested change
return {
signature: `0x${sig.r.slice(2)}${sig.s.slice(2)}${sig.v.toString(16).padStart(2, "0")}`,
const vValue =
typeof sig.v === "string"
? parseInt(sig.v, 16)
: Number(sig.v);
if (Number.isNaN(vValue)) {
throw new Error("Invalid v value in mock parseErc6492Signature");
}
const vHex = vValue.toString(16).padStart(2, "0");
return {
signature: `0x${sig.r.slice(2)}${sig.s.slice(2)}${vHex}`,

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

FIXED: Fixed the parseErc6492Signature mock to handle both numeric and string hex values for the v component. Added proper validation and parseInt for string values, with error handling for invalid v values.

Action taken: Added proper type checking and validation for sig.v parameter to handle both string and number formats

address: "0x0000000000000000000000000000000000000000",
data: "0x",
};
}
}),
};
});

Expand All @@ -50,9 +62,36 @@ vi.mock("@x402x/extensions", () => {
return {
isSettlementMode: vi.fn((pr) => !!pr.extra?.settlementRouter),
SettlementExtraError: MockSettlementExtraError,
parseSettlementExtraCore: vi.fn((extra: any) => ({
settlementRouter: extra.settlementRouter,
salt: extra.salt,
payTo: extra.payTo,
facilitatorFee: extra.facilitatorFee || "0",
hook: extra.hook,
hookData: extra.hookData,
})),
calculateCommitment: vi.fn(() => "0x0000000000000000000000000000000000000000000000000000000000000001"),
getNetworkConfig: vi.fn(() => ({
defaultAsset: {
address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
decimals: 6,
},
})),
getChain: vi.fn(() => ({ id: 84532 })),
};
});

// Mock @x402x/facilitator-sdk
vi.mock("@x402x/facilitator-sdk", () => ({
createRouterSettlementFacilitator: vi.fn(() => ({
verify: vi.fn().mockResolvedValue({
isValid: true,
invalidReason: "",
payer: "0x1234567890123456789012345678901234567890",
}),
})),
}));

describe("settlement", () => {
describe("isSettlementMode", () => {
it("should return true when settlementRouter is present", () => {
Expand Down Expand Up @@ -268,5 +307,214 @@ describe("settlement", () => {

expect(true).toBe(true); // Integration test placeholder
});

describe("fee validation (issue #200)", () => {
let mockBalanceChecker: any;

beforeEach(() => {
// Create a mock balance checker
mockBalanceChecker = {
checkBalance: vi.fn().mockResolvedValue({
hasSufficient: true,
balance: "10000000",
required: "1000000",
cached: false,
}),
};
});

it("should reject transaction when facilitatorFee exceeds value", async () => {
// Create payment with fee > value (the bug scenario)
const paymentWithHighFee = createMockPaymentPayload({
signature:
"0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890",
});
// Override the authorization value
(paymentWithHighFee.payload as any).authorization.value = "1000"; // 0.001 USDC

const requirementsWithHighFee = createMockSettlementRouterPaymentRequirements({
extra: {
settlementRouter: "0x32431D4511e061F1133520461B07eC42afF157D6",
hook: "0x0000000000000000000000000000000000000000",
hookData: "0x",
payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
facilitatorFee: "10000", // 0.01 USDC - EXCEEDS payment value
salt: "0x0000000000000000000000000000000000000000000000000000000000000001",
},
});

const result = await settleWithRouter(
signer,
paymentWithHighFee,
requirementsWithHighFee,
allowedRouters,
undefined,
undefined,
undefined,
mockBalanceChecker,
);

expect(result.success).toBe(false);
expect(result.errorReason).toBe("FACILITATOR_FEE_EXCEEDS_VALUE");
});

it("should accept transaction when facilitatorFee equals value", async () => {
// Edge case: fee == value (100% fee)
const paymentWithHighFee = createMockPaymentPayload({
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The variable name "paymentWithHighFee" is misleading in this test case because the test is verifying that fee equals value (100% fee edge case), not testing a normal payment scenario. Consider renaming to something more descriptive like "paymentWithEqualFee" or "paymentWithMaxFee" to better reflect the test scenario.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

FIXED: Renamed misleading variable name 'paymentWithHighFee' to 'paymentWithEqualFee' in the test case where fee equals value (100% fee edge case). This makes the test intent clearer.

Action taken: Renamed variable from 'paymentWithHighFee' to 'paymentWithEqualFee' for fee == value test case

signature:
"0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890",
});
(paymentWithHighFee.payload as any).authorization.value = "1000";

const requirementsWithHighFee = createMockSettlementRouterPaymentRequirements({
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The variable name "requirementsWithHighFee" is misleading in this test case because the test is verifying that fee equals value (100% fee edge case), not a high fee scenario specifically. Consider renaming to something more descriptive like "requirementsWithEqualFee" or "requirementsWithMaxFee" to better reflect the test scenario.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

FIXED: Renamed misleading variable name 'requirementsWithHighFee' to 'requirementsWithEqualFee' in the test case where fee equals value (100% fee edge case). This makes the test intent clearer.

Action taken: Renamed variable from 'requirementsWithHighFee' to 'requirementsWithEqualFee' for fee == value test case

extra: {
settlementRouter: "0x32431D4511e061F1133520461B07eC42afF157D6",
hook: "0x0000000000000000000000000000000000000000",
hookData: "0x",
payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
facilitatorFee: "1000", // Fee equals value
salt: "0x0000000000000000000000000000000000000000000000000000000000000001",
},
});

const result = await settleWithRouter(
signer,
paymentWithHighFee,
requirementsWithHighFee,
allowedRouters,
undefined,
undefined,
undefined,
mockBalanceChecker,
);

// Should pass validation (transaction may fail for other reasons but fee check passes)
// We just verify it doesn't return FACILITATOR_FEE_EXCEEDS_VALUE
expect(result.errorReason).not.toBe("FACILITATOR_FEE_EXCEEDS_VALUE");
});

it("should accept transaction when facilitatorFee is less than value", async () => {
// Normal case: fee < value
const paymentWithHighFee = createMockPaymentPayload({
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The variable name "paymentWithHighFee" is misleading in this test case because the test is verifying normal operation where fee is less than value (1% fee), not a high fee scenario. Consider renaming to something more descriptive like "paymentWithNormalFee" or "paymentPayload" to better reflect the test scenario.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

FIXED: Renamed misleading variable name 'paymentWithHighFee' to 'paymentWithNormalFee' in the test case where fee is less than value (1% fee, normal case). This makes the test intent clearer.

Action taken: Renamed variable from 'paymentWithHighFee' to 'paymentWithNormalFee' for fee < value test case

signature:
"0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890",
});
(paymentWithHighFee.payload as any).authorization.value = "1000000"; // 1 USDC

const requirementsWithHighFee = createMockSettlementRouterPaymentRequirements({
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The variable name "requirementsWithHighFee" is misleading in this test case because the test is verifying normal operation where fee is less than value (1% fee), not a high fee scenario. Consider renaming to something more descriptive like "requirementsWithNormalFee" or "paymentRequirements" to better reflect the test scenario.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

FIXED: Renamed misleading variable name 'requirementsWithHighFee' to 'requirementsWithNormalFee' in the test case where fee is less than value (1% fee, normal case). This makes the test intent clearer.

Action taken: Renamed variable from 'requirementsWithHighFee' to 'requirementsWithNormalFee' for fee < value test case

extra: {
settlementRouter: "0x32431D4511e061F1133520461B07eC42afF157D6",
hook: "0x0000000000000000000000000000000000000000",
hookData: "0x",
payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
facilitatorFee: "10000", // 0.01 USDC - 1% fee
salt: "0x0000000000000000000000000000000000000000000000000000000000000001",
},
});

const result = await settleWithRouter(
signer,
paymentWithHighFee,
requirementsWithHighFee,
allowedRouters,
undefined,
undefined,
undefined,
mockBalanceChecker,
);

// Should pass validation (transaction may fail for other reasons but fee check passes)
expect(result.errorReason).not.toBe("FACILITATOR_FEE_EXCEEDS_VALUE");
});

it("should warn when fee ratio exceeds 50%", async () => {
// Warning case: fee > 50% of value
const paymentWithHighFee = createMockPaymentPayload({
signature:
"0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890",
});
(paymentWithHighFee.payload as any).authorization.value = "10000"; // 0.01 USDC

const requirementsWithHighFee = createMockSettlementRouterPaymentRequirements({
extra: {
settlementRouter: "0x32431D4511e061F1133520461B07eC42afF157D6",
hook: "0x0000000000000000000000000000000000000000",
hookData: "0x",
payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
facilitatorFee: "6000", // 60% fee
salt: "0x0000000000000000000000000000000000000000000000000000000000000001",
},
});

const result = await settleWithRouter(
signer,
paymentWithHighFee,
requirementsWithHighFee,
allowedRouters,
undefined,
undefined,
undefined,
mockBalanceChecker,
);

// Should pass validation (transaction may fail for other reasons but fee check passes)
expect(result.errorReason).not.toBe("FACILITATOR_FEE_EXCEEDS_VALUE");
// The warning is logged, so we just ensure it doesn't fail
});

it("should reject transaction on validation errors", async () => {
// Test that validation errors reject the transaction (not continue)
mockBalanceChecker.checkBalance = vi.fn().mockRejectedValue(new Error("RPC error"));

const result = await settleWithRouter(
signer,
paymentPayload,
paymentRequirements,
allowedRouters,
undefined,
undefined,
undefined,
mockBalanceChecker,
);

expect(result.success).toBe(false);
expect(result.errorReason).toBe("VALIDATION_FAILED");
expect(mockBalanceChecker.checkBalance).toHaveBeenCalled();
});

it("should handle zero payment value safely", async () => {
// Edge case: zero value payment
const paymentWithZeroValue = createMockPaymentPayload({
signature:
"0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890",
});
(paymentWithZeroValue.payload as any).authorization.value = "0";

const requirementsWithZeroValue = createMockSettlementRouterPaymentRequirements({
extra: {
settlementRouter: "0x32431D4511e061F1133520461B07eC42afF157D6",
hook: "0x0000000000000000000000000000000000000000",
hookData: "0x",
payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
facilitatorFee: "0",
salt: "0x0000000000000000000000000000000000000000000000000000000000000001",
},
});

const result = await settleWithRouter(
signer,
paymentWithZeroValue,
requirementsWithZeroValue,
allowedRouters,
undefined,
undefined,
undefined,
mockBalanceChecker,
);

// Should not fail with fee validation error
expect(result.errorReason).not.toBe("FACILITATOR_FEE_EXCEEDS_VALUE");
Comment on lines +649 to +650
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

This test verifies that zero-value payments with zero fees don't trigger the fee validation error, but zero-value payments may be problematic for other reasons (what is being settled if nothing has value?). Consider whether zero-value payments should be rejected earlier in the flow, or if this is an intentional valid case. If zero-value is invalid, this test should expect an error.

Suggested change
// Should not fail with fee validation error
expect(result.errorReason).not.toBe("FACILITATOR_FEE_EXCEEDS_VALUE");
// Zero-value payments should be rejected by validation
expect(result.success).toBe(false);
expect(result.errorReason).toBe("VALIDATION_FAILED");

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

⚠️ WONTFIX: Zero-value payments are a valid edge case in the x402 protocol (e.g., test transactions, hook-only settlements with no token transfer). The current implementation correctly handles zero-value payments without fee validation errors. Rejecting them would break legitimate use cases.

Action taken: No change - zero-value payments are intentionally valid per x402 protocol specification

});
});
Comment on lines 408 to 696
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The test suite is missing a critical test case: when payment value is 0 but facilitatorFee > 0. This scenario should be rejected by the fee validation (fee > value), and a test should verify this behavior to ensure the edge case is properly handled.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

FIXED: Added test case for zero value payment with non-zero facilitator fee at facilitator/test/unit/settlement.test.ts:519-552. The test verifies that payments with value=0 and fee>0 are correctly rejected with FACILITATOR_FEE_EXCEEDS_VALUE error.

Action taken: Added new test case 'should reject transaction when value is 0 and facilitatorFee > 0' in the fee validation test suite

});
});
Loading