From b8cd018f29976359c9adedc623ca3d0ed453b825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 16 Jun 2026 14:12:07 -0300 Subject: [PATCH] fix(card): revert spend routing to collateral-first (smart-first reverts on-chain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2230 reordered computeSpendStrategy to smart-first, but card funds are swept smart→collateral to back the card, so the smart account is normally ~empty. The FE's smartBalance (useBalance, 30s-cached / pre-sweep) could read >= amount while the on-chain smart account is empty → smart-only was chosen → the USDC transfer reverts at the paymaster with "ERC20: transfer amount exceeds balance" for collateral-funded users (prod incident). Collateral-first never trusted the smart balance for these users, so it was safe. Restore collateral-first. Re-introducing smart-first (to avoid Rain's withdrawal-signature cooldown when the user genuinely holds smart-account USDC) needs a live/uncached balance read AND a fallback to collateral when the smart-only path can't be funded — tracked as a follow-up. --- .../wallet/__tests__/useSpendBundle.test.ts | 13 ++----------- src/hooks/wallet/useSpendBundle.ts | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/src/hooks/wallet/__tests__/useSpendBundle.test.ts b/src/hooks/wallet/__tests__/useSpendBundle.test.ts index 47cf0e508..885201a4a 100644 --- a/src/hooks/wallet/__tests__/useSpendBundle.test.ts +++ b/src/hooks/wallet/__tests__/useSpendBundle.test.ts @@ -4,7 +4,7 @@ * The hook itself orchestrates kernel clients, Rain API calls, and the * session-key grant flow — those paths are covered by integration + manual * testing on sandbox. These tests lock down the deterministic pieces: - * - `computeSpendStrategy` routing (smart → collateral → mixed → insufficient) + * - `computeSpendStrategy` routing (collateral → smart → mixed → insufficient) * - `usdcUnitsToRainCents` amount conversion at the Rain API boundary */ @@ -45,17 +45,8 @@ import { computeSpendStrategy } from '../useSpendBundle' describe('computeSpendStrategy', () => { const amount = 1000n - it('prefers smart-only when smart covers the amount, even if collateral-only is allowed', () => { - // Smart account is spent first so the payment never touches the Rain - // collateral (and its per-account withdrawal-signature cooldown) when - // smart-account USDC already covers it. + it('prefers collateral-only when allowed and rain covers the amount', () => { expect(computeSpendStrategy({ smart: 5000n, rain: 10_000n, amount, collateralOnlyAllowed: true })).toBe( - 'smart-only' - ) - }) - - it('uses collateral-only when smart cannot cover but collateral can (allowed)', () => { - expect(computeSpendStrategy({ smart: 100n, rain: 10_000n, amount, collateralOnlyAllowed: true })).toBe( 'collateral-only' ) }) diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 55bbaea92..a6c2eb93e 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -104,13 +104,16 @@ export class SessionKeyGrantRequiredError extends Error { /** * Pure routing helper — decides which bucket(s) a spend will pull from. - * Priority: smart → collateral → mixed. The smart account is spent first - * whenever it can cover the whole amount, so a payment never touches the - * Rain collateral — and Rain's per-account withdrawal-signature cooldown — - * if the user's smart-account USDC already covers it. Collateral is the - * fallback (single recipient AND no subsequent kernel calls, since Rain's - * coordinator transfers tokens directly with nothing following), and `mixed` - * tops up the shortfall from collateral when smart alone can't cover it. + * Priority: collateral → smart → mixed. Collateral-only requires a single + * recipient AND no subsequent kernel calls (Rain's coordinator transfers + * tokens directly; nothing follows). + * + * NOTE: a smart-first variant (#2230) was reverted — it routed `smart-only` + * whenever the cached `smartBalance` covered the amount, but card funds are + * swept smart→collateral, so a stale/pre-sweep balance made smart-only spends + * revert on-chain ("ERC20: transfer amount exceeds balance") for collateral- + * funded users. Re-introducing smart-first needs a live (uncached) balance read + * AND a fallback to collateral when the smart-only path can't be funded. */ export function computeSpendStrategy(input: { smart: bigint @@ -118,8 +121,8 @@ export function computeSpendStrategy(input: { amount: bigint collateralOnlyAllowed: boolean }): SpendStrategy { - if (input.smart >= input.amount) return 'smart-only' if (input.collateralOnlyAllowed && input.rain >= input.amount) return 'collateral-only' + if (input.smart >= input.amount) return 'smart-only' if (input.smart + input.rain >= input.amount) return 'mixed' return 'insufficient' }