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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
15 changes: 12 additions & 3 deletions components/DepositOption/WalletDepositAddress/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
25 changes: 13 additions & 12 deletions components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<Text
className={cn('text-[16px] leading-5', isSelected ? 'font-semibold' : 'font-medium')}
style={isSelected ? styles.selectedLabel : styles.label}
>
{/* 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. */}
<Text style={isSelected ? styles.selectedLabel : styles.label}>
{ROUTE_LABEL[route]}
</Text>
</Pressable>
Expand All @@ -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;
25 changes: 18 additions & 7 deletions components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ import {
canAffordUpgrade,
findOffer,
formatFuse,
formatFuseHeld,
formatFuseShortfall,
formatLockDuration,
formatMembershipDate,
formatUsd,
formatUsdHeld,
membershipDateLabel,
nextPurchasableTier,
remainingFuseForTier,
Expand All @@ -45,7 +48,8 @@ const TIER_LABELS: Record<RewardsTier, string> = {
};

/** 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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -262,7 +267,7 @@ export default function UpgradeTierScreen() {
<TierDetailRow label="Annual Fee" value={formatUsd(offer.annualFeeUsd)} withDivider />
<TierDetailRow
label="Balance"
value={`${formatUsd(availableUsdc).replace('$', '')} USDC`}
value={`${formatUsdHeld(availableUsdc).replace('$', '')} USDC`}
/>
</>
) : (
Expand All @@ -278,7 +283,7 @@ export default function UpgradeTierScreen() {
onExplain={() => void Linking.openURL(MEMBERSHIP_HELP_URL)}
withDivider
/>
<TierDetailRow label="Balance" value={`${formatFuse(availableFuse)} FUSE`} />
<TierDetailRow label="Balance" value={`${formatFuseHeld(availableFuse)} FUSE`} />
</>
)}
</View>
Expand Down Expand Up @@ -306,16 +311,22 @@ export default function UpgradeTierScreen() {
</Text>
</Button>

{/* 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 ? (
<Pressable
accessibilityRole="button"
onPress={handleTopUp}
className="mt-4 transition-opacity active:opacity-60"
>
<Text className="text-center text-[14px] leading-5 text-white/50">
{formatFuse(remainingFuse - availableFuse)} FUSE short — add more to Savings
{formatFuseShortfall(shortfallFuse)} FUSE short — add more to Savings
</Text>
</Pressable>
) : null}
Expand Down
28 changes: 26 additions & 2 deletions hooks/useTierMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
71 changes: 71 additions & 0 deletions lib/__tests__/tierUpgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import {
availableRoutes,
canAffordUpgrade,
formatFuse,
formatFuseHeld,
formatFuseShortfall,
formatLockDuration,
formatMembershipDate,
formatMembershipDay,
formatUsd,
formatUsdHeld,
fuseForShares,
fuseSharesForAmount,
membershipDateLabel,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down
48 changes: 46 additions & 2 deletions lib/tierUpgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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 = (
Expand Down Expand Up @@ -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".
*
Expand All @@ -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 '';
Expand Down
Loading