Skip to content

feat: support fxUSD and multi-decimal EIP-3009 stablecoins#109

Open
huwangtao123 wants to merge 3 commits intoBlockRunAI:mainfrom
huwangtao123:feat/fxusd-multi-asset-support
Open

feat: support fxUSD and multi-decimal EIP-3009 stablecoins#109
huwangtao123 wants to merge 3 commits intoBlockRunAI:mainfrom
huwangtao123:feat/fxusd-multi-asset-support

Conversation

@huwangtao123
Copy link

@huwangtao123 huwangtao123 commented Mar 21, 2026

What

Add proper multi-decimal EIP-3009 stablecoin support, fixing bugs that would cause incorrect behavior with non-6-decimal assets like fxUSD (18 decimals).

Why

ClawRouter's payment layer was hardcoded to assume 6 decimals (USDC) in several places. Adding fxUSD (18 decimals, verified on Basescan) exposed these gaps.

Changes

  • /health endpoint now returns paymentAssetDecimals
  • Proxy reuse path preserves decimals from existing proxy (fixes 10^12× balance inflation for 18-decimal assets)
  • transformPaymentError uses asset-native decimals for USD conversion in insufficient_funds errors
  • payment-asset.ts (new) — BasePaymentAsset type, normalizer, fetcher
  • balance.ts — updated USDC-specific comments to generic stablecoin
  • 3 new tests — fxUSD normalization, mixed-decimal assets, real contract address

On-Chain Verification

fxUSD contract (0x55380fe7a1910dff29a47b622057ab4139da42c5) on Base implements full EIP-3009:

  • transferWithAuthorization
  • receiveWithAuthorization
  • cancelAuthorization
  • All typehashes + authorizationState + DOMAIN_SEPARATOR

Testing

  • 363 tests pass, 0 regressions
  • 3 new tests added for fxUSD-specific scenarios

Summary by CodeRabbit

  • New Features
    • Support for multiple Base stablecoins with per-request asset selection, runtime asset switching, and balances normalized to USD micros; asset metadata exposed in health/proxy responses.
  • Bug Fixes / UX
    • Balance shortfall and error messages now reflect the selected asset symbol and use consistent USD formatting.
  • Tests
    • Added comprehensive tests for payment-asset normalization, fetching, ordering, and fallback behavior.

- Add paymentAssetDecimals to /health endpoint and proxy reuse path
  to prevent decimals data loss when reconnecting to an existing proxy
  (fixes 10^12x balance inflation for 18-decimal assets like fxUSD)
- Fix transformPaymentError to use asset-native decimals instead of
  hardcoded 6 for USD conversion in insufficient_funds error messages
- Update balance.ts and proxy.ts comments from USDC-specific to
  generic stablecoin language (USD micros)
- Add payment-asset.ts module with BasePaymentAsset type, normalizer,
  and fetcher for multi-asset support
- Add 3 new tests: fxUSD 18-decimal normalization, mixed-decimal
  asset list with disabled filtering, real fxUSD contract address

Verified: fxUSD contract (0x55380fe7a1910dff29a47b622057ab4139da42c5)
on Base fully implements EIP-3009 (transferWithAuthorization, all
typehashes, authorizationState) — confirmed on Basescan.
@coderabbitai
Copy link

coderabbitai bot commented Mar 21, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4f965ab7-112d-48af-b99f-e21bd19545ed

📥 Commits

Reviewing files that changed from the base of the PR and between 8023115 and bef5432.

📒 Files selected for processing (1)
  • src/payment-asset.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/payment-asset.ts

📝 Walkthrough

Walkthrough

Balance monitoring was generalized from USDC-only to configurable Base-chain stablecoins; a payment-asset module and tests were added; the proxy now tracks/advertises payment-asset metadata, selects a sufficient asset per request, and ties balance checks/deductions to the selected asset.

Changes

