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