From 65138daf850085a3fbcfcc8ed130d57d69a8d644 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Fri, 25 Sep 2026 22:16:21 +0530 Subject: [PATCH] fix(card-spend): disable the retired v2 module when moving a Safe onto v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fuse v2 module was redeployed on 2026-09-24 (0xE2d4…7b2B → 0xa98f…A999), but a shipped build kept the old address and moved Safes onto the retired core. The backend reads only the new module, so those cards decline every payment with SAFE_NOT_REGISTERED, while the old lens still tells the app they are set up. Once a build has the new address, those Safes show the enable-spending banner. The set-up and mode-switch batches now also disable any retired v2 core still on the Safe. Disables are computed against the module list as each earlier call leaves it, so v1 and the retired core can come off in one batch without GS103. Retired cores are listed in EXPO_PUBLIC_RETIRED_CASH_MODULE_V2_ADDRESSES, which defaults to 0xE2d4… and never includes the live core. Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 6 ++ hooks/useCardSpendRegistration.ts | 82 +++++++------- lib/config.ts | 15 +++ lib/utils/__tests__/safeModules.test.ts | 137 ++++++++++++++++++++++++ lib/utils/safeModules.ts | 77 +++++++++++++ 5 files changed, 277 insertions(+), 40 deletions(-) create mode 100644 lib/utils/__tests__/safeModules.test.ts create mode 100644 lib/utils/safeModules.ts diff --git a/.env.example b/.env.example index 8c6814bbc..be07ff9a2 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,12 @@ EXPO_PUBLIC_CARD_SPEND_V2=true EXPO_PUBLIC_CASH_MODULE_V2_ADDRESS= # SolidSpendLens on Fuse — the cohort-aware read serving both module generations. EXPO_PUBLIC_SPEND_LENS_V2_ADDRESS= +# Earlier SolidCashModuleV2 cores the address above replaced, comma-separated. Any of +# these still enabled on a Safe is disabled in the same batch that puts it on the live +# core — the backend no longer reads them, so a Safe left there declines every payment. +# Empty means the 2026-09-24 retiree (0xE2d4FB3d1eeD6Bdc3fD62A93ab35A33FC3c97b2B). +# Append here on the next redeploy, and change BOTH addresses above in the same release. +EXPO_PUBLIC_RETIRED_CASH_MODULE_V2_ADDRESSES= # ---- Base: the EURC spend instance ------------------------------------------- # A SECOND, INDEPENDENT deployment of the spend module, not a setting on the Fuse diff --git a/hooks/useCardSpendRegistration.ts b/hooks/useCardSpendRegistration.ts index 9767cf077..4fada2614 100644 --- a/hooks/useCardSpendRegistration.ts +++ b/hooks/useCardSpendRegistration.ts @@ -30,6 +30,7 @@ import { confirmWirexCardRegistration } from '@/lib/api'; import { ADDRESSES } from '@/lib/config'; import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; import { CardProvider } from '@/lib/types'; +import { buildModuleDisables, includesModule, SENTINEL_MODULES } from '@/lib/utils/safeModules'; import { publicClient } from '@/lib/wagmi'; import { useUserStore } from '@/store/useUserStore'; @@ -40,13 +41,7 @@ export const CARD_SPEND_REGISTRATION_QUERY_KEY = 'cardSpendRegistration'; const MODULE = ADDRESSES.fuse.cashModule; const MODULE_V2 = ADDRESSES.fuse.cashModuleV2; const SPEND_LENS_V2 = ADDRESSES.fuse.spendLensV2; - -/** - * Head of a Safe's module linked list. `disableModule(prevModule, module)` needs the - * entry pointing at the one being removed, and for the most recently enabled module - * that pointer is the sentinel itself rather than another module's address. - */ -const SENTINEL_MODULES = '0x0000000000000000000000000000000000000001' as Address; +const RETIRED_MODULES_V2 = ADDRESSES.fuse.retiredCashModulesV2; /** Enough to cover any real Safe's module list in one read. */ const MODULE_PAGE_SIZE = 50n; @@ -301,6 +296,24 @@ const findModulePredecessor = async ( return index === 0 ? SENTINEL_MODULES : modules[index - 1]; }; +/** + * `disableModule` calls for each of `targets` still enabled on the Safe, read at press time. + * + * The list is read fresh for the same reason {@link findModulePredecessor} reads it: enabling + * any module rewrites the pointers, and a stale predecessor reverts the whole batch. + */ +const encodeModuleDisables = async (safeAddress: Address, targets: readonly Address[]) => { + const client = publicClient(fuse.id); + const [modules] = await client.readContract({ + address: safeAddress, + abi: Safe_ABI, + functionName: 'getModulesPaginated', + args: [SENTINEL_MODULES, MODULE_PAGE_SIZE], + }); + + return buildModuleDisables(safeAddress, modules, targets); +}; + /** * The caps a migrating Safe registers on v2 with. * @@ -1127,28 +1140,22 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp const target = fresh.moduleAddress; // A Safe headed for v2 with v1 still switched on: v2's `registerSafe` reverts - // `LegacyModuleStillEnabled`, so v1 comes off first, in the same batch. - const prevLegacyModule = - fresh.awaitingV2 && fresh.legacyEnabled - ? await findModulePredecessor(safeAddress, MODULE as Address) - : null; - if (fresh.awaitingV2 && fresh.legacyEnabled && !prevLegacyModule) { + // `LegacyModuleStillEnabled`, so v1 comes off first, in the same batch. So does any + // retired v2 core the Safe was left on by a redeploy — it can no longer fund the card, + // and leaving it enabled keeps a module on the Safe that nothing is watching. + const needsLegacyDisable = fresh.awaitingV2 && fresh.legacyEnabled; + const cleanup = isV2Module(target) + ? await encodeModuleDisables(safeAddress, [ + ...(needsLegacyDisable ? [MODULE as Address] : []), + ...RETIRED_MODULES_V2, + ]) + : { disabled: [], transactions: [] }; + if (needsLegacyDisable && !includesModule(cleanup.disabled, MODULE as Address)) { throw new Error('Could not read your Safe. Please try again.'); } const transactions = [ - ...(prevLegacyModule - ? [ - { - to: safeAddress, - data: encodeFunctionData({ - abi: Safe_ABI, - functionName: 'disableModule', - args: [prevLegacyModule, MODULE as Address], - }), - }, - ] - : []), + ...cleanup.transactions, ...(fresh.moduleEnabled ? [] : [ @@ -1427,25 +1434,20 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp // with v1 re-enabled is reported as the v1 cardholder it is behaving like. const modeAfterBatch: SpendMode = needsRegistration ? 'cash' : v2.mode; - const transactions: { to: Address; data: `0x${string}` }[] = []; - // v1 has to go first and has to go entirely: while it is enabled v2 is inert by // design, and `registerSafe` refuses rather than letting one Safe hold two - // independent sets of spending caps. - if (v2.legacyEnabled) { - const prevModule = await findModulePredecessor(safeAddress, MODULE as Address); - if (!prevModule) throw new Error('Could not read your Safe. Please try again.'); - - transactions.push({ - to: safeAddress, - data: encodeFunctionData({ - abi: Safe_ABI, - functionName: 'disableModule', - args: [prevModule, MODULE as Address], - }), - }); + // independent sets of spending caps. A retired v2 core comes off in the same place — + // this is the one write a Safe left holding both cores is sure to make again. + const cleanup = await encodeModuleDisables(safeAddress, [ + ...(v2.legacyEnabled ? [MODULE as Address] : []), + ...RETIRED_MODULES_V2, + ]); + if (v2.legacyEnabled && !includesModule(cleanup.disabled, MODULE as Address)) { + throw new Error('Could not read your Safe. Please try again.'); } + const transactions: { to: Address; data: `0x${string}` }[] = [...cleanup.transactions]; + if (!v2.moduleEnabled) { transactions.push({ to: safeAddress, diff --git a/lib/config.ts b/lib/config.ts index 70847b82a..7926e9346 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,6 +1,8 @@ import { Address } from 'viem'; import { mainnet } from 'viem/chains'; +import { parseRetiredCashModulesV2 } from '@/lib/utils/safeModules'; + import type { CardProvider } from '@/lib/types'; export const EXPO_PUBLIC_BASE_URL = process.env.EXPO_PUBLIC_BASE_URL ?? ''; @@ -175,6 +177,15 @@ type Addresses = { * operation, and it only happens when a cardholder first chooses a mode v1 cannot serve. */ cashModuleV2: Address; + /** + * Earlier SolidCashModuleV2 cores that {@link cashModuleV2} replaced. + * + * A redeploy cannot move a Safe: the old core stays enabled on every Safe that consented to + * it, and the backend no longer reads it — so a Safe left there declines every card payment + * with `SAFE_NOT_REGISTERED` while the old lens still reports it as set up. Every write that + * puts a Safe on v2 disables whichever of these it still has enabled, in the same batch. + */ + retiredCashModulesV2: readonly Address[]; /** * SolidSpendLens — the cohort-aware read serving BOTH module generations from one call. * @@ -265,6 +276,10 @@ export const ADDRESSES: Addresses = { // `isCardSpendV2Configured` is what stops the app offering a mode it cannot execute. The env // overrides exist so a QA build can point at a testnet deployment without waiting for mainnet. cashModuleV2: (process.env.EXPO_PUBLIC_CASH_MODULE_V2_ADDRESS ?? ZERO_ADDRESS) as Address, + retiredCashModulesV2: parseRetiredCashModulesV2( + process.env.EXPO_PUBLIC_RETIRED_CASH_MODULE_V2_ADDRESSES, + process.env.EXPO_PUBLIC_CASH_MODULE_V2_ADDRESS, + ), spendLensV2: (process.env.EXPO_PUBLIC_SPEND_LENS_V2_ADDRESS ?? ZERO_ADDRESS) as Address, fastWithdrawManager: '0x0bA17eab7B6B2353eA4731c37A2cBA2a5AA4Ea1b', stargateOftUSDC: '0xAF54BE5B6eEc24d6BFACf1cce4eaF680A8239398', diff --git a/lib/utils/__tests__/safeModules.test.ts b/lib/utils/__tests__/safeModules.test.ts new file mode 100644 index 000000000..d46b18912 --- /dev/null +++ b/lib/utils/__tests__/safeModules.test.ts @@ -0,0 +1,137 @@ +import { Address, decodeFunctionData, getAddress } from 'viem'; + +import { Safe_ABI } from '@/lib/abis/Safe'; +import { + buildModuleDisables, + includesModule, + parseRetiredCashModulesV2, + RETIRED_FUSE_CASH_MODULE_V2, + SENTINEL_MODULES, +} from '@/lib/utils/safeModules'; + +/** + * The shape of a real stranded Safe (Fuse, 2026-09-25): the migration batch disabled v1 and + * enabled the retired v2 core, which went to the head of the list ahead of the 4337 module. + */ +const SAFE = getAddress('0xebaf1edf7164c9ecd5b8698e6bda9636ae09eabc'); +const V1 = getAddress('0x31F7f64769C6B2D4d3edd053421a0465FB371061'); +const RETIRED_V2 = getAddress('0xE2d4FB3d1eeD6Bdc3fD62A93ab35A33FC3c97b2B'); +const LIVE_V2 = getAddress('0xa98f2D4b79A465B265F68F745f5048BC369DA999'); +const SAFE_4337 = getAddress('0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226'); + +/** `[prevModule, module]` of each `disableModule` call, in batch order. */ +const disableArgs = (transactions: { to: Address; data: `0x${string}` }[]) => + transactions.map(({ to, data }) => { + expect(to).toBe(SAFE); + const decoded = decodeFunctionData({ abi: Safe_ABI, data }); + expect(decoded.functionName).toBe('disableModule'); + return decoded.args; + }); + +describe('buildModuleDisables', () => { + it('disables the retired core on a stranded Safe and skips v1, which is already off', () => { + const { disabled, transactions } = buildModuleDisables( + SAFE, + [RETIRED_V2, SAFE_4337], + [V1, RETIRED_V2], + ); + + expect(disabled).toEqual([RETIRED_V2]); + expect(disableArgs(transactions)).toEqual([[SENTINEL_MODULES, RETIRED_V2]]); + }); + + it('points at the list as earlier calls leave it when the targets are neighbours', () => { + // sentinel -> RETIRED_V2 -> V1 -> 4337. Removing RETIRED_V2 first re-points the sentinel + // at V1, so V1's predecessor is the sentinel — not RETIRED_V2, which a single snapshot says. + const { transactions } = buildModuleDisables( + SAFE, + [RETIRED_V2, V1, SAFE_4337], + [RETIRED_V2, V1], + ); + + expect(disableArgs(transactions)).toEqual([ + [SENTINEL_MODULES, RETIRED_V2], + [SENTINEL_MODULES, V1], + ]); + }); + + it('uses the neighbour that is still there when the later module goes first', () => { + const { transactions } = buildModuleDisables( + SAFE, + [RETIRED_V2, V1, SAFE_4337], + [V1, RETIRED_V2], + ); + + expect(disableArgs(transactions)).toEqual([ + [RETIRED_V2, V1], + [SENTINEL_MODULES, RETIRED_V2], + ]); + }); + + it('removes a module deeper in the list from its actual predecessor', () => { + const { transactions } = buildModuleDisables( + SAFE, + [LIVE_V2, SAFE_4337, RETIRED_V2], + [RETIRED_V2], + ); + + expect(disableArgs(transactions)).toEqual([[SAFE_4337, RETIRED_V2]]); + }); + + it('matches regardless of checksum case and reports the address as the Safe lists it', () => { + const { disabled, transactions } = buildModuleDisables( + SAFE, + [RETIRED_V2, SAFE_4337], + [RETIRED_V2.toLowerCase() as Address], + ); + + expect(disabled).toEqual([RETIRED_V2]); + expect(disableArgs(transactions)).toEqual([[SENTINEL_MODULES, RETIRED_V2]]); + }); + + it('sends nothing when none of the targets are enabled', () => { + expect(buildModuleDisables(SAFE, [LIVE_V2, SAFE_4337], [V1, RETIRED_V2])).toEqual({ + disabled: [], + transactions: [], + }); + }); + + it('leaves the list it was given untouched', () => { + const modules = [RETIRED_V2, V1, SAFE_4337]; + buildModuleDisables(SAFE, modules, [RETIRED_V2, V1]); + + expect(modules).toEqual([RETIRED_V2, V1, SAFE_4337]); + }); +}); + +describe('includesModule', () => { + it('ignores checksum case', () => { + expect(includesModule([V1], V1.toLowerCase() as Address)).toBe(true); + expect(includesModule([RETIRED_V2], V1)).toBe(false); + }); +}); + +describe('parseRetiredCashModulesV2', () => { + it('defaults to the 2026-09-24 retiree when unset or blank', () => { + expect(parseRetiredCashModulesV2(undefined, LIVE_V2)).toEqual([RETIRED_FUSE_CASH_MODULE_V2]); + // Helm and EAS render an unset key as an empty string, not as absent. + expect(parseRetiredCashModulesV2(' ', LIVE_V2)).toEqual([RETIRED_FUSE_CASH_MODULE_V2]); + }); + + it('reads a comma-separated list and drops entries that are not addresses', () => { + const other = getAddress('0x34c3564C4EBC29f90B4DD200509880C40Fd26E32'); + + expect(parseRetiredCashModulesV2(` ${RETIRED_V2} , nope,,${other}`, LIVE_V2)).toEqual([ + RETIRED_V2, + other, + ]); + }); + + it('never lists the live core, whatever the env says', () => { + expect( + parseRetiredCashModulesV2(`${RETIRED_V2},${LIVE_V2.toLowerCase()}`, ` ${LIVE_V2} `), + ).toEqual([RETIRED_V2]); + // A build still pointed at the retiree must not disable the module it registers on. + expect(parseRetiredCashModulesV2(undefined, RETIRED_V2)).toEqual([]); + }); +}); diff --git a/lib/utils/safeModules.ts b/lib/utils/safeModules.ts new file mode 100644 index 000000000..90dfd0cf2 --- /dev/null +++ b/lib/utils/safeModules.ts @@ -0,0 +1,77 @@ +import { Address, encodeFunctionData, isAddress } from 'viem'; + +import { Safe_ABI } from '@/lib/abis/Safe'; + +/** + * Head of a Safe's module linked list. `disableModule(prevModule, module)` needs the + * entry pointing at the one being removed, and for the most recently enabled module + * that pointer is the sentinel itself rather than another module's address. + */ +export const SENTINEL_MODULES = '0x0000000000000000000000000000000000000001' as Address; + +/** + * `disableModule` calls for each of `targets` present in `modules`, in batch order. + * + * `modules` is the Safe's list as `getModulesPaginated` returns it, walking outwards from the + * sentinel. Predecessors are worked out against the list as the calls before them leave it, not + * against that one snapshot: removing a module re-points its predecessor at its successor, so two + * neighbours read off the same snapshot would give the second call a pointer to a module that is + * already gone — GS103, and the whole batch with it. + * + * `disabled` names what the calls remove, so a caller that needs one of them can tell "already + * off" from "could not find it". + */ +export const buildModuleDisables = ( + safeAddress: Address, + modules: readonly Address[], + targets: readonly Address[], +): { disabled: Address[]; transactions: { to: Address; data: `0x${string}` }[] } => { + const remaining = [...modules]; + const disabled: Address[] = []; + const transactions: { to: Address; data: `0x${string}` }[] = []; + + for (const target of targets) { + const index = remaining.findIndex(entry => entry.toLowerCase() === target.toLowerCase()); + if (index === -1) continue; + + transactions.push({ + to: safeAddress, + data: encodeFunctionData({ + abi: Safe_ABI, + functionName: 'disableModule', + args: [index === 0 ? SENTINEL_MODULES : remaining[index - 1], remaining[index]], + }), + }); + disabled.push(remaining[index]); + remaining.splice(index, 1); + } + + return { disabled, transactions }; +}; + +/** Whether `modules` includes `target`, ignoring checksum case. */ +export const includesModule = (modules: readonly Address[], target: Address) => + modules.some(entry => entry.toLowerCase() === target.toLowerCase()); + +/** + * The Fuse v2 core replaced by the 2026-09-24 redeploy. Safes kept being moved onto it by builds + * that still carried its address, so it is the default rather than something every environment has + * to remember to set. + */ +export const RETIRED_FUSE_CASH_MODULE_V2 = '0xE2d4FB3d1eeD6Bdc3fD62A93ab35A33FC3c97b2B' as Address; + +/** + * The retired v2 cores to clean off a Safe: a comma-separated env list, or the known one when unset. + * + * The live core is always dropped from the list. A build whose env names its own module as retired + * would otherwise disable, in the same batch, the module it is about to register on. + */ +export const parseRetiredCashModulesV2 = ( + configured: string | undefined, + current: string | undefined, +): Address[] => + (configured?.trim() ? configured : RETIRED_FUSE_CASH_MODULE_V2) + .split(',') + .map(entry => entry.trim()) + .filter(entry => isAddress(entry, { strict: false })) + .filter(entry => entry.toLowerCase() !== current?.trim().toLowerCase()) as Address[];