Cohort / File(s) Summary
Payment Asset Infrastructure
src/payment-asset.ts, src/payment-asset.test.ts
Adds BasePaymentAsset type and DEFAULT_BASE_PAYMENT_ASSET, normalization helpers (normalizeBasePaymentAsset(s)), fetchers (fetchBasePaymentAsset(s)), sorting/filtering/fallback logic, and Vitest coverage for normalization, fetching, fallback, and ordering.
Balance Monitor Generalization
src/balance.ts
BalanceMonitor now accepts a BasePaymentAsset (constructor), stores/returns balances normalized to USD micros, replaces USDC-specific RPC reads with this.asset.asset, adds setAsset(), getAsset(), getAssetSymbol(), and updates BalanceInfo to include assetSymbol.
Proxy Server Integration
src/proxy.ts
Introduces active base payment-asset state and per-request selection, enriches /health and proxy handles with payment-asset metadata, updates error formatting to accept asset symbol/decimals, selects a sufficient asset before processing, and applies optimistic deductions/invalidation against the chosen asset monitor.
Types / Public API Surface
src/proxy.ts, src/balance.ts
Public types updated: LowBalanceInfo/InsufficientFundsInfo include assetSymbol; ProxyHandle may include paymentAsset and paymentAssets; BalanceMonitor constructor signature changed.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Proxy as Proxy Server
    participant Fetcher as Asset Fetcher
    participant Selector as Asset Selector
    participant Monitor as BalanceMonitor
    participant Chain as Blockchain

    Client->>Proxy: incoming request (EVM/Base)
    Proxy->>Fetcher: fetchBasePaymentAssets()
    Fetcher-->>Proxy: normalized asset list
    Proxy->>Selector: evaluate assets for sufficiency
    loop for each candidate asset
        Selector->>Monitor: setAsset(candidate)
        Selector->>Monitor: checkSufficient(bufferedCostMicros)
        Monitor->>Chain: read balance for candidate.asset
        Chain-->>Monitor: raw balance
        Monitor->>Monitor: toUsdMicros(raw balance, decimals)
        Monitor-->>Selector: sufficiency result (assetSymbol, shortfall?)
        alt sufficient
            Selector-->>Proxy: select asset and stop
        end
    end
    Proxy->>Proxy: assign requestBalanceMonitor & requestBasePaymentAsset
    Proxy->>Monitor: optimistic deduction (USD micros)
    Monitor-->>Proxy: deduction applied
    Proxy-->>Client: proceed / respond
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: support fxUSD and multi-decimal EIP-3009 stablecoins' clearly and accurately summarizes the main objective of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/proxy.ts (1)

1568-1577: Async asset refresh in onAfterPaymentCreation may race with concurrent requests.

This callback fetches assets and updates activeBasePaymentAssets and activeBasePaymentAsset while other requests may be reading these variables in their asset selection loops (lines 3438, 3447).

Since the selection loop creates its own monitors and reads getBasePaymentAssets() at the start of selection, this should be safe — a request either sees the old list or the new list, both valid. However, consider documenting this non-atomic update if it's intentional.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/proxy.ts` around lines 1568 - 1577, The async refresh in
onAfterPaymentCreation can non-atomically update
activeBasePaymentAssets/activeBasePaymentAsset while other requests read them;
fetch the assets into a local variable first (e.g., const newAssets = await
fetchBasePaymentAssets(apiBase).catch(() => undefined)), then if newAssets is
truthy assign activeBasePaymentAssets = newAssets and activeBasePaymentAsset =
newAssets[0] ?? activeBasePaymentAsset, and only after that call
balanceMonitor.setAsset(activeBasePaymentAsset) (BalanceMonitor); alternatively
add a brief comment near getBasePaymentAssets()/onAfterPaymentCreation
documenting that updates are intentionally non-atomic and readers accept either
the old or new list.
src/payment-asset.ts (1)

99-101: Consider handling JSON parse errors in fetchBasePaymentAssets.

If the API returns a 200 OK with invalid JSON, response.json() will throw and the error propagates up. This could cause unexpected failures when the upstream API has transient issues.

🛡️ Suggested defensive handling
   if (!response.ok) return [DEFAULT_BASE_PAYMENT_ASSET];

