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() {
- 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)'}
- {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.
)}
)}
-
How pricing works
-
-
+
>
);
}
diff --git a/src/features/arns/components/ManageDomainModal.tsx b/src/features/arns/components/ManageDomainModal.tsx
index cbdd38b2..54511b5a 100644
--- a/src/features/arns/components/ManageDomainModal.tsx
+++ b/src/features/arns/components/ManageDomainModal.tsx
@@ -4,7 +4,6 @@ import {
CalendarPlus,
CheckCircle2,
CreditCard,
- ExternalLink,
Infinity as InfinityIcon,
Layers,
Loader2,
@@ -47,6 +46,7 @@ import ArNSPaymentModal from './ArNSPaymentModal';
import ArNSCardPaymentModal from './ArNSCardPaymentModal';
import ModalHeader from '../../../components/modals/ModalHeader';
import { buttonClass } from '@/components/button';
+import DocsLink from '@/components/DocsLink';
const LEASE_YEAR_OPTIONS = [1, 2, 3, 4, 5];
const UNDERNAME_QTY_OPTIONS = [1, 5, 10, 25, 50];
@@ -619,15 +619,9 @@ export default function ManageDomainModal({
))}
-
- Learn about undernames
-
-
+
+ How undernames work
+
)}
diff --git a/src/features/arns/components/PrimaryNameModal.tsx b/src/features/arns/components/PrimaryNameModal.tsx
index 56a36e6f..06482688 100644
--- a/src/features/arns/components/PrimaryNameModal.tsx
+++ b/src/features/arns/components/PrimaryNameModal.tsx
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
import {
ArrowDown,
CheckCircle2,
- ExternalLink,
Info,
Loader2,
Search,
@@ -19,6 +18,7 @@ import ModalHeader from '../../../components/modals/ModalHeader';
import NeedsSolNote from './NeedsSolNote';
import TransactionReceipt from './TransactionReceipt';
import { buttonClass } from '@/components/button';
+import DocsLink from '@/components/DocsLink';
export type PrimaryNameModalMode = 'set' | 'change' | 'approve';
@@ -196,15 +196,7 @@ export default function PrimaryNameModal({
) : (
Setting a name you own is a single wallet approval.{' '}
-
- Learn about primary names
-
-
+ How primary names work
)}
diff --git a/src/features/browse/components/BrowsePanel.tsx b/src/features/browse/components/BrowsePanel.tsx
index 2396f60d..6b7d3431 100644
--- a/src/features/browse/components/BrowsePanel.tsx
+++ b/src/features/browse/components/BrowsePanel.tsx
@@ -33,6 +33,7 @@ import {
} from "../utils/contentTypeUtils";
import type { VerificationEvent } from "../service-worker/types";
import { buttonClass } from '@/components/button';
+import DocsLink from '@/components/DocsLink';
/**
* Extract the base gateway domain from a URL, removing the first subdomain.
@@ -1187,14 +1188,9 @@ function BrowsePanelContent({
{!isFullscreen && resolvedUrl && (
{browseConfig.verificationEnabled ? (
-
- Learn about Wayfinder
-
+
+ How verification works
+
) : (
)}
diff --git a/src/features/browse/components/VerificationBadge.tsx b/src/features/browse/components/VerificationBadge.tsx
index 15db9de6..bc2fea30 100644
--- a/src/features/browse/components/VerificationBadge.tsx
+++ b/src/features/browse/components/VerificationBadge.tsx
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { LoadingSpinner } from "./LoadingSpinner";
+import DocsLink from '@/components/DocsLink';
export type VerificationState =
| "idle" // No verification in progress
@@ -318,14 +319,11 @@ export function VerificationBadge({
)}
-
- Learn more about Wayfinder
-
+
+
+ How verification works
+
+
)}
diff --git a/src/features/browse/components/VerificationBlockedModal.tsx b/src/features/browse/components/VerificationBlockedModal.tsx
index 0c665e1e..4e2f0ad1 100644
--- a/src/features/browse/components/VerificationBlockedModal.tsx
+++ b/src/features/browse/components/VerificationBlockedModal.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import BaseModal from "@/components/modals/BaseModal";
import { buttonClass } from '@/components/button';
+import DocsLink from '@/components/DocsLink';
interface VerificationBlockedModalProps {
identifier: string;
@@ -186,14 +187,9 @@ export function VerificationBlockedModal({
diff --git a/src/features/pages/components/PublishModal.tsx b/src/features/pages/components/PublishModal.tsx
index 664e9f46..e8ebf970 100644
--- a/src/features/pages/components/PublishModal.tsx
+++ b/src/features/pages/components/PublishModal.tsx
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
-import { AlertTriangle, ExternalLink, Info, Loader2, Rocket, Sparkles } from 'lucide-react';
+import { AlertTriangle, Info, Loader2, Rocket, Sparkles } from 'lucide-react';
import BaseModal from '@/components/modals/BaseModal';
import { tokenLabels, type SupportedTokenType } from '@/constants';
import { isFileFree, useFreeStatus } from '@/hooks/useFreeUploadLimit';
@@ -12,6 +12,7 @@ import { arnsLabel } from '../publish/permalink';
import { MAX_PAGE_BYTES, estimatePageCredits } from '../publish/cost';
import type { PublishStage } from '../hooks/usePagePublish';
import { buttonClass } from '@/components/button';
+import DocsLink from '@/components/DocsLink';
interface PublishModalProps {
def: PageDef;
@@ -217,15 +218,9 @@ export default function PublishModal({
Your balance{creditBalance.toFixed(6)} Credits
-
+
What are credits?
-
-
+
{!hasCredits && !canJit && (
diff --git a/src/features/pages/components/controls/DomainControls.tsx b/src/features/pages/components/controls/DomainControls.tsx
index 7f0987eb..faf0d447 100644
--- a/src/features/pages/components/controls/DomainControls.tsx
+++ b/src/features/pages/components/controls/DomainControls.tsx
@@ -1,5 +1,6 @@
-import { ExternalLink, Info } from 'lucide-react';
+import { Info } from 'lucide-react';
import ArNSAssociationPanel from '@/components/ArNSAssociationPanel';
+import DocsLink from '@/components/DocsLink';
interface DomainControlsProps {
enabled: boolean;
@@ -38,15 +39,7 @@ export default function DomainControls({
Optional — add a domain now, or anytime after you publish.{' '}
-
- How ArNS works
-
-
+ How ArNS works
Date: Tue, 20 Oct 2026 03:32:25 -0400
Subject: [PATCH 4/7] fix(share): don't share against a stale balance after a
failed refresh
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LxLDipDTwGmYw9bgV4cjoJ
---
src/components/panels/ShareCreditsPanel.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/panels/ShareCreditsPanel.tsx b/src/components/panels/ShareCreditsPanel.tsx
index 761cae93..e39bc46d 100644
--- a/src/components/panels/ShareCreditsPanel.tsx
+++ b/src/components/panels/ShareCreditsPanel.tsx
@@ -458,7 +458,7 @@ export default function ShareCreditsPanel() {
shareableCredits || !approvedAddress}
+ disabled={sending || !address || balanceError || !shareBalance || creditAmount < 0.01 || creditAmount > shareableCredits || !approvedAddress}
className={`${buttonClass('primary', 'xl')} w-full`}
>
{sending ? (
From 577a7d2e03edbc8f271959361b387b9c1c6e872b Mon Sep 17 00:00:00 2001
From: vilenarios
Date: Tue, 20 Oct 2026 09:36:39 -0400
Subject: [PATCH 5/7] feat(share): redesign Share Credits around what you can
share
The page now leads with the shareable balance, splits out credits shared
with you (spend only), and lists shares in both directions beside the form.
- Amount takes a "credits" unit and a Max button; the limit is checked in
winc, so Max never reads as over the balance.
- Recipient is checked as it is typed, shows the wallet type, and refuses
the signed-in wallet.
- Expiry is one segmented control (Never, 1 hour, 1 day, 1 week, Custom).
- A one-line summary says what the button will do; the button names the
amount, and success says who can spend what and what is left to share.
- "Your shares" and "Shared with you" are tabs in one card, pinned to the
form's height on desktop and scrolling inside it; a phone shows three
rows and "Show all".
- Shares are grouped by wallet, because revokeCredits revokes every
approval to an address: a per-approval Revoke would revoke more than its
row showed.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LxLDipDTwGmYw9bgV4cjoJ
---
src/components/panels/ShareCreditsList.tsx | 160 +++++
src/components/panels/ShareCreditsPanel.tsx | 736 ++++++++++----------
src/utils/shareableCredits.test.ts | 75 +-
src/utils/shareableCredits.ts | 100 ++-
4 files changed, 688 insertions(+), 383 deletions(-)
create mode 100644 src/components/panels/ShareCreditsList.tsx
diff --git a/src/components/panels/ShareCreditsList.tsx b/src/components/panels/ShareCreditsList.tsx
new file mode 100644
index 00000000..69ace572
--- /dev/null
+++ b/src/components/panels/ShareCreditsList.tsx
@@ -0,0 +1,160 @@
+import { useState } from 'react';
+import { formatWalletAddress } from '../../utils';
+import { formatCreditsFloor, type ShareRow } from '../../utils/shareableCredits';
+import { buttonClass } from '@/components/button';
+
+// Rows a phone shows before "Show all"; desktop scrolls the whole list inside
+// a card locked to the form's height.
+const PHONE_ROWS = 3;
+
+const fmt = formatCreditsFloor;
+
+const expiryLabel = (row: ShareRow) => {
+ if (!row.soonestExpiry) return 'Never expires';
+ const date = new Date(row.soonestExpiry).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+ return row.count > 1 ? `Next expiry ${date}` : `Expires ${date}`;
+};
+
+type Tab = 'given' | 'received';
+
+/**
+ * The right-hand card on Share Credits: the shares this wallet has made, and
+ * the ones made to it, as two tabs. Given rows are already grouped by
+ * recipient (`toShareRows`), so Revoke acts on exactly what its row shows.
+ */
+export default function ShareCreditsList({
+ given,
+ received,
+ sharedOutWinc,
+ receivedWinc,
+ onRevoke,
+}: {
+ given: ShareRow[];
+ received: ShareRow[];
+ sharedOutWinc: bigint;
+ receivedWinc: bigint;
+ onRevoke: (address: string) => Promise;
+}) {
+ const [tab, setTab] = useState(given.length === 0 && received.length > 0 ? 'received' : 'given');
+ const [expanded, setExpanded] = useState(false);
+ const [confirming, setConfirming] = useState(null);
+ const [revoking, setRevoking] = useState(null);
+ const [revokeError, setRevokeError] = useState('');
+
+ const rows = tab === 'given' ? given : received;
+ const pick = (next: Tab) => {
+ setTab(next);
+ setExpanded(false);
+ setConfirming(null);
+ setRevokeError('');
+ };
+
+ const revoke = async (address: string) => {
+ setRevoking(address);
+ setRevokeError('');
+ try {
+ await onRevoke(address);
+ setConfirming(null);
+ } catch (err) {
+ setRevokeError(err instanceof Error ? err.message : 'Failed to revoke');
+ } finally {
+ setRevoking(null);
+ }
+ };
+
+ const tabClass = (active: boolean) =>
+ `rounded-xl text-sm transition-colors ${active ? 'bg-lavender font-semibold text-foreground' : 'font-medium text-foreground/80 hover:text-foreground'}`;
+
+ return (
+
+
+ pick('given')} className={tabClass(tab === 'given')}>
+ Your shares · {given.length}
+
+ pick('received')} className={tabClass(tab === 'received')}>
+ Shared with you · {received.length}
+
+
+
+ {rows.length > 0 && (
+
+ {tab === 'given' ? `${fmt(sharedOutWinc)} unspent` : `${fmt(receivedWinc)} left to spend`}
+ {tab === 'given' ? 'Revoke returns the unspent part' : 'Spend only, can’t be re-shared'}
+
+ )}
+
+ {revokeError &&
{revokeError}
}
+
+
+ {rows.length === 0 && (
+
+ {tab === 'given'
+ ? 'You haven’t shared any credits yet. Shares you make show up here, with how much has been spent.'
+ : 'No wallet has shared credits with you.'}
+
+ Revoke the unspent {fmt(row.remainingWinc)} credits
+ {row.count > 1 ? ` across all ${row.count} shares` : ''}? This wallet can’t spend them after this.
+
+ {amountOver ? (
+
+ You can share up to {shareableText}.
+ {receivedWinc > 0n && ` The ${receivedText} shared with you can be spent but not passed on.`}
+
+ ) : amountUnder ? (
+ The smallest share is {MIN_SHARE} credits.
+ ) : amountMalformed ? (
+ Enter a number, like 5 or 0.25.
+ ) : gib !== null ? (
+ About {gib.toFixed(2)} GiB of storage
+ ) : (
+ At least {MIN_SHARE} credits
)}
+ {recipientIsSelf ? (
+ That's the wallet you're signed in with. Enter the wallet you want to share with.
+ ) : recipientInvalid ? (
+ That isn't an Arweave, Ethereum or Solana address.
+ ) : (
+ Arweave, Ethereum or Solana address.
+ )}
+
+ {expirySeconds > 0 ? `Ends ${formatEnd(expirySeconds)}.` : expiry === 'custom' ? 'Enter how long the share lasts.' : 'Stays open until you revoke it.'}
+
+
- {success && (
-
-
-
- Credits shared successfully!
-
-
- {sharedAmount} credits are now available for the recipient to use. Your balance has been updated.
-
-
+ {/* What the button will do, in one sentence */}
+ {amountValid && recipientValid && expiryValid && (
+
+ {formatWalletAddress(recipient, 4)} can spend up to{' '}
+
+ {plainCredits(amount)} {creditsWord(amount)}
+ {' '}
+ of yours{expirySeconds > 0 ? `, until ${formatEnd(expirySeconds)}` : ''}. You can revoke the unspent part at any time.
+
+ Sharing credits needs the Turbo payment service, which x402-only mode turns off.
)}
+
+
+
+ {success || form}
+
+
+ {/* On desktop the list card is pinned to the form card's height and
+ scrolls inside it, so the columns end together however many shares
+ there are. */}
+
+
+
+
);
-}
\ No newline at end of file
+}
diff --git a/src/utils/shareableCredits.test.ts b/src/utils/shareableCredits.test.ts
index 5adf86a4..11ff8fc9 100644
--- a/src/utils/shareableCredits.test.ts
+++ b/src/utils/shareableCredits.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { toShareableBalance, toApprovedWinc, wincToCredits } from './shareableCredits';
+import { toShareableBalance, toApprovedWinc, wincToCredits, toShareRows, isSameWallet, formatCreditsFloor, creditsToWinc } from './shareableCredits';
const CREDIT = 1_000_000_000_000n;
@@ -66,3 +66,76 @@ describe('toApprovedWinc', () => {
expect(toApprovedWinc(NaN, CREDIT)).toBe(0n);
});
});
+
+describe('toShareRows', () => {
+ const now = Date.parse('2026-10-20T00:00:00Z');
+
+ it('groups given approvals by recipient, because revoke works per address', () => {
+ const rows = toShareRows(
+ [
+ { approvedAddress: 'A', payingAddress: 'me', approvedWincAmount: String(2n * CREDIT), usedWincAmount: String(CREDIT / 2n), creationDate: '2026-10-01T00:00:00Z' },
+ { approvedAddress: 'A', payingAddress: 'me', approvedWincAmount: String(CREDIT), usedWincAmount: '0', creationDate: '2026-10-05T00:00:00Z', expirationDate: '2026-10-27T00:00:00Z' },
+ { approvedAddress: 'B', payingAddress: 'me', approvedWincAmount: String(CREDIT), usedWincAmount: '0', creationDate: '2026-10-03T00:00:00Z' },
+ ],
+ 'given',
+ now,
+ );
+ expect(rows.map((r) => r.address)).toEqual(['A', 'B']);
+ expect(rows[0]).toEqual({
+ address: 'A',
+ approvedWinc: 3n * CREDIT,
+ usedWinc: CREDIT / 2n,
+ remainingWinc: 3n * CREDIT - CREDIT / 2n,
+ soonestExpiry: '2026-10-27T00:00:00Z',
+ count: 2,
+ });
+ expect(rows[1].soonestExpiry).toBeUndefined();
+ });
+
+ it('keys received approvals by the granting wallet', () => {
+ const rows = toShareRows(
+ [{ approvedAddress: 'me', payingAddress: 'G', approvedWincAmount: '5', usedWincAmount: '2' }],
+ 'received',
+ now,
+ );
+ expect(rows).toEqual([{ address: 'G', approvedWinc: 5n, usedWinc: 2n, remainingWinc: 3n, count: 1 }]);
+ });
+
+ it('drops expired approvals and malformed rows', () => {
+ const rows = toShareRows(
+ [
+ { approvedAddress: 'A', approvedWincAmount: '5', expirationDate: '2026-10-19T00:00:00Z' },
+ { approvedWincAmount: '5' },
+ { approvedAddress: 'B', approvedWincAmount: 'x', usedWincAmount: '9' },
+ ],
+ 'given',
+ now,
+ );
+ expect(rows).toEqual([{ address: 'B', approvedWinc: 0n, usedWinc: 9n, remainingWinc: 0n, count: 1 }]);
+ expect(toShareRows(undefined, 'given', now)).toEqual([]);
+ });
+});
+
+describe('isSameWallet', () => {
+ it('matches 0x addresses case-insensitively and others exactly', () => {
+ expect(isSameWallet('0xABCdef0000000000000000000000000000000001', '0xabcdef0000000000000000000000000000000001')).toBe(true);
+ expect(isSameWallet(' HtR3kP9w ', 'HtR3kP9w')).toBe(true);
+ expect(isSameWallet('htr3kp9w', 'HtR3kP9w')).toBe(false);
+ expect(isSameWallet('A', null)).toBe(false);
+ });
+});
+
+describe('formatCreditsFloor', () => {
+ it('rounds down to four places without floating-point drift', () => {
+ expect(formatCreditsFloor(37_462_200_000_000n)).toBe('37.4622');
+ expect(formatCreditsFloor(33_912_299_999_999n)).toBe('33.9122');
+ expect(formatCreditsFloor(50_000_000n)).toBe('0.0000');
+ expect(formatCreditsFloor(0n)).toBe('0.0000');
+ });
+
+ it('what Max fills in reads back as no more than the balance', () => {
+ for (const winc of [33_912_200_000_000n, 37_462_200_000_000n, 123_456_789n, 9_007_199_254_740_993_000n]) {
+ expect(creditsToWinc(Number(formatCreditsFloor(winc)))!).toBeLessThanOrEqual(winc);
+ }
+ });
+});
diff --git a/src/utils/shareableCredits.ts b/src/utils/shareableCredits.ts
index 91e9dd7d..9e4198cd 100644
--- a/src/utils/shareableCredits.ts
+++ b/src/utils/shareableCredits.ts
@@ -58,6 +58,25 @@ export function wincToCredits(winc: bigint): number {
return Number(winc) / wincPerCredit;
}
+/** Credits as typed, in winc. Undefined for anything that isn't a finite, non-negative number. */
+export function creditsToWinc(credits: number): bigint | undefined {
+ if (!Number.isFinite(credits) || credits < 0) return undefined;
+ return BigInt(Math.round(credits * wincPerCredit));
+}
+
+const WINC_PER_TEN_THOUSANDTH = BigInt(wincPerCredit) / 10_000n;
+
+/**
+ * Winc as credits to four places, rounded DOWN, worked in bigint. Rounding
+ * down means a figure the user copies into the form (or that Max fills in)
+ * never exceeds the balance; doing it in floating point turned 37.4622 into
+ * 37.4621.
+ */
+export function formatCreditsFloor(winc: bigint): string {
+ const units = (winc > 0n ? winc : 0n) / WINC_PER_TEN_THOUSANDTH;
+ return `${units / 10_000n}.${(units % 10_000n).toString().padStart(4, '0')}`;
+}
+
/**
* The winc to approve for a share of `credits`, never above `shareableWinc`.
*
@@ -66,7 +85,84 @@ export function wincToCredits(winc: bigint): number {
* service refuses. Clamping here makes "share everything" always succeed.
*/
export function toApprovedWinc(credits: number, shareableWinc: bigint): bigint {
- if (!Number.isFinite(credits) || credits <= 0) return 0n;
- const requested = BigInt(Math.round(credits * wincPerCredit));
+ const requested = creditsToWinc(credits) ?? 0n;
return requested > shareableWinc ? shareableWinc : requested;
}
+
+/** One approval as `/v1/account/balance` lists it (turbo-sdk `CreditShareApproval`). */
+export interface ApprovalLike {
+ approvedAddress?: string;
+ payingAddress?: string;
+ approvedWincAmount?: unknown;
+ usedWincAmount?: unknown;
+ creationDate?: string;
+ expirationDate?: string;
+}
+
+/** Every approval between this wallet and one other wallet, in one direction. */
+export interface ShareRow {
+ /** The other wallet: the recipient of a given share, the granter of a received one. */
+ address: string;
+ approvedWinc: bigint;
+ usedWinc: bigint;
+ remainingWinc: bigint;
+ /** The soonest expiry among these approvals; undefined when none expire. */
+ soonestExpiry?: string;
+ /** How many approvals the row stands for. */
+ count: number;
+}
+
+const time = (iso?: string) => {
+ const t = iso ? Date.parse(iso) : NaN;
+ return Number.isFinite(t) ? t : undefined;
+};
+
+/**
+ * Groups approvals by the other wallet, newest first, dropping any that have
+ * expired.
+ *
+ * Grouping is not cosmetic for given shares: `revokeCredits` takes an address
+ * and revokes every approval to it, so a Revoke button on a single approval
+ * would revoke more than its row shows.
+ */
+export function toShareRows(
+ approvals: ApprovalLike[] | null | undefined,
+ direction: 'given' | 'received',
+ now: number = Date.now(),
+): ShareRow[] {
+ const rows = new Map();
+ const newest = new Map();
+ for (const a of approvals ?? []) {
+ const address = direction === 'given' ? a.approvedAddress : a.payingAddress;
+ if (!address) continue;
+ const expires = time(a.expirationDate);
+ if (expires !== undefined && expires <= now) continue;
+
+ const approved = toWinc(a.approvedWincAmount);
+ const used = toWinc(a.usedWincAmount);
+ const row = rows.get(address) ?? {
+ address,
+ approvedWinc: 0n,
+ usedWinc: 0n,
+ remainingWinc: 0n,
+ count: 0,
+ };
+ row.approvedWinc += approved;
+ row.usedWinc += used;
+ row.remainingWinc += approved > used ? approved - used : 0n;
+ row.count += 1;
+ newest.set(address, Math.max(newest.get(address) ?? 0, time(a.creationDate) ?? 0));
+ if (expires !== undefined && (row.soonestExpiry === undefined || expires < time(row.soonestExpiry)!)) {
+ row.soonestExpiry = a.expirationDate;
+ }
+ rows.set(address, row);
+ }
+ return [...rows.values()].sort((x, y) => newest.get(y.address)! - newest.get(x.address)!);
+}
+
+/** Whether `candidate` is `own`, ignoring the case of a 0x address. */
+export function isSameWallet(candidate: string, own: string | null | undefined): boolean {
+ if (!own) return false;
+ const a = candidate.trim();
+ return /^0x/i.test(a) ? a.toLowerCase() === own.toLowerCase() : a === own;
+}
From d3c7094e73c5732c03e394ab7c9b1b0345eef6bd Mon Sep 17 00:00:00 2001
From: vilenarios
Date: Tue, 20 Oct 2026 10:59:28 -0400
Subject: [PATCH 6/7] fix(share): exact amounts, the right opening tab,
readable failures
- Amounts are parsed from the typed text straight to winc, so nothing goes
through floating point; validation, the approval and the clamp now use
one exact value (CodeRabbit).
- The list remounts once the balance loads, so a wallet with only received
shares opens on "Shared with you" rather than an empty tab (CodeRabbit).
- Share and revoke failures say what happened instead of the SDK's
"Failed to upload file after 1 attempts Failed request (Status 400)"
wrapper, and refresh the balance.
- The balance stays on screen after a share, so success no longer repeats
it or leaves the card mostly empty.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LxLDipDTwGmYw9bgV4cjoJ
---
src/components/panels/ShareCreditsPanel.tsx | 52 +++++++++++++-------
src/utils/shareableCredits.test.ts | 52 +++++++++++++++++++-
src/utils/shareableCredits.ts | 54 ++++++++++++++++++---
3 files changed, 132 insertions(+), 26 deletions(-)
diff --git a/src/components/panels/ShareCreditsPanel.tsx b/src/components/panels/ShareCreditsPanel.tsx
index e66fe7eb..71f2cb01 100644
--- a/src/components/panels/ShareCreditsPanel.tsx
+++ b/src/components/panels/ShareCreditsPanel.tsx
@@ -16,8 +16,9 @@ import {
toApprovedWinc,
toShareRows,
isSameWallet,
- creditsToWinc,
+ parseCreditsToWinc,
formatCreditsFloor,
+ shareFailureMessage,
} from '../../utils/shareableCredits';
import { useWincForOneGiB } from '../../hooks/useWincForOneGiB';
import { validateWalletAddress, getWalletTypeLabel } from '../../utils/addressValidation';
@@ -31,6 +32,7 @@ const plainCredits = (credits: number) => String(Number(credits.toFixed(4)));
const creditsWord = (credits: number) => (credits === 1 ? 'credit' : 'credits');
const MIN_SHARE = 0.01;
+const MIN_SHARE_WINC = BigInt(wincPerCredit) / 100n;
type Expiry = 'never' | '1h' | '1d' | '1w' | 'custom';
const EXPIRY_OPTIONS: { id: Expiry; label: string; short: string; seconds: number }[] = [
@@ -54,7 +56,6 @@ interface SharedResult {
credits: number;
recipient: string;
expirySeconds: number;
- remaining: string;
}
export default function ShareCreditsPanel() {
@@ -148,13 +149,15 @@ export default function ShareCreditsPanel() {
const receivedText = formatCreditsFloor(receivedWinc);
// Amount
- const amount = /^\d*\.?\d*$/.test(amountInput.trim()) ? Number(amountInput.trim()) : NaN;
- // Compared in winc: as floats, the Max figure can read a hair over the balance.
- const amountWinc = creditsToWinc(amount);
+ // Decided in winc, parsed exactly from the text: as floats, the Max figure
+ // can read a hair over the balance. `amount` is for display only.
+ const amountWinc = parseCreditsToWinc(amountInput);
+ const amount = amountWinc !== undefined ? Number(amountInput.trim()) : NaN;
+ const amountEntered = amountInput.trim() !== '';
const amountOver = !!shareBalance && amountWinc !== undefined && amountWinc > shareableWinc;
- const amountValid = amountWinc !== undefined && amount >= MIN_SHARE && !amountOver && !!shareBalance;
- const amountUnder = amountInput.trim() !== '' && Number.isFinite(amount) && amount < MIN_SHARE;
- const amountMalformed = amountInput.trim() !== '' && !Number.isFinite(amount);
+ const amountUnder = amountEntered && amountWinc !== undefined && amountWinc < MIN_SHARE_WINC;
+ const amountMalformed = amountEntered && amountWinc === undefined;
+ const amountValid = !!shareBalance && amountWinc !== undefined && !amountUnder && !amountOver;
const wincPerGiB = wincForOneGiB ? Number(wincForOneGiB) : NaN;
const gib = amountValid && Number.isFinite(wincPerGiB) && wincPerGiB > 0 ? (amount * wincPerCredit) / wincPerGiB : null;
@@ -196,11 +199,11 @@ export default function ShareCreditsPanel() {
try {
const turbo = await createTurboClient();
- const approvedWincAmount = toApprovedWinc(amount, shareableWinc).toString();
+ const approvedWinc = toApprovedWinc(amountInput, shareableWinc);
await turbo.shareCredits({
approvedAddress: recipient,
- approvedWincAmount,
+ approvedWincAmount: approvedWinc.toString(),
expiresBySeconds: expirySeconds > 0 ? expirySeconds : undefined,
});
@@ -209,7 +212,6 @@ export default function ShareCreditsPanel() {
credits: amount,
recipient,
expirySeconds,
- remaining: formatCreditsFloor(shareableWinc - toApprovedWinc(amount, shareableWinc)),
});
resetForm();
@@ -218,15 +220,23 @@ export default function ShareCreditsPanel() {
refetchBalance();
} catch (err) {
console.error('Share credits error:', err);
- setError(err instanceof Error ? err.message : 'Failed to share credits');
+ setError(shareFailureMessage(err, 'share'));
+ // A refusal usually means the balance moved; show the current one.
+ refetchBalance();
} finally {
setSending(false);
}
};
const handleRevoke = async (revokedAddress: string) => {
- const turbo = await createTurboClient();
- await turbo.revokeCredits({ revokedAddress });
+ try {
+ const turbo = await createTurboClient();
+ await turbo.revokeCredits({ revokedAddress });
+ } catch (err) {
+ console.error('Revoke credits error:', err);
+ refetchBalance();
+ throw new Error(shareFailureMessage(err, 'revoke'));
+ }
window.dispatchEvent(new CustomEvent('refresh-balance'));
await refetchBalance();
};
@@ -249,7 +259,7 @@ export default function ShareCreditsPanel() {
const inputShell = (invalid: boolean) =>
`field flex h-12 items-center gap-2 rounded-2xl border bg-background transition-colors ${invalid ? 'border-error' : 'border-border/20 focus-within:border-foreground'}`;
- const form = (
+ const balanceHeader = (
<>
{/* What can be shared, ahead of everything else */}
@@ -279,6 +289,11 @@ export default function ShareCreditsPanel() {
)}
+ >
+ );
+
+ const form = (
+ <>
{/* Amount */}
@@ -455,8 +470,7 @@ export default function ShareCreditsPanel() {
@@ -493,6 +507,7 @@ export default function ShareCreditsPanel() {
+ {balanceHeader}
{success || form}
@@ -500,7 +515,10 @@ export default function ShareCreditsPanel() {
scrolls inside it, so the columns end together however many shares
there are. */}
+ {/* Keyed on load so the list picks its opening tab from real data:
+ a wallet with only received shares should open on that tab. */}
{
it('what Max fills in reads back as no more than the balance', () => {
for (const winc of [33_912_200_000_000n, 37_462_200_000_000n, 123_456_789n, 9_007_199_254_740_993_000n]) {
- expect(creditsToWinc(Number(formatCreditsFloor(winc)))!).toBeLessThanOrEqual(winc);
+ expect(parseCreditsToWinc(formatCreditsFloor(winc))!).toBeLessThanOrEqual(winc);
}
});
});
+
+describe('parseCreditsToWinc', () => {
+ it('parses exactly, without floating point', () => {
+ expect(parseCreditsToWinc('5')).toBe(5n * CREDIT);
+ expect(parseCreditsToWinc('0.25')).toBe(CREDIT / 4n);
+ expect(parseCreditsToWinc('.5')).toBe(CREDIT / 2n);
+ expect(parseCreditsToWinc('3.')).toBe(3n * CREDIT);
+ expect(parseCreditsToWinc(' 12345.6789 ')).toBe(12_345_678_900_000_000n);
+ // Number('9007.1993') * 1e12 lands a winc away from what was typed.
+ expect(parseCreditsToWinc('9007.1993')).toBe(9_007_199_300_000_000n);
+ });
+
+ it('rounds digits past the twelfth place half up', () => {
+ expect(parseCreditsToWinc('0.0000000000014')).toBe(1n);
+ expect(parseCreditsToWinc('0.0000000000015')).toBe(2n);
+ });
+
+ it('rejects anything that is not a plain decimal', () => {
+ for (const bad of ['', '.', '-1', '1e3', 'abc', '1.2.3', '0x10']) {
+ expect(parseCreditsToWinc(bad)).toBeUndefined();
+ }
+ });
+
+ it('toApprovedWinc uses the exact parse for text and still clamps', () => {
+ expect(toApprovedWinc('9007.1993', 10_000n * CREDIT)).toBe(9_007_199_300_000_000n);
+ expect(toApprovedWinc('40', 33n * CREDIT)).toBe(33n * CREDIT);
+ expect(toApprovedWinc('', CREDIT)).toBe(0n);
+ });
+});
+
+describe('shareFailureMessage', () => {
+ it('turns the SDK wrapper into a refusal the user can act on', () => {
+ const err = new Error('Failed to upload file after 1 attempts Failed request (Status 400): Insufficient balance');
+ expect(shareFailureMessage(err, 'share')).toMatch(/^Couldn't share credits: the payment service says this wallet doesn't have enough credits/);
+ });
+
+ it('says nothing happened when the signature was cancelled', () => {
+ expect(shareFailureMessage(new Error('User rejected the request'), 'revoke')).toBe(
+ 'Nothing was revoked: the signature request was cancelled.',
+ );
+ });
+
+ it('strips the wrapper and keeps the service reason', () => {
+ const err = new Error('Failed to upload file after 3 attempts Failed request (Status 503): Service Unavailable');
+ expect(shareFailureMessage(err, 'share')).toBe("Couldn't share credits: Service Unavailable");
+ expect(shareFailureMessage(undefined, 'revoke')).toBe("Couldn't revoke that share. Try again.");
+ });
+});
diff --git a/src/utils/shareableCredits.ts b/src/utils/shareableCredits.ts
index 9e4198cd..02e68cc8 100644
--- a/src/utils/shareableCredits.ts
+++ b/src/utils/shareableCredits.ts
@@ -58,8 +58,25 @@ export function wincToCredits(winc: bigint): number {
return Number(winc) / wincPerCredit;
}
-/** Credits as typed, in winc. Undefined for anything that isn't a finite, non-negative number. */
-export function creditsToWinc(credits: number): bigint | undefined {
+const WINC_DECIMALS = String(wincPerCredit).length - 1; // 12
+
+/**
+ * Credits as typed ("5", "0.25", ".5", "3."), in winc, parsed as a decimal
+ * string so nothing passes through floating point: `Number` turns some
+ * four-place figures above ~9,000 credits into a winc more than they say.
+ * Digits past the twelfth place round half up. Undefined for anything else.
+ */
+export function parseCreditsToWinc(text: string): bigint | undefined {
+ const m = /^(\d*)(?:\.(\d*))?$/.exec(text.trim());
+ if (!m || (m[1] === '' && !m[2])) return undefined;
+ const frac = m[2] ?? '';
+ const kept = frac.slice(0, WINC_DECIMALS).padEnd(WINC_DECIMALS, '0');
+ const roundUp = frac.length > WINC_DECIMALS && frac[WINC_DECIMALS] >= '5' ? 1n : 0n;
+ return BigInt(m[1] || '0') * BigInt(wincPerCredit) + BigInt(kept) + roundUp;
+}
+
+/** A credits figure that is already a number (not typed), in winc. */
+function numberToWinc(credits: number): bigint | undefined {
if (!Number.isFinite(credits) || credits < 0) return undefined;
return BigInt(Math.round(credits * wincPerCredit));
}
@@ -79,13 +96,14 @@ export function formatCreditsFloor(winc: bigint): string {
/**
* The winc to approve for a share of `credits`, never above `shareableWinc`.
+ * Pass the form's text where there is one (`parseCreditsToWinc`, exact).
*
- * Credits are a float in the form, so sharing "the max" round-trips through
- * floating point and can land a winc or two above the balance, which the
- * service refuses. Clamping here makes "share everything" always succeed.
+ * The clamp is a backstop: a number can round-trip through floating point and
+ * land a winc or two above the balance, which the service refuses. Clamping
+ * here makes "share everything" always succeed.
*/
-export function toApprovedWinc(credits: number, shareableWinc: bigint): bigint {
- const requested = creditsToWinc(credits) ?? 0n;
+export function toApprovedWinc(credits: number | string, shareableWinc: bigint): bigint {
+ const requested = (typeof credits === 'string' ? parseCreditsToWinc(credits) : numberToWinc(credits)) ?? 0n;
return requested > shareableWinc ? shareableWinc : requested;
}
@@ -166,3 +184,25 @@ export function isSameWallet(candidate: string, own: string | null | undefined):
const a = candidate.trim();
return /^0x/i.test(a) ? a.toLowerCase() === own.toLowerCase() : a === own;
}
+
+/**
+ * A share or revoke failure in words a person can act on. Both go through
+ * turbo-sdk's `uploadFile`, so a service refusal arrives wrapped as "Failed to
+ * upload file after N attempts Failed request (Status 400): ", which
+ * reads like a crash rather than a refusal.
+ */
+export function shareFailureMessage(err: unknown, action: 'share' | 'revoke'): string {
+ const raw = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
+ const verb = action === 'share' ? 'share credits' : 'revoke that share';
+ if (/insufficient balance/i.test(raw)) {
+ return `Couldn't ${verb}: the payment service says this wallet doesn't have enough credits for it. Your balance may have changed; it has been refreshed.`;
+ }
+ if (/user (rejected|denied|cancel)|cancelled|canceled|rejected the request/i.test(raw)) {
+ return `Nothing was ${action === 'share' ? 'shared' : 'revoked'}: the signature request was cancelled.`;
+ }
+ const reason = raw
+ .replace(/^Failed to upload file after \d+ attempts?\s*/i, '')
+ .replace(/^Failed request \(Status (\d+)\):\s*/i, '')
+ .trim();
+ return reason ? `Couldn't ${verb}: ${reason}` : `Couldn't ${verb}. Try again.`;
+}
From 1df927020ae209bd5407c240a5258874182a61cf Mon Sep 17 00:00:00 2001
From: vilenarios
Date: Tue, 20 Oct 2026 11:42:57 -0400
Subject: [PATCH 7/7] fix(share): show the share's real end time on success
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The success message computed "Ends …" from Date.now() on every render, so a
refetch or refocus moved it later. It now shows the expirationDate of the
approval the service returned, falling back to the time the share was made.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LxLDipDTwGmYw9bgV4cjoJ
---
src/components/panels/ShareCreditsPanel.tsx | 20 ++++++++++++++------
1 file changed, 14 insertions(+), 6 deletions(-)
diff --git a/src/components/panels/ShareCreditsPanel.tsx b/src/components/panels/ShareCreditsPanel.tsx
index 71f2cb01..ce7e5bfd 100644
--- a/src/components/panels/ShareCreditsPanel.tsx
+++ b/src/components/panels/ShareCreditsPanel.tsx
@@ -44,18 +44,21 @@ const EXPIRY_OPTIONS: { id: Expiry; label: string; short: string; seconds: numbe
];
const CUSTOM_UNITS = { hours: 3_600, days: 86_400 } as const;
-const formatEnd = (seconds: number) =>
- new Date(Date.now() + seconds * 1000).toLocaleString(undefined, {
+const formatTime = (at: number) =>
+ new Date(at).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
+// A preview: before sharing, the end is "this long from now".
+const formatEnd = (seconds: number) => formatTime(Date.now() + seconds * 1000);
interface SharedResult {
credits: number;
recipient: string;
- expirySeconds: number;
+ /** When the share ends, fixed when it was made; null when it never does. */
+ endsAt: number | null;
}
export default function ShareCreditsPanel() {
@@ -201,7 +204,7 @@ export default function ShareCreditsPanel() {
const turbo = await createTurboClient();
const approvedWinc = toApprovedWinc(amountInput, shareableWinc);
- await turbo.shareCredits({
+ const approval = await turbo.shareCredits({
approvedAddress: recipient,
approvedWincAmount: approvedWinc.toString(),
expiresBySeconds: expirySeconds > 0 ? expirySeconds : undefined,
@@ -211,7 +214,12 @@ export default function ShareCreditsPanel() {
setShared({
credits: amount,
recipient,
- expirySeconds,
+ // The service's own expiry when it returns one; otherwise the time
+ // the share was made, so a re-render can't move it.
+ endsAt:
+ expirySeconds > 0
+ ? Date.parse(approval?.expirationDate ?? '') || Date.now() + expirySeconds * 1000
+ : null,
});
resetForm();
@@ -470,7 +478,7 @@ export default function ShareCreditsPanel() {