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..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, @@ -45,7 +48,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. @@ -172,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, @@ -262,7 +267,7 @@ export default function UpgradeTierScreen() { ) : ( @@ -278,7 +283,7 @@ export default function UpgradeTierScreen() { onExplain={() => void Linking.openURL(MEMBERSHIP_HELP_URL)} withDivider /> - + )} @@ -306,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 '';