From b8d06c00b51168b3902739f07a7cc456aab382b3 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Tue, 20 Oct 2026 01:56:08 -0400 Subject: [PATCH 1/7] fix(share): offer only the credits a wallet can actually share The Share Credits page read the store's `creditBalance`, which is the payment service's `effectiveBalance`: owned credits plus what other wallets shared with this one. Received credits can be spent but not re-shared, so the page offered more than the service would approve. A user with 3.55 credits shared to them saw 37.46 "available to share" when only 33.91 was. The panel now fetches its own balance and uses `winc`, the figure turbo-sdk documents as what a user "can currently spend or share". It shows credits shared with you as spend-only, and "Already Shared" now has a real value instead of a hard-coded 0. Also fixed on the page: - the success message said "0 credits", because the form reset the amount before the message rendered it - a zero balance hid the "Available to Share" row instead of showing 0 - the shown balance is floored, so copying it back is never over-limit - the approved winc is clamped to the balance, so sharing the maximum can't round a winc above it CLAUDE.md gains a gotcha on spendable vs shareable balances and an updated BaseModal consumer count. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LxLDipDTwGmYw9bgV4cjoJ --- CLAUDE.md | 4 +- src/components/panels/ShareCreditsPanel.tsx | 82 ++++++++++++++------- src/utils/shareableCredits.test.ts | 68 +++++++++++++++++ src/utils/shareableCredits.ts | 67 +++++++++++++++++ 4 files changed, 193 insertions(+), 28 deletions(-) create mode 100644 src/utils/shareableCredits.test.ts create mode 100644 src/utils/shareableCredits.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8a031c54..6a96781b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,8 @@ Before diving in, these are the most common issues: 10. **Free tier has two axes**: `useFreeUploadLimit()` returns `{ freeUploadLimitBytes, freeTier }`. `freeUploadLimitBytes` (== `freeTier.maxItemBytes`) is the per-item size cap used by `isFileFree()`. `freeTier.lifetimeBytes` / `freeTier.ipBytes` are separate lifetime/IP quotas (0 = uncapped) shown in UI messaging — don't conflate them with the per-item limit. +11. **The store's `creditBalance` is spendable, not shareable**: `useCreditBalance` fills it from the payment service's `effectiveBalance`, which includes credits *other wallets shared with this one*. Those can pay for uploads but cannot be shared onward; only `winc` can. Anything that offers to share or hand credits on reads `toShareableBalance()` (`utils/shareableCredits.ts`), as `ShareCreditsPanel` does. Reading `creditBalance` there offers more than the service will approve. + ## Architecture Overview ### Application Structure @@ -769,7 +771,7 @@ its own surface or horizontal padding — that's a frame inside a frame and double inset. Panels written for a standalone page (the `/topup` fiat and crypto steps) carry both and gate them on being embedded. -All modal chrome lives in `components/modals/BaseModal.tsx` (~22 consumers). It provides Escape-to-close, a Tab focus trap, `role="dialog"`/`aria-modal`, body scroll lock, focus restore on close, and a labelled close button — **don't reimplement any of that in a consumer.** Two things to know: +All modal chrome lives in `components/modals/BaseModal.tsx` (~28 consumers). It provides Escape-to-close, a Tab focus trap, `role="dialog"`/`aria-modal`, body scroll lock, focus restore on close, and a labelled close button — **don't reimplement any of that in a consumer.** Two things to know: - `showCloseButton` defaults to **true**. A dismiss affordance is opt-out, not opt-in. - Modals **nest** (`WalletSelectionModal` renders `BlockingMessageModal` inside its own `BaseModal`), so BaseModal keeps a module-level stack: only the topmost instance answers Escape and traps Tab, and the scroll lock lifts only when the last one closes. Preserve that if you touch it. diff --git a/src/components/panels/ShareCreditsPanel.tsx b/src/components/panels/ShareCreditsPanel.tsx index c4160792..b4b06255 100644 --- a/src/components/panels/ShareCreditsPanel.tsx +++ b/src/components/panels/ShareCreditsPanel.tsx @@ -1,4 +1,5 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { TurboFactory, TurboAuthenticatedClient, @@ -8,13 +9,18 @@ import { useStore } from '../../store/useStore'; import { wincPerCredit } from '../../constants'; import { useTurboConfig } from '../../hooks/useTurboConfig'; import { ExternalLink, Shield, ArrowRight, Share2, Book, Lightbulb, Code, CheckCircle, Wallet } from 'lucide-react'; -import { promptSignIn } from '../../utils'; +import { promptSignIn, getTurboBalance } from '../../utils'; +import { toShareableBalance, toApprovedWinc, wincToCredits } from '../../utils/shareableCredits'; import { useWincForOneGiB } from '../../hooks/useWincForOneGiB'; import { validateWalletAddress, getWalletTypeLabel } from '../../utils/addressValidation'; import { useEthereumTurboClient } from '../../hooks/useEthereumTurboClient'; import { useWallet } from '@solana/wallet-adapter-react'; import { buttonClass } from '@/components/button'; +// Rounded down so a figure the user copies back into the form never exceeds +// what can actually be shared. +const floorTo4 = (credits: number) => (Math.floor(credits * 10_000) / 10_000).toFixed(4); + interface Approval { approvedAddress: string; approvedWincAmount: string; @@ -22,7 +28,8 @@ interface Approval { } export default function ShareCreditsPanel() { - const { address, walletType, creditBalance } = useStore(); + const { address, walletType, configMode } = useStore(); + const paymentAvailable = useStore((s) => s.isPaymentServiceAvailable()); const wincForOneGiB = useWincForOneGiB(); const turboConfig = useTurboConfig(); const { createEthereumTurboClient } = useEthereumTurboClient(); @@ -78,13 +85,27 @@ export default function ShareCreditsPanel() { const [revokeAddress, setRevokeAddress] = useState(''); const [revoking, setRevoking] = useState(false); - // Use creditBalance from store instead of fetching separately - const balance = useMemo(() => { - if (creditBalance > 0) { - return { winc: String(creditBalance * wincPerCredit) }; - } - return null; - }, [creditBalance]); + /* + Fetched here rather than read from the store's `creditBalance`, which is + `effectiveBalance`: it includes credits other wallets shared with this one, + which can be spent but not re-shared. Reading it offered more than the + service would approve. The 'credit-balance' prefix keeps this query in the + set that `refresh-balance` invalidates. + */ + const { data: shareBalance, isLoading: balanceLoading } = useQuery({ + queryKey: ['credit-balance', 'shareable', address, walletType, configMode], + enabled: !!address && !!walletType && paymentAvailable, + staleTime: 30_000, + queryFn: async () => toShareableBalance(await getTurboBalance(address!, walletType!)), + }); + const shareableWinc = shareBalance?.shareableWinc ?? 0n; + const shareableCredits = wincToCredits(shareableWinc); + const receivedCredits = wincToCredits(shareBalance?.receivedWinc ?? 0n); + const sharedOutCredits = wincToCredits(shareBalance?.sharedOutWinc ?? 0n); + + // The amount of the share that just succeeded; the form resets to 0 before + // the success message renders, so it can't read `creditAmount`. + const [sharedAmount, setSharedAmount] = useState(0); const handleShare = async () => { if (!address || !approvedAddress || creditAmount < 0.01) { @@ -97,7 +118,7 @@ export default function ShareCreditsPanel() { try { const turbo = await createTurboClient(); - const approvedWincAmount = (creditAmount * wincPerCredit).toFixed(); + const approvedWincAmount = toApprovedWinc(creditAmount, shareableWinc).toString(); await turbo.shareCredits({ approvedAddress, @@ -105,6 +126,7 @@ export default function ShareCreditsPanel() { expiresBySeconds: expiresBySeconds > 0 ? expiresBySeconds : undefined, }); + setSharedAmount(creditAmount); setSuccess(true); // Refresh balance in header @@ -174,9 +196,6 @@ export default function ShareCreditsPanel() { ); } - const spendPower = creditBalance > 0 ? creditBalance.toFixed(4) : '0'; - // For now, we don't have shared credits info without authenticated client - const sharedCredits = '0'; const givenApprovals: Approval[] = []; return ( @@ -198,23 +217,31 @@ export default function ShareCreditsPanel() {
{/* Current Balance */} - {balance && ( + {paymentAvailable && (
Available to Share:
- {spendPower} Credits - {wincForOneGiB && ( + + {balanceLoading ? '…' : floorTo4(shareableCredits)} Credits + + {!balanceLoading && wincForOneGiB && (
- ~{((creditBalance * wincPerCredit) / Number(wincForOneGiB)).toFixed(2)} GiB capacity + ~{((shareableCredits * wincPerCredit) / Number(wincForOneGiB)).toFixed(2)} GiB capacity
)}
- {+sharedCredits > 0 && ( -
+ {receivedCredits > 0 && ( +
+ Shared with you (spendable, not shareable): + {receivedCredits.toFixed(4)} Credits +
+ )} + {sharedOutCredits > 0 && ( +
Already Shared: - {sharedCredits} Credits + {sharedOutCredits.toFixed(4)} Credits
)}
@@ -244,8 +271,8 @@ export default function ShareCreditsPanel() { let finalAmount = creditAmount; if (creditAmount < 0.01) { finalAmount = 0.01; - } else if (creditAmount > creditBalance) { - finalAmount = creditBalance; + } else if (creditAmount > shareableCredits) { + finalAmount = shareableCredits; } setCreditAmount(finalAmount); setCreditAmountInput(String(finalAmount)); @@ -264,9 +291,10 @@ export default function ShareCreditsPanel() { Minimum share amount is 0.01 credits
)} - {creditAmount > creditBalance && ( + {!balanceLoading && creditAmount > shareableCredits && (
- Exceeds available balance ({creditBalance.toFixed(2)} credits) + Exceeds the {floorTo4(shareableCredits)} credits you can share + {receivedCredits > 0 && ' (credits shared with you can’t be re-shared)'}
)}
@@ -400,14 +428,14 @@ export default function ShareCreditsPanel() { Credits shared successfully!

- {creditAmount} credits are now available for the recipient to use. Your balance has been updated. + {sharedAmount} credits are now available for the recipient to use. Your balance has been updated.

)} + + ) : (
- {balanceLoading ? '…' : floorTo4(shareableCredits)} Credits + {shareBalance ? floorTo4(shareableCredits) : '…'} Credits - {!balanceLoading && wincForOneGiB && ( + {shareBalance && wincForOneGiB && (
~{((shareableCredits * wincPerCredit) / Number(wincForOneGiB)).toFixed(2)} GiB capacity
)}
+ )} {receivedCredits > 0 && (
@@ -272,7 +295,7 @@ export default function ShareCreditsPanel() { if (creditAmount < 0.01) { finalAmount = 0.01; } else if (creditAmount > shareableCredits) { - finalAmount = shareableCredits; + finalAmount = Number(floorTo4(shareableCredits)); } setCreditAmount(finalAmount); setCreditAmountInput(String(finalAmount)); @@ -291,7 +314,7 @@ export default function ShareCreditsPanel() { Minimum share amount is 0.01 credits
)} - {!balanceLoading && creditAmount > shareableCredits && ( + {shareBalance && creditAmount > shareableCredits && (
Exceeds the {floorTo4(shareableCredits)} credits you can share {receivedCredits > 0 && ' (credits shared with you can’t be re-shared)'} @@ -435,7 +458,7 @@ export default function ShareCreditsPanel() {