-  const payload = (await response.json()) as PaymentMetadataResponse;
-  return normalizeBasePaymentAssets(payload);
+  try {
+    const payload = (await response.json()) as PaymentMetadataResponse;
+    return normalizeBasePaymentAssets(payload);
+  } catch {
+    return [DEFAULT_BASE_PAYMENT_ASSET];
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/payment-asset.ts` around lines 99 - 101, fetchBasePaymentAssets currently
calls response.json() directly which will throw on invalid JSON and propagate;
wrap the JSON parsing in a try/catch inside fetchBasePaymentAssets, attempt to
read and include the raw body (e.g., response.text()) or the parse error message
in the catch, log or attach that context, and then either throw a clearer, typed
error or return a safe fallback before calling
normalizeBasePaymentAssets(payload); reference fetchBasePaymentAssets,
response.json(), payload, and normalizeBasePaymentAssets to locate where to add
the try/catch and error enrichment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/balance.ts`:
- Around line 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.

---

Nitpick comments:
In `@src/payment-asset.ts`:
- Around line 99-101: fetchBasePaymentAssets currently calls response.json()
directly which will throw on invalid JSON and propagate; wrap the JSON parsing
in a try/catch inside fetchBasePaymentAssets, attempt to read and include the
raw body (e.g., response.text()) or the parse error message in the catch, log or
attach that context, and then either throw a clearer, typed error or return a
safe fallback before calling normalizeBasePaymentAssets(payload); reference
fetchBasePaymentAssets, response.json(), payload, and normalizeBasePaymentAssets
to locate where to add the try/catch and error enrichment.

In `@src/proxy.ts`:
- Around line 1568-1577: The async refresh in onAfterPaymentCreation can
non-atomically update activeBasePaymentAssets/activeBasePaymentAsset while other
requests read them; fetch the assets into a local variable first (e.g., const
newAssets = await fetchBasePaymentAssets(apiBase).catch(() => undefined)), then
if newAssets is truthy assign activeBasePaymentAssets = newAssets and
activeBasePaymentAsset = newAssets[0] ?? activeBasePaymentAsset, and only after
that call balanceMonitor.setAsset(activeBasePaymentAsset) (BalanceMonitor);
alternatively add a brief comment near
getBasePaymentAssets()/onAfterPaymentCreation documenting that updates are
intentionally non-atomic and readers accept either the old or new list.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7c0df0cb-eb1f-4015-afc0-1c3c0b4d7eab

📥 Commits

Reviewing files that changed from the base of the PR and between aada5d0 and 62c8b9e.

📒 Files selected for processing (4)
  • src/balance.ts
  • src/payment-asset.test.ts
  • src/payment-asset.ts
  • src/proxy.ts

Comment on lines +163 to +172
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();
}
}
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.

Adds docstrings to all exported types, constants, and functions
to satisfy the 80% docstring coverage CI requirement.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/payment-asset.ts`:
- Around line 113-124: fetchBasePaymentAssets currently doesn't catch network or
JSON parse errors, so add a try-catch around the fetch + json + normalize steps
in fetchBasePaymentAssets and, on any thrown error, log or ignore the error and
return [DEFAULT_BASE_PAYMENT_ASSET] as the documented fallback; ensure you still
preserve the existing behavior for non-ok responses (the early return when
response.ok is false) and reference the PaymentMetadataResponse parsing and
normalizeBasePaymentAssets call inside the try block so parse/normalize failures
also fall back.
- Around line 87-88: The function normalizeBasePaymentAssets currently returns
[] early when value is null/undefined or not an object, which bypasses the
fallback-to-USDC logic; modify normalizeBasePaymentAssets so it does not
early-return for falsy/non-object inputs but instead allows the existing
fallback path (the code that adds USDC when no valid assets are found) to
run—locate normalizeBasePaymentAssets and remove or change the early return
check so invalid input is handled by the later validation/fallback logic that
produces the USDC default.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e8345e67-07ef-4b45-8ae2-f2ee7023d2c2

📥 Commits

Reviewing files that changed from the base of the PR and between 62c8b9e and 8023115.

📒 Files selected for processing (1)
  • src/payment-asset.ts

- normalizeBasePaymentAssets now returns [DEFAULT_BASE_PAYMENT_ASSET]
  instead of [] for null/undefined/non-object input, ensuring the
  USDC fallback is always applied
- fetchBasePaymentAssets wraps fetch+json+normalize in try-catch so
  network errors and JSON parse failures fall back to USDC instead
  of propagating (matches documented contract)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant