Skip to content

Commit 1e3c7df

Browse files
Fix: Facilitator should validate user balance before sending settlement transaction (#199)
* Fix: Facilitator should validate user balance before sending settlement transaction * Apply changes from Holon --------- Co-authored-by: holonbot[bot] <250454749+holonbot[bot]@users.noreply.github.com>
1 parent 061f150 commit 1e3c7df

File tree

3 files changed

+226
-1
lines changed

3 files changed

+226
-1
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export {
4141
parseSettlementRouterParams,
4242
settleWithSettlementRouter,
4343
executeSettlementWithWalletClient,
44+
InsufficientBalanceError,
4445
} from "./settlement.js";
4546

4647
// Validation utilities

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

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,38 @@ export function calculateGasLimit(
184184
return totalGas;
185185
}
186186

187+
/**
188+
* Custom error for insufficient balance errors
189+
* Thrown when user's token balance is less than required amount for settlement
190+
*/
191+
export class InsufficientBalanceError extends Error {
192+
/** User's current balance */
193+
readonly balance: bigint;
194+
/** Required amount (payment + facilitator fee) */
195+
readonly required: bigint;
196+
/** Payment amount */
197+
readonly payment: bigint;
198+
/** Facilitator fee amount */
199+
readonly fee: bigint;
200+
201+
constructor(
202+
balance: bigint,
203+
required: bigint,
204+
payment: bigint,
205+
fee: bigint,
206+
) {
207+
super(
208+
`Insufficient balance: user has ${balance} tokens, but needs ${required} ` +
209+
`(payment: ${payment} + facilitator fee: ${fee})`
210+
);
211+
this.name = "InsufficientBalanceError";
212+
this.balance = balance;
213+
this.required = required;
214+
this.payment = payment;
215+
this.fee = fee;
216+
}
217+
}
218+
187219
/**
188220
* Check if a settlement has already been executed
189221
*/
@@ -208,14 +240,42 @@ export async function checkIfSettled(
208240
}
209241

210242
/**
211-
* Execute settlement via SettlementRouter
243+
* ERC20 ABI for balance checks
244+
*/
245+
const ERC20_ABI = [
246+
{
247+
type: "function",
248+
stateMutability: "view",
249+
inputs: [{ name: "account", type: "address" }],
250+
name: "balanceOf",
251+
outputs: [{ type: "uint256" }],
252+
},
253+
] as const;
254+
255+
/**
256+
* Execute settlement via SettlementRouter.
257+
*
258+
* @param walletClient - The viem {@link WalletClient} used to send the settlement transaction.
259+
* @param params - The {@link SettlementRouterParams} describing the settlement to execute.
260+
* @param config - Optional configuration overrides.
261+
* @param config.gasLimit - Explicit gas limit to use for the transaction. If omitted, it is derived
262+
* from {@link calculateGasLimit} based on the facilitator fee and `gasMultiplier`.
263+
* @param config.gasMultiplier - Multiplier applied when estimating gas usage (for safety margin).
264+
* @param config.publicClient - Optional viem {@link PublicClient} to use for read operations
265+
* (for example, balance checks). If provided, the user's token balance will be validated before
266+
* sending the transaction to avoid wasting gas on insufficient balance scenarios.
267+
* @returns A promise that resolves to the transaction hash of the settlement as a hex string.
268+
* @throws {InsufficientBalanceError} When `publicClient` is provided and the user's token balance
269+
* is less than the required amount (payment + facilitator fee).
270+
* @throws {Error} When transaction execution fails (e.g., invalid signature, contract revert).
212271
*/
213272
export async function executeSettlementWithRouter(
214273
walletClient: WalletClient,
215274
params: SettlementRouterParams,
216275
config: {
217276
gasLimit?: bigint;
218277
gasMultiplier?: number;
278+
publicClient?: PublicClient;
219279
} = {},
220280
): Promise<Hex> {
221281
const gasLimit =
@@ -238,6 +298,36 @@ export async function executeSettlementWithRouter(
238298
settlementRouter: params.settlementRouter,
239299
});
240300

301+
// Check user balance before sending transaction to avoid wasting gas
302+
if (config.publicClient) {
303+
try {
304+
const balance = await config.publicClient.readContract({
305+
address: params.token,
306+
abi: ERC20_ABI,
307+
functionName: "balanceOf",
308+
args: [params.from],
309+
});
310+
311+
const paymentAmount = BigInt(params.value);
312+
const feeAmount = BigInt(params.facilitatorFee);
313+
const requiredAmount = paymentAmount + feeAmount;
314+
315+
if (balance < requiredAmount) {
316+
throw new InsufficientBalanceError(balance, requiredAmount, paymentAmount, feeAmount);
317+
}
318+
} catch (error) {
319+
// If balance check fails, log the error but continue with transaction
320+
// The transaction will fail on-chain with a more specific error if balance is truly insufficient
321+
if (error instanceof InsufficientBalanceError) {
322+
// Re-throw the insufficient balance error
323+
throw error;
324+
} else {
325+
// Log RPC/other errors and proceed with transaction
326+
console.warn("[executeSettlementWithRouter] Balance check failed, proceeding with transaction:", error instanceof Error ? error.message : error);
327+
}
328+
}
329+
}
330+
241331
try {
242332
const txHash = await walletClient.writeContract({
243333
address: params.settlementRouter,
@@ -503,6 +593,7 @@ export async function executeSettlementWithWalletClient(
503593
const txHash = await executeSettlementWithRouter(walletClient, params, {
504594
gasLimit: config.gasLimit,
505595
gasMultiplier: config.gasMultiplier,
596+
publicClient,
506597
});
507598

508599
// Wait for receipt
@@ -594,6 +685,7 @@ export async function settleWithSettlementRouter(
594685
const txHash = await executeSettlementWithRouter(walletClient, params, {
595686
gasLimit: options.gasLimit,
596687
gasMultiplier: options.gasMultiplier,
688+
publicClient,
597689
});
598690

599691
// Wait for receipt

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

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
waitForSettlementReceipt,
1313
parseSettlementRouterParams,
1414
settleWithSettlementRouter,
15+
InsufficientBalanceError,
1516
} from "../src/index.js";
1617
import { SettlementRouterError } from "../src/types.js";
1718
import {
@@ -317,6 +318,137 @@ describe("SettlementRouter integration", () => {
317318
);
318319
});
319320

321+
it("should validate user balance before sending transaction when publicClient is provided", async () => {
322+
const params = {
323+
token: MOCK_ADDRESSES.token,
324+
from: MOCK_ADDRESSES.payer,
325+
value: MOCK_VALUES.paymentAmount,
326+
validAfter: MOCK_VALUES.validAfter,
327+
validBefore: MOCK_VALUES.validBefore,
328+
nonce: MOCK_VALUES.nonce,
329+
signature: MOCK_VALUES.signature,
330+
salt: MOCK_VALUES.salt,
331+
payTo: MOCK_ADDRESSES.merchant,
332+
facilitatorFee: MOCK_VALUES.facilitatorFee,
333+
hook: MOCK_ADDRESSES.hook,
334+
hookData: MOCK_VALUES.hookData,
335+
settlementRouter: MOCK_ADDRESSES.settlementRouter,
336+
};
337+
338+
// Mock balance check - user has sufficient balance (2,000,000 is greater than payment 1,000,000 + fee 100,000)
339+
const sufficientBalance = BigInt(2000000);
340+
mockPublicClient.readContract.mockResolvedValue(sufficientBalance);
341+
342+
const txHash = await executeSettlementWithRouter(mockWalletClient, params, {
343+
publicClient: mockPublicClient,
344+
});
345+
346+
expect(txHash).toBe(mockSettleResponse.transaction);
347+
expect(mockPublicClient.readContract).toHaveBeenCalledWith({
348+
address: MOCK_ADDRESSES.token,
349+
abi: expect.arrayContaining([
350+
expect.objectContaining({
351+
name: "balanceOf",
352+
type: "function",
353+
}),
354+
]),
355+
functionName: "balanceOf",
356+
args: [MOCK_ADDRESSES.payer],
357+
});
358+
expect(mockWalletClient.writeContract).toHaveBeenCalled();
359+
});
360+
361+
it("should throw error when user has insufficient balance", async () => {
362+
const params = {
363+
token: MOCK_ADDRESSES.token,
364+
from: MOCK_ADDRESSES.payer,
365+
value: MOCK_VALUES.paymentAmount,
366+
validAfter: MOCK_VALUES.validAfter,
367+
validBefore: MOCK_VALUES.validBefore,
368+
nonce: MOCK_VALUES.nonce,
369+
signature: MOCK_VALUES.signature,
370+
salt: MOCK_VALUES.salt,
371+
payTo: MOCK_ADDRESSES.merchant,
372+
facilitatorFee: MOCK_VALUES.facilitatorFee,
373+
hook: MOCK_ADDRESSES.hook,
374+
hookData: MOCK_VALUES.hookData,
375+
settlementRouter: MOCK_ADDRESSES.settlementRouter,
376+
};
377+
378+
// Mock insufficient balance (1000 is less than payment amount + facilitator fee)
379+
const paymentAmount = BigInt(MOCK_VALUES.paymentAmount);
380+
const facilitatorFee = BigInt(MOCK_VALUES.facilitatorFee);
381+
const requiredAmount = paymentAmount + facilitatorFee;
382+
const insufficientBalance = requiredAmount - 1n;
383+
384+
mockPublicClient.readContract.mockResolvedValue(insufficientBalance);
385+
386+
await expect(
387+
executeSettlementWithRouter(mockWalletClient, params, {
388+
publicClient: mockPublicClient,
389+
}),
390+
).rejects.toThrow(InsufficientBalanceError);
391+
392+
// Verify transaction was NOT sent
393+
expect(mockWalletClient.writeContract).not.toHaveBeenCalled();
394+
});
395+
396+
it("should skip balance check when publicClient is not provided", async () => {
397+
const params = {
398+
token: MOCK_ADDRESSES.token,
399+
from: MOCK_ADDRESSES.payer,
400+
value: MOCK_VALUES.paymentAmount,
401+
validAfter: MOCK_VALUES.validAfter,
402+
validBefore: MOCK_VALUES.validBefore,
403+
nonce: MOCK_VALUES.nonce,
404+
signature: MOCK_VALUES.signature,
405+
salt: MOCK_VALUES.salt,
406+
payTo: MOCK_ADDRESSES.merchant,
407+
facilitatorFee: MOCK_VALUES.facilitatorFee,
408+
hook: MOCK_ADDRESSES.hook,
409+
hookData: MOCK_VALUES.hookData,
410+
settlementRouter: MOCK_ADDRESSES.settlementRouter,
411+
};
412+
413+
// Call without publicClient
414+
const txHash = await executeSettlementWithRouter(mockWalletClient, params);
415+
416+
expect(txHash).toBe(mockSettleResponse.transaction);
417+
// Balance check should not be performed
418+
expect(mockPublicClient.readContract).not.toHaveBeenCalled();
419+
// But transaction should still be sent
420+
expect(mockWalletClient.writeContract).toHaveBeenCalled();
421+
});
422+
423+
it("should handle balance check errors gracefully and proceed with transaction", async () => {
424+
const params = {
425+
token: MOCK_ADDRESSES.token,
426+
from: MOCK_ADDRESSES.payer,
427+
value: MOCK_VALUES.paymentAmount,
428+
validAfter: MOCK_VALUES.validAfter,
429+
validBefore: MOCK_VALUES.validBefore,
430+
nonce: MOCK_VALUES.nonce,
431+
signature: MOCK_VALUES.signature,
432+
salt: MOCK_VALUES.salt,
433+
payTo: MOCK_ADDRESSES.merchant,
434+
facilitatorFee: MOCK_VALUES.facilitatorFee,
435+
hook: MOCK_ADDRESSES.hook,
436+
hookData: MOCK_VALUES.hookData,
437+
settlementRouter: MOCK_ADDRESSES.settlementRouter,
438+
};
439+
440+
// Mock balance check failure (e.g., RPC error)
441+
mockPublicClient.readContract.mockRejectedValue(new Error("RPC timeout"));
442+
443+
const txHash = await executeSettlementWithRouter(mockWalletClient, params, {
444+
publicClient: mockPublicClient,
445+
});
446+
447+
// Should still proceed with transaction despite balance check failure
448+
expect(txHash).toBe(mockSettleResponse.transaction);
449+
expect(mockWalletClient.writeContract).toHaveBeenCalled();
450+
});
451+
320452
it("should use custom gas limit", async () => {
321453
const params = {
322454
token: MOCK_ADDRESSES.token,

0 commit comments

Comments
 (0)