Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 42 additions & 40 deletions hooks/useCardSpendRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
? []
: [
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
@@ -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 ?? '';
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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',
Expand Down
137 changes: 137 additions & 0 deletions lib/utils/__tests__/safeModules.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
77 changes: 77 additions & 0 deletions lib/utils/safeModules.ts
Original file line number Diff line number Diff line change
@@ -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[];
Loading