From a92006e24dc8aea0199b62aac4318217d8d2adb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 13:45:28 +0000 Subject: [PATCH 1/2] fix(rewards): support URL, the switch label, and default deposits to Fuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The membership "Learn more" pointed at docs.solid.money/rewards/tiers, which is not a URL we serve. It now points at the rewards terms on support.solid.xyz. That was the only occurrence, in any of the four repos. - The route switch's label is styled entirely through StyleSheet now, with no className at all. The colour was already an inline style; the weight and size follow it so nothing about the label depends on class resolution, which is what turned a black-on-white label white-on-white. Matches how TierUpgradeCard and TierSwitcher write theirs. - The deposit-address screen opened on Ethereum. That address is the user's Safe on whichever chain is selected — it is not a bridge, so a deposit made on the default lands on the default, and Ethereum is both the most expensive gas and the one chain the balance cannot then be used from. It opens on Fuse/USDC: the card, the vaults and the annual membership charge are all on Fuse, and the membership charge can only ever move Fuse USDC.e. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGzkz4QFpEZVg6GpNH311x --- .../__tests__/walletDepositConstants.test.ts | 16 ++++++++++-- .../WalletDepositAddress/constants.ts | 15 ++++++++--- .../UpgradeTier/UpgradeRouteSwitch.tsx | 25 ++++++++++--------- .../UpgradeTier/UpgradeTierScreen.tsx | 3 ++- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/components/DepositOption/WalletDepositAddress/__tests__/walletDepositConstants.test.ts b/components/DepositOption/WalletDepositAddress/__tests__/walletDepositConstants.test.ts index 224a3ccb..aa0fb653 100644 --- a/components/DepositOption/WalletDepositAddress/__tests__/walletDepositConstants.test.ts +++ b/components/DepositOption/WalletDepositAddress/__tests__/walletDepositConstants.test.ts @@ -73,7 +73,19 @@ describe('getWalletDepositNetworks', () => { }); describe('getDefaultWalletDepositSelection', () => { - it('opens on USDC over Ethereum', () => { - expect(getDefaultWalletDepositSelection()).toEqual({ chainId: mainnet.id, symbol: 'USDC' }); + /** + * Fuse, not Ethereum. The address shown is the user's Safe on whichever chain + * is picked — it is not a bridge — so a deposit made on the default lands on + * the default. Everything the balance is then spent on (the card, the vaults, + * the annual membership charge) is on Fuse, and the membership charge can + * only ever move Fuse USDC.e. + */ + it('opens on USDC over Fuse', () => { + expect(getDefaultWalletDepositSelection()).toEqual({ chainId: fuse.id, symbol: 'USDC' }); + }); + + it('offers USDC on the chain it opens on', () => { + const { chainId, symbol } = getDefaultWalletDepositSelection(); + expect(getWalletDepositTokens(chainId).map(token => token.symbol)).toContain(symbol); }); }); diff --git a/components/DepositOption/WalletDepositAddress/constants.ts b/components/DepositOption/WalletDepositAddress/constants.ts index 00fc56e4..c5040fcc 100644 --- a/components/DepositOption/WalletDepositAddress/constants.ts +++ b/components/DepositOption/WalletDepositAddress/constants.ts @@ -98,11 +98,20 @@ export const getWalletDepositNetworks = (): WalletDepositNetwork[] => { export const getWalletDepositMinimum = (chainId: number, symbol: string): number => MINIMUM_DEPOSIT_BY_TOKEN[symbol] ?? MINIMUM_DEPOSIT_BY_CHAIN[chainId] ?? DEFAULT_MINIMUM_DEPOSIT; -/** The pairing the screen opens on: USDC on Ethereum, falling back if either is off. */ +/** + * The pairing the screen opens on: USDC on Fuse, falling back if either is off. + * + * Fuse rather than Ethereum because this address is the user's Safe on the + * chain they pick, not a bridge — what lands on Ethereum stays on Ethereum. + * Everything the app then spends that balance on lives on Fuse: the card, the + * vaults, and the annual membership charge, which can only ever move Fuse + * USDC.e. Opening on Ethereum put the most expensive gas and the one chain the + * balance cannot be used from in front of the user by default. + */ export const getDefaultWalletDepositSelection = (): { chainId: number; symbol: string } => { const networks = getWalletDepositNetworks(); - const chainId = networks.some(network => network.chainId === mainnet.id) - ? mainnet.id + const chainId = networks.some(network => network.chainId === fuse.id) + ? fuse.id : (networks[0]?.chainId ?? mainnet.id); const tokens = getWalletDepositTokens(chainId); const symbol = diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx index e73b214e..27da880c 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx @@ -48,16 +48,15 @@ const UpgradeRouteSwitch = ({ routes, selected, onSelect }: UpgradeRouteSwitchPr isSelected && 'bg-white', )} > - {/* The colour is a style, not a class. `Text` composes its own - class with whatever the surrounding text context provides, and - a selected label that loses that merge is white on white — an - empty pill, which is what this rendered as. An inline style - cannot be merged away. Every other black-on-light label in the - rewards screens is written the same way. */} - + {/* Styled entirely through StyleSheet, with no className at all. + `Text` composes its own class with whatever the surrounding text + context provides, and a selected label whose colour is lost in + that merge is black-on-white turning white-on-white — an empty + pill, which is how this shipped. An inline style cannot be + merged away, and the weight goes with it so nothing about this + label depends on class resolution. Matches how TierUpgradeCard + and TierSwitcher write their labels. */} + {ROUTE_LABEL[route]} @@ -67,9 +66,11 @@ const UpgradeRouteSwitch = ({ routes, selected, onSelect }: UpgradeRouteSwitchPr ); }; +const LABEL_BASE = { fontSize: 16, lineHeight: 20 } as const; + const styles = StyleSheet.create({ - label: { color: '#FFFFFF' }, - selectedLabel: { color: '#000000' }, + label: { ...LABEL_BASE, color: '#FFFFFF', fontFamily: 'MonaSans_500Medium' }, + selectedLabel: { ...LABEL_BASE, color: '#000000', fontFamily: 'MonaSans_600SemiBold' }, }); export default UpgradeRouteSwitch; diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx index e83f6315..8166c2ec 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx @@ -45,7 +45,8 @@ const TIER_LABELS: Record = { }; /** Where "Learn more" and "How to earn points?" send the user. */ -const MEMBERSHIP_HELP_URL = 'https://docs.solid.money/rewards/tiers'; +const MEMBERSHIP_HELP_URL = + 'https://support.solid.xyz/en/articles/15613716-solid-rewards-terms-and-conditions'; /** * Buying a tier: what it costs by each route, what the user has, and one action. From bb313e92084a236bbee995803754a32a588f927f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 14:12:32 +0000 Subject: [PATCH 2/2] fix(rewards): stop the upgrade screen contradicting its own balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reports, two causes. The screen compares balances to the sixth decimal and displays them as whole FUSE, and rounded the display to nearest. A Safe holding 14,999.6 FUSE showed "15,000" next to a requirement of "15,000" and still offered "Top up" — with a footer reading "0 FUSE short — add more to Savings", a shortfall too small to render printed as zero and a top-up of nothing to act on. Held amounts now round DOWN and shortfalls round UP, so the displayed numbers can never claim the user has enough when they do not, and a sub-unit gap is stated as the 1 FUSE that would actually clear it. The footer is hidden entirely once there is no shortfall left to state. Deliberately not fixed by loosening the comparison to match the display. Locking short of the threshold succeeds on-chain and grants no tier — the backend measures the locked position against it — so a user waved through on a rounded balance commits their FUSE for a year and gets nothing. The tolerance that was added, 1e-9, exists only to absorb the ulps of a bigint that has been through a decimal string and a double; it is nine orders of magnitude below anything anyone could be short by, and a test pins that 49,999.999 against 50,000 is still refused. Separately, the balances never refreshed: `staleTime` with no interval, on a screen whose whole job is to notice a deposit arriving. The top-up it sends the user to settles somewhere else — the savings sheet, or the deposit drawer — so nothing invalidated this on the way back, and the CTA stayed "Top up" with the FUSE already in the Safe. Now polled every 5s, roughly one Fuse block, and refetched on mount and focus. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGzkz4QFpEZVg6GpNH311x --- .../UpgradeTier/UpgradeTierScreen.tsx | 22 ++++-- hooks/useTierMembership.ts | 28 +++++++- lib/__tests__/tierUpgrade.test.ts | 71 +++++++++++++++++++ lib/tierUpgrade.ts | 48 ++++++++++++- 4 files changed, 159 insertions(+), 10 deletions(-) diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx index 8166c2ec..5e817594 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx @@ -20,9 +20,12 @@ import { canAffordUpgrade, findOffer, formatFuse, + formatFuseHeld, + formatFuseShortfall, formatLockDuration, formatMembershipDate, formatUsd, + formatUsdHeld, membershipDateLabel, nextPurchasableTier, remainingFuseForTier, @@ -173,6 +176,7 @@ export default function UpgradeTierScreen() { const remainingFuse = remainingFuseForTier(offer, membership.lock.lockedFuse); const availableFuse = chain?.fuse ?? 0; const availableUsdc = chain?.usdcAmount ?? 0; + const shortfallFuse = Math.max(0, remainingFuse - availableFuse); const affordable = canAffordUpgrade({ route, @@ -263,7 +267,7 @@ export default function UpgradeTierScreen() { ) : ( @@ -279,7 +283,7 @@ export default function UpgradeTierScreen() { onExplain={() => void Linking.openURL(MEMBERSHIP_HELP_URL)} withDivider /> - + )} @@ -307,16 +311,22 @@ export default function UpgradeTierScreen() { - {/* Only ever shown when it changes the decision: the user has the money - but it is in the wrong place, which "Top up" does not describe. */} - {!affordable && route === 'lock' && availableFuse > 0 ? ( + {/* Only when it changes the decision: the user has some FUSE but not + enough, which "Top up" alone does not describe. + + `shortfallFuse > 0` is what stops the line this screen used to end + on — "0 FUSE short — add more to Savings" — which appeared whenever + the gap was under half a unit, told the user nothing, and pointed at + a top-up of nothing. Affordable hides it outright; a sub-unit gap is + rounded up to the 1 FUSE that would actually clear it. */} + {!affordable && route === 'lock' && availableFuse > 0 && shortfallFuse > 0 ? ( - {formatFuse(remainingFuse - availableFuse)} FUSE short — add more to Savings + {formatFuseShortfall(shortfallFuse)} FUSE short — add more to Savings ) : null} diff --git a/hooks/useTierMembership.ts b/hooks/useTierMembership.ts index 9e50d7f7..425609dc 100644 --- a/hooks/useTierMembership.ts +++ b/hooks/useTierMembership.ts @@ -25,7 +25,17 @@ import { selectedRewardsUserId, useRewardsUpgradeStore } from '@/store/useReward import { useUserStore } from '@/store/useUserStore'; export const TIER_MEMBERSHIP_QUERY_KEY = 'tierMembership'; -export const TIER_UPGRADE_BALANCES_QUERY_KEY = 'tierUpgradeBalances'; +export /** + * How often the upgrade screen re-reads the Safe's balances. + * + * Five seconds: a Fuse block is ~5s, so this is roughly one read per block — + * fast enough that a deposit landing while the user watches flips the CTA + * within a block or two, and slow enough not to hammer the node from a screen + * someone may leave open. + */ +const BALANCE_POLL_MS = 5_000; + +const TIER_UPGRADE_BALANCES_QUERY_KEY = 'tierUpgradeBalances'; /** soFUSE shares, the accountant rate and the share token all use 18 decimals. */ const SHARE_DECIMALS = 18; @@ -101,7 +111,21 @@ export const useTierUpgradeChainState = (contracts?: { moduleAddress, ], enabled: Boolean(safeAddress), - staleTime: 15_000, + // Polled, not cached-and-forgotten. This drives the difference between + // "Top up" and "Review upgrade", and the top-up it sends the user to + // settles somewhere else entirely — a savings deposit or the deposit + // drawer — so nothing invalidates this on the way back. Without a poll the + // screen kept showing the pre-deposit balance, and the CTA stayed "Top up" + // with the FUSE already sitting in the Safe. + // + // `staleTime` is under the interval so a remount mid-flow refetches rather + // than serving the balance the user is standing there watching for. + staleTime: BALANCE_POLL_MS / 2, + refetchInterval: BALANCE_POLL_MS, + // The interesting case is the user coming back from topping up, which is + // exactly a remount or a refocus. + refetchOnMount: true, + refetchOnWindowFocus: true, queryFn: async () => { const client = publicClient(fuse.id); diff --git a/lib/__tests__/tierUpgrade.test.ts b/lib/__tests__/tierUpgrade.test.ts index 43ba04de..88bed7cd 100644 --- a/lib/__tests__/tierUpgrade.test.ts +++ b/lib/__tests__/tierUpgrade.test.ts @@ -2,10 +2,13 @@ import { availableRoutes, canAffordUpgrade, formatFuse, + formatFuseHeld, + formatFuseShortfall, formatLockDuration, formatMembershipDate, formatMembershipDay, formatUsd, + formatUsdHeld, fuseForShares, fuseSharesForAmount, membershipDateLabel, @@ -136,6 +139,44 @@ describe('availableRoutes', () => { describe('canAffordUpgrade', () => { const base = { offer: offer(), lockedFuse: 0, availableFuse: 0, availableUsdc: 0 }; + /** + * The bug behind "0 FUSE short — add more to Savings". + * + * Both sides of this come out of on-chain bigints through a decimal string + * and a double, so a position worth exactly the threshold can land a few ulps + * under it. Strict >= then said "Top up" to someone holding precisely enough, + * and the shortfall — far too small to render — printed as zero. + */ + it('treats a position a few ulps under the threshold as enough', () => { + const offerAt = offer({ lockFuse: 50_000 }); + + expect( + canAffordUpgrade({ + ...base, + offer: offerAt, + route: 'lock', + availableFuse: 50_000 - 1e-12, + }), + ).toBe(true); + }); + + /** + * And the other side of it, which matters more: locking short of the + * threshold succeeds on-chain and grants no tier, because the backend + * measures the locked position against the threshold. A user waved through + * here commits their FUSE for a year and gets nothing, so the tolerance has + * to stay far below anything anyone could actually be short by. + */ + it('does not wave through a real shortfall, however small', () => { + const offerAt = offer({ lockFuse: 50_000 }); + + for (const availableFuse of [49_999.9, 49_999.99, 49_999.999]) { + expect(canAffordUpgrade({ ...base, offer: offerAt, route: 'lock', availableFuse })).toBe( + false, + ); + } + }); + it('needs the whole annual fee in USDC', () => { expect(canAffordUpgrade({ ...base, route: 'cash', availableUsdc: 198.99 })).toBe(false); expect(canAffordUpgrade({ ...base, route: 'cash', availableUsdc: 199 })).toBe(true); @@ -307,6 +348,36 @@ describe('formatting', () => { expect(formatUsd(undefined)).toBe(''); }); + /** + * The display half of the same bug. The screen compares to the sixth decimal + * and shows whole FUSE, so rounding a held balance to nearest let it print + * "15,000" beside a requirement of "15,000" and still offer "Top up" — the + * screen contradicting its own numbers. + */ + it('rounds a held balance down, so it never claims enough', () => { + expect(formatFuseHeld(14_999.6)).toBe('14,999'); + expect(formatFuseHeld(15_000)).toBe('15,000'); + expect(formatFuseHeld(15_000.9)).toBe('15,000'); + }); + + it('rounds a shortfall up, so topping it up always clears it', () => { + expect(formatFuseShortfall(0.4)).toBe('1'); + expect(formatFuseShortfall(1)).toBe('1'); + expect(formatFuseShortfall(1.1)).toBe('2'); + }); + + it('never writes a held balance above what is held, or a shortfall below it', () => { + for (const amount of [0.1, 0.9, 1.5, 14_999.6, 50_000.4]) { + expect(Number(formatFuseHeld(amount).replace(/,/g, ''))).toBeLessThanOrEqual(amount); + expect(Number(formatFuseShortfall(amount).replace(/,/g, ''))).toBeGreaterThanOrEqual(amount); + } + }); + + it('rounds a USDC balance down to the cent', () => { + expect(formatUsdHeld(198.999)).toBe('$198.99'); + expect(formatUsdHeld(199)).toBe('$199.00'); + }); + it('writes a lock term in months', () => { expect(formatLockDuration(365)).toBe('12 months'); expect(formatLockDuration(180)).toBe('6 months'); diff --git a/lib/tierUpgrade.ts b/lib/tierUpgrade.ts index d594767c..3d8e6778 100644 --- a/lib/tierUpgrade.ts +++ b/lib/tierUpgrade.ts @@ -80,6 +80,25 @@ export const availableRoutes = (offer: TierOffer | undefined): TierUpgradeRoute[ return routes; }; +/** + * Below this, a shortfall is not a shortfall. + * + * Both sides come out of on-chain bigints through a decimal string and a + * double, so a position worth exactly the threshold can land a few ulps under + * it. A billionth of a FUSE is nine orders of magnitude below anything anyone + * could deposit to fix, so treating it as a shortfall only ever strands a user + * who does have enough. + * + * Kept this small on purpose: a loose tolerance would wave through someone who + * is genuinely short, and locking short of the threshold succeeds on-chain + * while granting no tier. + */ +const AMOUNT_EPSILON = 1e-9; + +/** Whether `available` covers `required`, ignoring representation noise. */ +export const covers = (available: number, required: number): boolean => + available + AMOUNT_EPSILON >= required; + /** * Whether the user can complete the upgrade now, or has to top up first. * @@ -102,8 +121,10 @@ export const canAffordUpgrade = ({ availableUsdc: number; }): boolean => route === 'cash' - ? offer.annualFeeUsd !== null && offer.annualFeeUsd > 0 && availableUsdc >= offer.annualFeeUsd - : availableFuse >= remainingFuseForTier(offer, lockedFuse); + ? offer.annualFeeUsd !== null && + offer.annualFeeUsd > 0 && + covers(availableUsdc, offer.annualFeeUsd) + : covers(availableFuse, remainingFuseForTier(offer, lockedFuse)); /** The offer for one tier, or undefined when it is not sold. */ export const findOffer = ( @@ -189,6 +210,26 @@ export const formatMembershipDay = (iso: string | null | undefined): string => { export const formatFuse = (amount: number): string => amount.toLocaleString('en-US', { maximumFractionDigits: 0 }); +/** + * A balance the user holds, rounded DOWN. + * + * The screen shows whole FUSE while it compares to the sixth decimal, and + * rounding a balance to nearest is how those two disagree in public: a Safe + * holding 14,999.6 FUSE displayed "15,000", sat beside a requirement of + * "15,000", and still offered "Top up" — the screen contradicting its own + * numbers. Rounding held amounts down and shortfalls up means the displayed + * numbers can never claim the user has enough when they do not. + * + * Deliberately not solved by loosening the comparison instead. Locking 14,999.6 + * FUSE succeeds on-chain and grants no tier — the backend measures the locked + * position against the threshold — so a user waved through on a rounded balance + * commits their FUSE for a year and gets nothing for it. + */ +export const formatFuseHeld = (amount: number): string => formatFuse(Math.floor(amount)); + +/** A shortfall, rounded UP — so topping it up always clears it. */ +export const formatFuseShortfall = (amount: number): string => formatFuse(Math.ceil(amount)); + /** * A USD figure with cents: "$199.00". * @@ -201,6 +242,9 @@ export const formatUsd = (amount: number | null | undefined): string => ? '' : `$${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +/** A USDC balance, rounded down to the cent — see `formatFuseHeld`. */ +export const formatUsdHeld = (amount: number): string => formatUsd(Math.floor(amount * 100) / 100); + /** A lock term in the words the design uses: "12 months". */ export const formatLockDuration = (days: number): string => { if (days <= 0) return '';