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
65 changes: 48 additions & 17 deletions src/balance.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/**
* Balance Monitor for ClawRouter
*
* Monitors USDC balance on Base network with intelligent caching.
* Monitors stablecoin balance on Base network with intelligent caching.
* Supports any EIP-3009 stablecoin (USDC, fxUSD, EURC, etc.) with
* automatic normalization from native decimals to USD micros (6 decimals).
* Provides pre-request balance checks to prevent failed payments.
*
* Caching Strategy:
Expand All @@ -13,14 +15,12 @@
import { createPublicClient, http, erc20Abi } from "viem";
import { base } from "viem/chains";
import { RpcError } from "./errors.js";

/** USDC contract address on Base mainnet */
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const;
import { DEFAULT_BASE_PAYMENT_ASSET, type BasePaymentAsset } from "./payment-asset.js";

/** Cache TTL in milliseconds (30 seconds) */
const CACHE_TTL_MS = 30_000;

/** Balance thresholds in USDC smallest unit (6 decimals) */
/** Balance thresholds in USD micros (6 decimals, normalized from any stablecoin) */
export const BALANCE_THRESHOLDS = {
/** Low balance warning threshold: $1.00 */
LOW_BALANCE_MICROS: 1_000_000n,
Expand All @@ -30,10 +30,12 @@ export const BALANCE_THRESHOLDS = {

/** Balance information returned by checkBalance() */
export type BalanceInfo = {
/** Raw balance in USDC smallest unit (6 decimals) */
/** Raw balance normalized to USD micros (6 decimals, regardless of the underlying asset's native decimals) */
balance: bigint;
/** Formatted balance as "$X.XX" */
balanceUSD: string;
/** Symbol of the active Base payment asset */
assetSymbol: string;
/** True if balance < $1.00 */
isLow: boolean;
/** True if balance < $0.0001 (effectively zero) */
Expand All @@ -53,7 +55,7 @@ export type SufficiencyResult = {
};

/**
* Monitors USDC balance on Base network.
* Monitors stablecoin balance on Base network.
*
* Usage:
* const monitor = new BalanceMonitor("0x...");
Expand All @@ -63,14 +65,16 @@ export type SufficiencyResult = {
export class BalanceMonitor {
private readonly client;
private readonly walletAddress: `0x${string}`;
private asset: BasePaymentAsset;

/** Cached balance (null = not yet fetched) */
private cachedBalance: bigint | null = null;
/** Timestamp when cache was last updated */
private cachedAt = 0;

constructor(walletAddress: string) {
constructor(walletAddress: string, asset: BasePaymentAsset = DEFAULT_BASE_PAYMENT_ASSET) {
this.walletAddress = walletAddress as `0x${string}`;
this.asset = asset;
this.client = createPublicClient({
chain: base,
transport: http(undefined, {
Expand Down Expand Up @@ -110,7 +114,7 @@ export class BalanceMonitor {
/**
* Check if balance is sufficient for an estimated cost.
*
* @param estimatedCostMicros - Estimated cost in USDC smallest unit (6 decimals)
* @param estimatedCostMicros - Estimated cost in USD micros (6 decimals)
*/
async checkSufficient(estimatedCostMicros: bigint): Promise<SufficiencyResult> {
const info = await this.checkBalance();
Expand All @@ -123,15 +127,15 @@ export class BalanceMonitor {
return {
sufficient: false,
info,
shortfall: this.formatUSDC(shortfall),
shortfall: this.formatUSD(shortfall),
};
}

/**
* Optimistically deduct estimated cost from cached balance.
* Call this after a successful payment to keep cache accurate.
*
* @param amountMicros - Amount to deduct in USDC smallest unit
* @param amountMicros - Amount to deduct in USD micros
*/
deductEstimated(amountMicros: bigint): void {
if (this.cachedBalance !== null && this.cachedBalance >= amountMicros) {
Expand All @@ -156,11 +160,25 @@ export class BalanceMonitor {
return this.checkBalance();
}

setAsset(asset: BasePaymentAsset): void {
if (
this.asset.asset.toLowerCase() !== asset.asset.toLowerCase() ||
this.asset.symbol !== asset.symbol ||
this.asset.decimals !== asset.decimals
) {
this.asset = asset;
this.invalidate();
}
}
Comment on lines +163 to +172
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential race condition when setAsset() is called concurrently with balance checks.

Per the context snippets, setAsset() is called from onAfterPaymentCreation (async callback during payment creation) while checkSufficient() may be executing concurrently for other requests. The check-then-act pattern at lines 164-170 is not atomic with respect to async operations:

  1. Request A calls checkBalance() → awaits fetchBalance() with asset X
  2. Request B's payment triggers onAfterPaymentCreationsetAsset(Y) invalidates cache
  3. Request A's fetchBalance() returns balance for asset X, but this.asset is now Y

This could cause balance info to report the wrong assetSymbol or cache a balance for the wrong asset.

Consider either:

  • Making BalanceMonitor immutable (create new instance per asset) — which src/proxy.ts already does in the asset selection loop at lines 3447-3457
  • Or documenting that setAsset() should only be called when no concurrent operations are in flight
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/balance.ts` around lines 163 - 172, setAsset mutates BalanceMonitor while
async balance checks (checkSufficient/checkBalance/fetchBalance) can be
in-flight, causing cached balances or responses to be associated with the wrong
asset; make BalanceMonitor immutable instead of mutating it: remove/stop using
setAsset and ensure callers (e.g., the onAfterPaymentCreation path that
currently calls setAsset) create a new BalanceMonitor instance per asset (or per
asset change) so each monitor owns a fixed BasePaymentAsset, or alternatively
implement an atomic/version check in fetchBalance that aborts/cancels caching if
this.asset changed during the fetch; reference: setAsset, BalanceMonitor,
checkSufficient, checkBalance, fetchBalance, onAfterPaymentCreation.


getAsset(): BasePaymentAsset {
return this.asset;
}

/**
* Format USDC amount (in micros) as "$X.XX".
* Format a stablecoin amount (normalized to USD micros) as "$X.XX".
*/
formatUSDC(amountMicros: bigint): string {
// USDC has 6 decimals
formatUSD(amountMicros: bigint): string {
const dollars = Number(amountMicros) / 1_000_000;
return `$${dollars.toFixed(2)}`;
}
Expand All @@ -172,16 +190,20 @@ export class BalanceMonitor {
return this.walletAddress;
}

getAssetSymbol(): string {
return this.asset.symbol;
}

/** Fetch balance from RPC */
private async fetchBalance(): Promise<bigint> {
try {
const balance = await this.client.readContract({
address: USDC_BASE,
address: this.asset.asset,
abi: erc20Abi,
functionName: "balanceOf",
args: [this.walletAddress],
});
return balance;
return this.toUsdMicros(balance);
} catch (error) {
// Throw typed error instead of silently returning 0
// This allows callers to distinguish "node down" from "wallet empty"
Expand All @@ -193,10 +215,19 @@ export class BalanceMonitor {
private buildInfo(balance: bigint): BalanceInfo {
return {
balance,
balanceUSD: this.formatUSDC(balance),
balanceUSD: this.formatUSD(balance),
assetSymbol: this.asset.symbol,
isLow: balance < BALANCE_THRESHOLDS.LOW_BALANCE_MICROS,
isEmpty: balance < BALANCE_THRESHOLDS.ZERO_THRESHOLD,
walletAddress: this.walletAddress,
};
}

private toUsdMicros(rawAmount: bigint): bigint {
if (this.asset.decimals === 6) return rawAmount;
if (this.asset.decimals > 6) {
return rawAmount / 10n ** BigInt(this.asset.decimals - 6);
}
return rawAmount * 10n ** BigInt(6 - this.asset.decimals);
}
}
216 changes: 216 additions & 0 deletions src/payment-asset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_BASE_PAYMENT_ASSET,
fetchBasePaymentAsset,
fetchBasePaymentAssets,
normalizeBasePaymentAsset,
normalizeBasePaymentAssets,
} from "./payment-asset.js";

describe("payment asset helpers", () => {
it("normalizes a valid flat response", () => {
const asset = normalizeBasePaymentAsset({
asset: "0x1111111111111111111111111111111111111111",
symbol: "eurc",
decimals: 6,
name: "Euro Coin",
transferMethod: "eip3009",
});

expect(asset).toEqual({
chain: "base",
asset: "0x1111111111111111111111111111111111111111",
symbol: "EURC",
decimals: 6,
name: "Euro Coin",
transferMethod: "eip3009",
});
});

it("rejects non-eip3009 assets", () => {
const asset = normalizeBasePaymentAsset({
asset: "0x1111111111111111111111111111111111111111",
symbol: "USDT",
decimals: 6,
name: "Tether",
transferMethod: "permit2",
});

expect(asset).toBeUndefined();
});

it("parses nested paymentAsset responses", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
paymentAsset: {
asset: "0x2222222222222222222222222222222222222222",
symbol: "EURC",
decimals: 6,
name: "Euro Coin",
transferMethod: "eip3009",
},
}),
{ status: 200 },
),
);

const asset = await fetchBasePaymentAsset(
"https://blockrun.ai/api",
mockFetch as unknown as typeof fetch,
);
expect(asset?.asset).toBe("0x2222222222222222222222222222222222222222");
expect(asset?.symbol).toBe("EURC");
expect(mockFetch).toHaveBeenCalledWith(
"https://blockrun.ai/api/v1/payment-metadata?chain=base",
expect.any(Object),
);
});

it("falls back to the default asset for invalid metadata responses", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ paymentAsset: { symbol: "EURC" } }), { status: 200 }),
);

const asset = await fetchBasePaymentAsset(
"https://blockrun.ai/api",
mockFetch as unknown as typeof fetch,
);
expect(asset).toEqual(DEFAULT_BASE_PAYMENT_ASSET);
});

it("parses and sorts multiple assets by priority", () => {
const assets = normalizeBasePaymentAssets({
paymentAssets: [
{
asset: "0x3333333333333333333333333333333333333333",
symbol: "FXUSD",
decimals: 18,
name: "fxUSD",
transferMethod: "eip3009",
priority: 2,
},
{
asset: "0x1111111111111111111111111111111111111111",
symbol: "USDC",
decimals: 6,
name: "USD Coin",
transferMethod: "eip3009",
priority: 1,
},
],
});

expect(assets.map((asset) => asset.symbol)).toEqual(["USDC", "FXUSD"]);
});

it("fetches multiple payment assets", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
paymentAssets: [
{
asset: "0x1111111111111111111111111111111111111111",
symbol: "USDC",
decimals: 6,
name: "USD Coin",
transferMethod: "eip3009",
priority: 1,
},
{
asset: "0x2222222222222222222222222222222222222222",
symbol: "EURC",
decimals: 6,
name: "Euro Coin",
transferMethod: "eip3009",
priority: 2,
},
],
}),
{ status: 200 },
),
);

const assets = await fetchBasePaymentAssets(
"https://blockrun.ai/api",
mockFetch as unknown as typeof fetch,
);
expect(assets).toHaveLength(2);
expect(assets[0]?.symbol).toBe("USDC");
expect(assets[1]?.symbol).toBe("EURC");
});

it("keeps USDC as the safe default asset", () => {
expect(DEFAULT_BASE_PAYMENT_ASSET.symbol).toBe("USDC");
expect(DEFAULT_BASE_PAYMENT_ASSET.transferMethod).toBe("eip3009");
});

it("normalizes fxUSD with 18 decimals correctly", () => {
const asset = normalizeBasePaymentAsset({
asset: "0x55380fe7a1910dff29a47b622057ab4139da42c5",
symbol: "fxusd",
decimals: 18,
name: "fxUSD",
transferMethod: "eip3009",
});

expect(asset).toEqual({
chain: "base",
asset: "0x55380fe7a1910dff29a47b622057ab4139da42c5",
symbol: "FXUSD",
decimals: 18,
name: "fxUSD",
transferMethod: "eip3009",
});
});

it("handles mixed-decimal assets in normalizeBasePaymentAssets", () => {
const assets = normalizeBasePaymentAssets({
paymentAssets: [
{
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
symbol: "USDC",
decimals: 6,
name: "USD Coin",
transferMethod: "eip3009",
priority: 1,
},
{
asset: "0x55380fe7a1910dff29a47b622057ab4139da42c5",
symbol: "FXUSD",
decimals: 18,
name: "fxUSD",
transferMethod: "eip3009",
priority: 2,
},
{
asset: "0x0000000000000000000000000000000000000000",
symbol: "DISABLED",
decimals: 6,
name: "Disabled Token",
transferMethod: "eip3009",
enabled: false,
},
],
});

expect(assets).toHaveLength(2);
expect(assets[0]?.symbol).toBe("USDC");
expect(assets[0]?.decimals).toBe(6);
expect(assets[1]?.symbol).toBe("FXUSD");
expect(assets[1]?.decimals).toBe(18);
});

it("accepts the real fxUSD Base contract address", () => {
const asset = normalizeBasePaymentAsset({
asset: "0x55380fe7A1910dFf29a47B622057AB4139DA42C5",
symbol: "FXUSD",
decimals: 18,
name: "fxUSD",
transferMethod: "eip3009",
});

expect(asset).toBeDefined();
expect(asset?.asset).toBe("0x55380fe7A1910dFf29a47B622057AB4139DA42C5");
});
});
Loading