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' }