Skip to content

Commit c298979

Browse files
Fix: bug(facilitator-sdk): facilitatorFee 校验仅支持 hex,导致动态 fee 流程失败 (#203)
* Fix: bug(facilitator-sdk): facilitatorFee 校验仅支持 hex,导致动态 fee 流程失败 * Apply changes from Holon --------- Co-authored-by: holonbot[bot] <250454749+holonbot[bot]@users.noreply.github.com>
1 parent 03b8147 commit c298979

File tree

3 files changed

+248
-4
lines changed

3 files changed

+248
-4
lines changed

typescript/packages/facilitator-sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export {
5050
isValidHex,
5151
isValid32ByteHex,
5252
isValid256BitHex,
53+
isValidFacilitatorFee,
5354
validateSettlementRouter,
5455
validateSettlementExtra,
5556
validateNetwork,

typescript/packages/facilitator-sdk/src/validation.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,55 @@ export function isValid256BitHex(hex: string): boolean {
3535
return /^0x[a-fA-F0-9]{1,64}$/.test(hex);
3636
}
3737

38+
/**
39+
* Check if a string is a valid facilitator fee amount.
40+
* Accepts both decimal (atomic units) and hex formats for compatibility.
41+
* Ensures the value is non-negative and fits within uint256.
42+
*
43+
* @param fee - The fee amount to validate (decimal or hex string)
44+
* @returns true if the fee is valid, false otherwise
45+
*
46+
* @example
47+
* ```typescript
48+
* isValidFacilitatorFee("10000") // true - decimal
49+
* isValidFacilitatorFee("0x2710") // true - hex (10000)
50+
* isValidFacilitatorFee("0x0") // true - zero
51+
* isValidFacilitatorFee("-100") // false - negative
52+
* isValidFacilitatorFee("abc") // false - non-numeric
53+
* isValidFacilitatorFee("0xXYZ") // false - invalid hex
54+
* ```
55+
*/
56+
export function isValidFacilitatorFee(fee: string): boolean {
57+
if (!fee || typeof fee !== "string") {
58+
return false;
59+
}
60+
61+
// Check if it's a hex string (0x prefix)
62+
if (fee.startsWith("0x") || fee.startsWith("0X")) {
63+
// Must be valid hex with 1-64 hex digits (uint256 max)
64+
return /^0[xX][a-fA-F0-9]{1,64}$/.test(fee);
65+
}
66+
67+
// Check if it's a decimal string (atomic units)
68+
// Must be one or more digits, no sign or decimal point
69+
if (!/^\d+$/.test(fee)) {
70+
return false;
71+
}
72+
73+
// Ensure it fits within uint256 (max value is 2^256 - 1)
74+
try {
75+
const value = BigInt(fee);
76+
const MAX_UINT256 = BigInt(
77+
"115792089237316195423570985008687907853269984665640564039457584007913129639935",
78+
);
79+
80+
// Value must be non-negative and must not exceed the uint256 maximum
81+
return value >= 0n && value <= MAX_UINT256;
82+
} catch {
83+
return false;
84+
}
85+
}
86+
3887
/**
3988
* Validate SettlementRouter address against allowed list
4089
*/
@@ -105,8 +154,10 @@ export function validateSettlementExtra(extra: unknown): SettlementExtraCore {
105154
if (!e.facilitatorFee || typeof e.facilitatorFee !== "string") {
106155
throw new FacilitatorValidationError("Missing or invalid facilitatorFee");
107156
}
108-
if (!isValid256BitHex(e.facilitatorFee)) {
109-
throw new FacilitatorValidationError("Facilitator fee must be a valid hex number");
157+
if (!isValidFacilitatorFee(e.facilitatorFee)) {
158+
throw new FacilitatorValidationError(
159+
"Facilitator fee must be a valid number (decimal atomic units or hex format)",
160+
);
110161
}
111162

112163
if (!e.hook || typeof e.hook !== "string") {

typescript/packages/facilitator-sdk/test/validation.test.ts

Lines changed: 194 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isValidHex,
99
isValid32ByteHex,
1010
isValid256BitHex,
11+
isValidFacilitatorFee,
1112
validateSettlementRouter,
1213
validateSettlementExtra,
1314
validateNetwork,
@@ -81,6 +82,110 @@ describe("validation utilities", () => {
8182
});
8283
});
8384

85+
describe("isValidFacilitatorFee", () => {
86+
describe("decimal format (atomic units)", () => {
87+
it("should return true for valid decimal strings", () => {
88+
expect(isValidFacilitatorFee("0")).toBe(true);
89+
expect(isValidFacilitatorFee("10000")).toBe(true);
90+
expect(isValidFacilitatorFee("1000000")).toBe(true); // 1 USDC (6 decimals)
91+
expect(isValidFacilitatorFee("1000000000000000000")).toBe(true); // 1 ETH (18 decimals)
92+
expect(isValidFacilitatorFee("1")).toBe(true);
93+
expect(isValidFacilitatorFee("123456789")).toBe(true);
94+
});
95+
96+
it("should return false for non-numeric decimal strings", () => {
97+
expect(isValidFacilitatorFee("abc")).toBe(false);
98+
expect(isValidFacilitatorFee("100abc")).toBe(false);
99+
expect(isValidFacilitatorFee("12.34")).toBe(false); // decimal point not allowed
100+
expect(isValidFacilitatorFee("-100")).toBe(false); // negative sign not allowed
101+
expect(isValidFacilitatorFee("+100")).toBe(false); // plus sign not allowed
102+
});
103+
104+
it("should return false for decimal values that exceed uint256", () => {
105+
// A value with more than 78 digits exceeds uint256 max (2^256-1 ≈ 1.16e77)
106+
const tooLarge = "1" + "0".repeat(78);
107+
expect(isValidFacilitatorFee(tooLarge)).toBe(false);
108+
109+
// Test with an even larger number
110+
const wayTooLarge = "9".repeat(100);
111+
expect(isValidFacilitatorFee(wayTooLarge)).toBe(false);
112+
});
113+
114+
it("should accept values within uint256 range", () => {
115+
// Max uint256 is approximately 1.16e77, so 77-78 digits is the boundary
116+
expect(isValidFacilitatorFee("1" + "0".repeat(76))).toBe(true);
117+
expect(isValidFacilitatorFee("115792089237316195423570985008687907853269984665640564039457584007913129639935")).toBe(true); // 2^256 - 1
118+
});
119+
});
120+
121+
describe("hex format", () => {
122+
it("should return true for valid hex strings", () => {
123+
expect(isValidFacilitatorFee("0x0")).toBe(true);
124+
expect(isValidFacilitatorFee("0x1")).toBe(true);
125+
expect(isValidFacilitatorFee("0x2710")).toBe(true); // 10000 in decimal
126+
expect(isValidFacilitatorFee("0x186A0")).toBe(true); // 100000 in decimal (0.1 USDC)
127+
expect(isValidFacilitatorFee("0x" + "FF".repeat(32))).toBe(true);
128+
expect(isValidFacilitatorFee("0xFF")).toBe(true);
129+
expect(isValidFacilitatorFee("0xabcdef")).toBe(true);
130+
expect(isValidFacilitatorFee("0xABCDEF")).toBe(true);
131+
});
132+
133+
it("should return false for invalid hex strings", () => {
134+
expect(isValidFacilitatorFee("0x" + "FF".repeat(33))).toBe(false); // > 256 bits
135+
expect(isValidFacilitatorFee("0xGG")).toBe(false);
136+
expect(isValidFacilitatorFee("0x")).toBe(false); // empty hex
137+
});
138+
139+
it("should accept odd-length hex strings", () => {
140+
expect(isValidFacilitatorFee("0x123")).toBe(true); // odd-length hex is valid numeric input
141+
expect(isValidFacilitatorFee("0x1")).toBe(true); // single digit
142+
expect(isValidFacilitatorFee("0xFFF")).toBe(true); // 4095
143+
});
144+
});
145+
146+
describe("edge cases", () => {
147+
it("should return false for empty string", () => {
148+
expect(isValidFacilitatorFee("")).toBe(false);
149+
});
150+
151+
it("should return false for non-string types", () => {
152+
expect(isValidFacilitatorFee(null as unknown as string)).toBe(false);
153+
expect(isValidFacilitatorFee(undefined as unknown as string)).toBe(false);
154+
expect(isValidFacilitatorFee(10000 as unknown as string)).toBe(false);
155+
});
156+
157+
it("should handle uppercase 0X prefix", () => {
158+
expect(isValidFacilitatorFee("0X2710")).toBe(true);
159+
expect(isValidFacilitatorFee("0XABCDEF")).toBe(true);
160+
});
161+
162+
it("should return false for malformed inputs", () => {
163+
expect(isValidFacilitatorFee("10000 ")).toBe(false); // trailing space
164+
expect(isValidFacilitatorFee(" 10000")).toBe(false); // leading space
165+
expect(isValidFacilitatorFee("0x 2710")).toBe(false); // space after prefix
166+
expect(isValidFacilitatorFee("0x2710 ")).toBe(false); // trailing space
167+
});
168+
});
169+
170+
describe("cross-format equivalence", () => {
171+
it("should accept equivalent values in both formats", () => {
172+
// 10000 in decimal and hex
173+
expect(isValidFacilitatorFee("10000")).toBe(true);
174+
expect(isValidFacilitatorFee("0x2710")).toBe(true);
175+
176+
// 0 in both formats
177+
expect(isValidFacilitatorFee("0")).toBe(true);
178+
expect(isValidFacilitatorFee("0x0")).toBe(true);
179+
180+
// Large number in both formats
181+
const largeDecimal = "1000000000000000000"; // 1e18
182+
const largeHex = "0xde0b6b3a7640000"; // Same value in hex
183+
expect(isValidFacilitatorFee(largeDecimal)).toBe(true);
184+
expect(isValidFacilitatorFee(largeHex)).toBe(true);
185+
});
186+
});
187+
});
188+
84189
describe("validateSettlementRouter", () => {
85190
it("should validate valid router address", () => {
86191
const result = validateSettlementRouter(
@@ -132,12 +237,26 @@ describe("validation utilities", () => {
132237
});
133238

134239
describe("validateSettlementExtra", () => {
135-
it("should validate valid settlement extra", () => {
240+
it("should validate valid settlement extra with hex facilitatorFee", () => {
136241
const extra = {
137242
settlementRouter: MOCK_ADDRESSES.settlementRouter,
138243
salt: MOCK_VALUES.salt,
139244
payTo: MOCK_ADDRESSES.merchant,
140-
facilitatorFee: MOCK_VALUES.facilitatorFee,
245+
facilitatorFee: MOCK_VALUES.facilitatorFee, // hex format
246+
hook: MOCK_ADDRESSES.hook,
247+
hookData: MOCK_VALUES.hookData,
248+
};
249+
250+
const result = validateSettlementExtra(extra);
251+
expect(result).toEqual(extra);
252+
});
253+
254+
it("should validate valid settlement extra with decimal facilitatorFee", () => {
255+
const extra = {
256+
settlementRouter: MOCK_ADDRESSES.settlementRouter,
257+
salt: MOCK_VALUES.salt,
258+
payTo: MOCK_ADDRESSES.merchant,
259+
facilitatorFee: "100000", // decimal format (same as 0x186A0)
141260
hook: MOCK_ADDRESSES.hook,
142261
hookData: MOCK_VALUES.hookData,
143262
};
@@ -146,6 +265,34 @@ describe("validation utilities", () => {
146265
expect(result).toEqual(extra);
147266
});
148267

268+
it("should validate facilitatorFee as decimal zero", () => {
269+
const extra = {
270+
settlementRouter: MOCK_ADDRESSES.settlementRouter,
271+
salt: MOCK_VALUES.salt,
272+
payTo: MOCK_ADDRESSES.merchant,
273+
facilitatorFee: "0", // decimal zero
274+
hook: MOCK_ADDRESSES.hook,
275+
hookData: MOCK_VALUES.hookData,
276+
};
277+
278+
const result = validateSettlementExtra(extra);
279+
expect(result.facilitatorFee).toBe("0");
280+
});
281+
282+
it("should validate facilitatorFee as hex zero", () => {
283+
const extra = {
284+
settlementRouter: MOCK_ADDRESSES.settlementRouter,
285+
salt: MOCK_VALUES.salt,
286+
payTo: MOCK_ADDRESSES.merchant,
287+
facilitatorFee: "0x0", // hex zero
288+
hook: MOCK_ADDRESSES.hook,
289+
hookData: MOCK_VALUES.hookData,
290+
};
291+
292+
const result = validateSettlementExtra(extra);
293+
expect(result.facilitatorFee).toBe("0x0");
294+
});
295+
149296
it("should throw for missing extra", () => {
150297
expect(() => {
151298
validateSettlementExtra(null);
@@ -195,6 +342,51 @@ describe("validation utilities", () => {
195342
validateSettlementExtra(extra);
196343
}).toThrow(FacilitatorValidationError);
197344
});
345+
346+
it("should throw for invalid facilitatorFee (non-numeric)", () => {
347+
const extra = {
348+
settlementRouter: MOCK_ADDRESSES.settlementRouter,
349+
salt: MOCK_VALUES.salt,
350+
payTo: MOCK_ADDRESSES.merchant,
351+
facilitatorFee: "invalid-fee",
352+
hook: MOCK_ADDRESSES.hook,
353+
hookData: MOCK_VALUES.hookData,
354+
};
355+
356+
expect(() => {
357+
validateSettlementExtra(extra);
358+
}).toThrow(FacilitatorValidationError);
359+
});
360+
361+
it("should throw for invalid facilitatorFee (negative)", () => {
362+
const extra = {
363+
settlementRouter: MOCK_ADDRESSES.settlementRouter,
364+
salt: MOCK_VALUES.salt,
365+
payTo: MOCK_ADDRESSES.merchant,
366+
facilitatorFee: "-10000",
367+
hook: MOCK_ADDRESSES.hook,
368+
hookData: MOCK_VALUES.hookData,
369+
};
370+
371+
expect(() => {
372+
validateSettlementExtra(extra);
373+
}).toThrow(FacilitatorValidationError);
374+
});
375+
376+
it("should throw for invalid facilitatorFee (malformed hex)", () => {
377+
const extra = {
378+
settlementRouter: MOCK_ADDRESSES.settlementRouter,
379+
salt: MOCK_VALUES.salt,
380+
payTo: MOCK_ADDRESSES.merchant,
381+
facilitatorFee: "0xXYZ123",
382+
hook: MOCK_ADDRESSES.hook,
383+
hookData: MOCK_VALUES.hookData,
384+
};
385+
386+
expect(() => {
387+
validateSettlementExtra(extra);
388+
}).toThrow(FacilitatorValidationError);
389+
});
198390
});
199391

200392
describe("validateNetwork", () => {

0 commit comments

Comments
 (0)