From 9755ca55b0ab3b5f8ed4026ef9c962d520f474c0 Mon Sep 17 00:00:00 2001 From: gospeltout Date: Fri, 16 Oct 2026 18:03:44 +0100 Subject: [PATCH] feat: add batch transfer modal for multi-wallet key distributions --- src/components/common/BatchTransferModal.tsx | 394 ++++++++++++ src/components/common/PortfolioHoldingRow.tsx | 572 +++++++++++++----- .../__tests__/BatchTransferModal.test.tsx | 273 +++++++++ ...PortfolioHoldingRow.batchTransfer.test.tsx | 150 +++++ src/hooks/useWallet.ts | 154 ++++- src/pages/LandingPage.tsx | 224 +++++-- .../creatorKeysContract.service.test.ts | 48 +- src/services/creatorKeysContract.service.ts | 63 +- .../__tests__/batchTransfer.utils.test.ts | 151 +++++ src/utils/batchTransfer.utils.ts | 160 +++++ vitest.config.ts | 9 +- 11 files changed, 1929 insertions(+), 269 deletions(-) create mode 100644 src/components/common/BatchTransferModal.tsx create mode 100644 src/components/common/__tests__/BatchTransferModal.test.tsx create mode 100644 src/components/common/__tests__/PortfolioHoldingRow.batchTransfer.test.tsx create mode 100644 src/utils/__tests__/batchTransfer.utils.test.ts create mode 100644 src/utils/batchTransfer.utils.ts diff --git a/src/components/common/BatchTransferModal.tsx b/src/components/common/BatchTransferModal.tsx new file mode 100644 index 00000000..9fcf5407 --- /dev/null +++ b/src/components/common/BatchTransferModal.tsx @@ -0,0 +1,394 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { StableButtonContent } from '@/components/ui/stable-button-content'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Plus, Trash2, Users } from 'lucide-react'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import showToast from '@/utils/toast.util'; +import { + MAX_BATCH_TRANSFER_RECIPIENTS, + validateBatchTransferRows, + type BatchTransferRecipient, + type BatchTransferRecipientRow, +} from '@/utils/batchTransfer.utils'; +import { useBatchTransferMutation } from '@/hooks/useWallet'; + +export interface BatchTransferModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + creatorId: string; + creatorName?: string; + liquidBalance: number; + initialRecipients?: Array<{ address: string; quantity: number | string }>; + isSubmitting?: boolean; + onConfirm?: ( + recipients: BatchTransferRecipient[], + creatorId: string + ) => Promise | void; +} + +let rowIdCounter = 0; +function createNewRow( + address = '', + quantity: number | string = 1 +): BatchTransferRecipientRow { + rowIdCounter += 1; + return { + id: `row-${Date.now()}-${rowIdCounter}`, + address, + quantity, + }; +} + +export default function BatchTransferModal({ + open, + onOpenChange, + creatorId, + creatorName = 'creator', + liquidBalance, + initialRecipients, + isSubmitting: externalIsSubmitting, + onConfirm, +}: BatchTransferModalProps) { + const [rows, setRows] = useState(() => { + if (initialRecipients && initialRecipients.length > 0) { + return initialRecipients.map(r => createNewRow(r.address, r.quantity)); + } + return [createNewRow('', 1)]; + }); + const [touched, setTouched] = useState>({}); + const [hasSubmitted, setHasSubmitted] = useState(false); + const [isInternalSubmitting, setIsInternalSubmitting] = useState(false); + + const batchTransferMutation = useBatchTransferMutation(); + const firstInputRef = useRef(null); + + const isSubmitting = externalIsSubmitting ?? isInternalSubmitting; + + // Reset state when modal opens + useEffect(() => { + if (open) { + if (initialRecipients && initialRecipients.length > 0) { + setRows( + initialRecipients.map(r => createNewRow(r.address, r.quantity)) + ); + } else { + setRows([createNewRow('', 1)]); + } + setTouched({}); + setHasSubmitted(false); + setIsInternalSubmitting(false); + } + }, [open, initialRecipients]); + + const validation = useMemo( + () => validateBatchTransferRows(rows, liquidBalance), + [rows, liquidBalance] + ); + + const handleAddRow = () => { + if (rows.length >= MAX_BATCH_TRANSFER_RECIPIENTS) return; + setRows(prev => [...prev, createNewRow('', 1)]); + }; + + const handleRemoveRow = (id: string) => { + if (rows.length <= 1) return; + setRows(prev => prev.filter(r => r.id !== id)); + setTouched(prev => { + const next = { ...prev }; + delete next[id]; + return next; + }); + }; + + const handleAddressChange = (id: string, address: string) => { + setRows(prev => prev.map(r => (r.id === id ? { ...r, address } : r))); + setTouched(prev => ({ ...prev, [id]: true })); + }; + + const handleQuantityChange = (id: string, quantityStr: string) => { + setRows(prev => + prev.map(r => (r.id === id ? { ...r, quantity: quantityStr } : r)) + ); + setTouched(prev => ({ ...prev, [id]: true })); + }; + + const handleSubmit = async () => { + setHasSubmitted(true); + if (!validation.isValid || isSubmitting) return; + + const payload: BatchTransferRecipient[] = rows.map(r => ({ + address: r.address.trim().toUpperCase(), + quantity: Number(r.quantity), + })); + + try { + setIsInternalSubmitting(true); + showToast.loading( + `Submitting batch transfer of ${validation.totalKeys} keys to ${payload.length} wallet${payload.length === 1 ? '' : 's'}...` + ); + + if (onConfirm) { + await onConfirm(payload, creatorId); + } else { + await batchTransferMutation.mutateAsync({ + creatorId, + recipients: payload, + }); + } + + showToast.transactionSuccess( + 'Batch transfer confirmed', + `Sent ${formatNumber(validation.totalKeys)} key${validation.totalKeys === 1 ? '' : 's'} to ${payload.length} recipient${payload.length === 1 ? '' : 's'}.` + ); + onOpenChange(false); + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Batch transfer transaction failed. Please check your connection and try again.'; + showToast.error(message); + } finally { + setIsInternalSubmitting(false); + } + }; + + const canSubmit = validation.isValid && !isSubmitting; + const isAtMaxRows = rows.length >= MAX_BATCH_TRANSFER_RECIPIENTS; + + return ( + !isSubmitting && onOpenChange(next)} + > + { + e.preventDefault(); + firstInputRef.current?.focus(); + }} + data-testid="batch-transfer-modal" + > + +
+
+ +
+
+ + Batch Transfer Keys + + + Send {creatorName} keys to up to{' '} + {MAX_BATCH_TRANSFER_RECIPIENTS} recipient wallets in a + single signed transaction. + +
+
+
+ +
+ {/* Balance banner */} +
+ + Liquid balance available: + + + {formatNumber(liquidBalance)} keys + +
+ + {/* Recipients dynamic list */} +
+ {rows.map((row, index) => { + const rowError = validation.rowErrors[row.id]; + const isRowTouched = touched[row.id] || hasSubmitted; + const showAddressError = + isRowTouched && !!rowError?.addressError; + const showQuantityError = + isRowTouched && !!rowError?.quantityError; + + return ( +
+
+ {/* Recipient index */} + + #{index + 1} + + + {/* Wallet Address Input */} +
+ + handleAddressChange( + row.id, + e.target.value + ) + } + placeholder="Stellar wallet address (G...)" + aria-label={`Recipient ${index + 1} address`} + aria-invalid={showAddressError || undefined} + disabled={isSubmitting} + className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-xs text-white placeholder-white/30 outline-none transition-all focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15 font-mono" + data-testid={`batch-transfer-address-${index}`} + /> +
+ + {/* Quantity Input */} +
+ + handleQuantityChange( + row.id, + e.target.value + ) + } + placeholder="Qty" + aria-label={`Recipient ${index + 1} quantity`} + aria-invalid={ + showQuantityError || undefined + } + disabled={isSubmitting} + className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-2.5 py-2 text-xs text-white placeholder-white/30 outline-none transition-all focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15 text-center font-medium" + data-testid={`batch-transfer-quantity-${index}`} + /> +
+ + {/* Remove Button */} +
+ +
+
+ + {/* Row-level errors */} + {(showAddressError || showQuantityError) && ( +
+ {showAddressError && ( +

{rowError?.addressError}

+ )} + {showQuantityError && ( +

{rowError?.quantityError}

+ )} +
+ )} +
+ ); + })} +
+ + {/* Add recipient row action */} +
+ + + {rows.length} / {MAX_BATCH_TRANSFER_RECIPIENTS} recipients + +
+ + {/* Total keys & summary bar */} +
+
+ + Total keys to transfer: + + + {formatNumber(validation.totalKeys)} key + {validation.totalKeys === 1 ? '' : 's'} + +
+ + {/* Balance validation error */} + {validation.balanceError && ( +
+ {validation.balanceError} +
+ )} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/common/PortfolioHoldingRow.tsx b/src/components/common/PortfolioHoldingRow.tsx index 56453de2..76323fdd 100644 --- a/src/components/common/PortfolioHoldingRow.tsx +++ b/src/components/common/PortfolioHoldingRow.tsx @@ -4,9 +4,13 @@ import LockupCountdown from '@/components/common/LockupCountdown'; import ReinvestDividendDialog from '@/components/common/ReinvestDividendDialog'; import DeprecationNotice from '@/components/common/DeprecationNotice'; import RedeemKeyDialog from '@/components/common/RedeemKeyDialog'; +import BatchTransferModal from '@/components/common/BatchTransferModal'; import { computeRemainingLockupSeconds } from '@/utils/lockupCountdown.utils'; import { formatNumber } from '@/utils/numberFormat.utils'; -import { formatDisplayKeyPrice, resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; +import { + formatDisplayKeyPrice, + resolveCreatorKeyPriceStroops, +} from '@/utils/keyPriceDisplay.utils'; import { calculatePositionPnL, formatPnLDisplay, @@ -15,10 +19,26 @@ import { getPnLToneChipClassName, type HeldKeyPosition, } from '@/utils/portfolioValue.utils'; -import { hasUnclaimedDividend, xlmToStroops } from '@/utils/reinvestDividend.utils'; +import { + hasUnclaimedDividend, + xlmToStroops, +} from '@/utils/reinvestDividend.utils'; import { isKeyDeprecated } from '@/utils/keyDeprecation.utils'; +import type { BatchTransferRecipient } from '@/utils/batchTransfer.utils'; -import { TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import { + TrendingUp, + TrendingDown, + Minus, + MoreVertical, + Users, +} from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import type { Course } from '@/services/course.service'; import { cn } from '@/lib/utils'; import { useGlobalPause } from '@/hooks/useGlobalPause'; @@ -34,12 +54,16 @@ export interface PortfolioHoldingRowProps { onFreeze?: (position: HeldKeyPosition) => void; onUnfreeze?: (position: HeldKeyPosition) => void; onTransfer?: (creatorId: string) => void; + onBatchTransfer?: (position: HeldKeyPosition) => void; + onBatchTransferSubmit?: ( + recipients: BatchTransferRecipient[], + creatorId: string + ) => Promise | void; onBurn?: (creatorId: string) => void; isSubmitting?: boolean; isReinvesting?: boolean; isRedeeming?: boolean; isNetworkMismatch?: boolean; - } export const PortfolioHoldingRow: React.FC = ({ @@ -52,16 +76,21 @@ export const PortfolioHoldingRow: React.FC = ({ onFreeze, onUnfreeze, onTransfer, + onBatchTransfer, + onBatchTransferSubmit, onBurn, isSubmitting = false, isReinvesting = false, isRedeeming = false, isNetworkMismatch = false, }) => { - const initialRemaining = computeRemainingLockupSeconds(position.last_buy_timestamp); + const initialRemaining = computeRemainingLockupSeconds( + position.last_buy_timestamp + ); const [isLocked, setIsLocked] = useState(initialRemaining > 0); const [reinvestOpen, setReinvestOpen] = useState(false); const [redeemOpen, setRedeemOpen] = useState(false); + const [batchTransferOpen, setBatchTransferOpen] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const frozenQuantity = position.frozenQuantity ?? 0; const liquidQuantity = position.liquidQuantity ?? position.quantity ?? 0; @@ -76,7 +105,11 @@ export const PortfolioHoldingRow: React.FC = ({ const positionPnL = calculatePositionPnL(position); const pnlTone = getPnLTone(positionPnL.unrealisedPnLStroops); const PnlIcon = - pnlTone === 'positive' ? TrendingUp : pnlTone === 'negative' ? TrendingDown : Minus; + pnlTone === 'positive' + ? TrendingUp + : pnlTone === 'negative' + ? TrendingDown + : Minus; const pnlLabel = positionPnL.status === 'loading' ? 'Refreshing price' @@ -103,195 +136,400 @@ export const PortfolioHoldingRow: React.FC = ({ setRedeemOpen(false); }; + const handleBatchTransferClick = () => { + setBatchTransferOpen(true); + onBatchTransfer?.(position); + }; + return ( <> -
-
- -
- {formatNumber(position.quantity)} keys ·{' '} - {position.isPriceLoading - ? 'Refreshing price' - : position.isPriceStale - ? 'Price stale' - : formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(position))} -
-
- - Avg buy{' '} - - {positionPnL.averagePurchasePriceStroops == null - ? '—' - : formatDisplayKeyPrice(positionPnL.averagePurchasePriceStroops)} - - - - Current{' '} - - {positionPnL.currentValueStroops == null - ? '—' - : formatDisplayKeyPrice(positionPnL.currentValueStroops)} +
+
+
- {hasDividends && ( - + + Pending + + )} + +
+ {formatNumber(position.quantity)} keys ·{' '} + {position.isPriceLoading + ? 'Refreshing price' + : position.isPriceStale + ? 'Price stale' + : formatDisplayKeyPrice( + resolveCreatorKeyPriceStroops(position) + )} +
+
-
+ + Current{' '} + + {positionPnL.currentValueStroops == null + ? '—' + : formatDisplayKeyPrice( + positionPnL.currentValueStroops + )} + + + + + unrealised P&L +
+ {hasDividends && ( + + + )} +
-
- {!deprecated && ( - setIsLocked(false)} - /> - )} +
+ {!deprecated && ( + setIsLocked(false)} + /> + )} -
- {deprecated ? ( - onRedeem && ( - - ) - ) : ( - <> - {onReinvest && hasDividends && ( +
+ {deprecated ? ( + onRedeem && ( - )} - {onBuy && ( - + ) + ) : ( + <> + {onReinvest && hasDividends && ( - - )} - {onSell && ( - + )} + {onBuy && ( + + + + )} + {onSell && ( + + + + )} + {(onBatchTransfer || + onTransfer || + onFreeze || + onUnfreeze || + onBurn) && ( + + + + + + {onBatchTransfer && ( + + + Batch Transfer + + )} + {onTransfer && ( + + onTransfer(position.creatorId) + } + disabled={isLiquidEmpty || isSubmitting} + data-testid="holding-action-transfer" + className="cursor-pointer" + > + Transfer + + )} + {onFreeze && ( + onFreeze(position)} + disabled={isLiquidEmpty || isSubmitting} + data-testid="holding-action-freeze" + className="cursor-pointer" + > + Freeze + + )} + {onUnfreeze && ( + onUnfreeze(position)} + disabled={ + frozenQuantity <= 0 || isSubmitting + } + data-testid="holding-action-unfreeze" + className="cursor-pointer" + > + Unfreeze + + )} + {onBurn && ( + + onBurn(position.creatorId) + } + disabled={isLiquidEmpty || isSubmitting} + data-testid="holding-action-burn" + className="cursor-pointer text-red-400 focus:text-red-300" + > + Burn + + )} + + + )} + + )} +
+
+ {isExpanded && ( +
+
+
+

+ Self-freeze +

+
+
+
Frozen
+
+ {formatNumber(frozenQuantity)} keys +
+
+
+
Liquid
+
+ {formatNumber(liquidQuantity)} keys +
+
+
+
+
+ {onFreeze && ( - - )} - - )} -
-
- {isExpanded && ( -
-
-
-

Self-freeze

-
-
Frozen
{formatNumber(frozenQuantity)} keys
-
Liquid
{formatNumber(liquidQuantity)} keys
-
-
-
- {onFreeze && } - {onUnfreeze && } - {onTransfer && } - {onBurn && } + )} + {onUnfreeze && ( + + )} + {onTransfer && ( + + )} + {onBatchTransfer && ( + + )} + {onBurn && ( + + )} +
-
+ )} +
+ + {onBatchTransfer && ( + )} -
- {onReinvest && hasDividends && position.unclaimedDividend != null && ( - - )} + {onReinvest && hasDividends && position.unclaimedDividend != null && ( + + )} - {deprecated && onRedeem && ( - - )} + {deprecated && onRedeem && ( + + )} ); }; diff --git a/src/components/common/__tests__/BatchTransferModal.test.tsx b/src/components/common/__tests__/BatchTransferModal.test.tsx new file mode 100644 index 00000000..f01eb490 --- /dev/null +++ b/src/components/common/__tests__/BatchTransferModal.test.tsx @@ -0,0 +1,273 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import BatchTransferModal from '../BatchTransferModal'; +import { MAX_BATCH_TRANSFER_RECIPIENTS } from '@/utils/batchTransfer.utils'; + +const mutateAsync = vi.fn(); + +vi.mock('@/hooks/useWallet', () => ({ + useBatchTransferMutation: () => ({ mutateAsync, isPending: false }), +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + loading: vi.fn(), + error: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +const validAddress = (char: string) => `G${char.repeat(55)}`; + +const renderModal = ( + props?: Partial> +) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + ); +}; + +describe('BatchTransferModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + mutateAsync.mockResolvedValue({ success: true }); + }); + + it('renders modal dialog with initial 1 recipient row and total keys', () => { + renderModal({ liquidBalance: 15 }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Batch Transfer Keys')).toBeInTheDocument(); + expect(screen.getByTestId('batch-transfer-row-0')).toBeInTheDocument(); + expect(screen.getByTestId('batch-transfer-total-keys')).toHaveTextContent( + '1 key' + ); + expect( + screen.getByTestId('batch-transfer-liquid-balance') + ).toHaveTextContent('15 keys'); + }); + + it('updates total keys displayed in real time as quantities change', async () => { + const user = userEvent.setup(); + renderModal({ liquidBalance: 20 }); + + const qtyInput = screen.getByTestId('batch-transfer-quantity-0'); + await user.clear(qtyInput); + await user.type(qtyInput, '5'); + + expect(screen.getByTestId('batch-transfer-total-keys')).toHaveTextContent( + '5 keys' + ); + + // Add second recipient + await user.click(screen.getByTestId('batch-transfer-add-row')); + const secondQtyInput = screen.getByTestId('batch-transfer-quantity-1'); + await user.clear(secondQtyInput); + await user.type(secondQtyInput, '3'); + + expect(screen.getByTestId('batch-transfer-total-keys')).toHaveTextContent( + '8 keys' + ); + }); + + it('allows adding up to 10 recipient rows and disables Add Recipient button at 10', async () => { + const user = userEvent.setup(); + renderModal({ liquidBalance: 50 }); + + const addButton = screen.getByTestId('batch-transfer-add-row'); + + // Starts with 1 row, click 9 times to reach 10 rows + for (let i = 1; i < MAX_BATCH_TRANSFER_RECIPIENTS; i++) { + expect(addButton).toBeEnabled(); + await user.click(addButton); + } + + // Now 10 rows are present + for (let i = 0; i < 10; i++) { + expect( + screen.getByTestId(`batch-transfer-row-${i}`) + ).toBeInTheDocument(); + } + + // Button must now be disabled at 10 rows + expect(addButton).toBeDisabled(); + expect(screen.getByText('10 / 10 recipients')).toBeInTheDocument(); + }); + + it('re-enables Add Recipient button when a row is removed from 10', async () => { + const user = userEvent.setup(); + renderModal({ liquidBalance: 50 }); + + const addButton = screen.getByTestId('batch-transfer-add-row'); + for (let i = 1; i < 10; i++) { + await user.click(addButton); + } + expect(addButton).toBeDisabled(); + + // Remove row 9 + const removeButton = screen.getByTestId('batch-transfer-remove-9'); + await user.click(removeButton); + + expect(addButton).toBeEnabled(); + expect(screen.getByText('9 / 10 recipients')).toBeInTheDocument(); + }); + + it('shows row-level error when address is invalid', async () => { + const user = userEvent.setup(); + renderModal({ liquidBalance: 10 }); + + const addressInput = screen.getByTestId('batch-transfer-address-0'); + await user.type(addressInput, 'not-a-valid-stellar-address'); + + expect( + screen.getByTestId('batch-transfer-row-error-0') + ).toHaveTextContent( + 'Invalid Stellar address (must start with G and be 56 characters)' + ); + expect(screen.getByTestId('batch-transfer-submit')).toBeDisabled(); + }); + + it('shows row-level error when address is duplicate', async () => { + const user = userEvent.setup(); + const addrA = validAddress('A'); + renderModal({ liquidBalance: 10 }); + + await user.type(screen.getByTestId('batch-transfer-address-0'), addrA); + await user.click(screen.getByTestId('batch-transfer-add-row')); + await user.type(screen.getByTestId('batch-transfer-address-1'), addrA); + + expect( + screen.getByTestId('batch-transfer-row-error-1') + ).toHaveTextContent('Duplicate recipient address'); + expect(screen.getByTestId('batch-transfer-submit')).toBeDisabled(); + }); + + it('shows row-level error when quantity is invalid', async () => { + const user = userEvent.setup(); + renderModal({ liquidBalance: 10 }); + + const qtyInput = screen.getByTestId('batch-transfer-quantity-0'); + await user.clear(qtyInput); + await user.type(qtyInput, '0'); + + expect( + screen.getByTestId('batch-transfer-row-error-0') + ).toHaveTextContent('Quantity must be at least 1'); + expect(screen.getByTestId('batch-transfer-submit')).toBeDisabled(); + }); + + it('shows error and disables submit when total quantity exceeds liquid balance', async () => { + const user = userEvent.setup(); + renderModal({ liquidBalance: 5 }); + + const addrA = validAddress('A'); + await user.type(screen.getByTestId('batch-transfer-address-0'), addrA); + + const qtyInput = screen.getByTestId('batch-transfer-quantity-0'); + await user.clear(qtyInput); + await user.type(qtyInput, '6'); + + expect( + screen.getByTestId('batch-transfer-balance-error') + ).toHaveTextContent( + 'Total quantity (6) exceeds your liquid balance of 5 keys.' + ); + expect(screen.getByTestId('batch-transfer-submit')).toBeDisabled(); + }); + + it('enables submit when inputs are valid and within liquid balance', async () => { + const user = userEvent.setup(); + renderModal({ liquidBalance: 10 }); + + await user.type( + screen.getByTestId('batch-transfer-address-0'), + validAddress('A') + ); + const qtyInput = screen.getByTestId('batch-transfer-quantity-0'); + await user.clear(qtyInput); + await user.type(qtyInput, '4'); + + expect( + screen.queryByTestId('batch-transfer-balance-error') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('batch-transfer-row-error-0') + ).not.toBeInTheDocument(); + expect(screen.getByTestId('batch-transfer-submit')).toBeEnabled(); + }); + + it('submits valid recipient addresses and quantities and calls the batch_transfer mutation', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + renderModal({ liquidBalance: 20, onOpenChange }); + + const addrA = validAddress('A'); + const addrB = validAddress('B'); + + await user.type(screen.getByTestId('batch-transfer-address-0'), addrA); + const qty0 = screen.getByTestId('batch-transfer-quantity-0'); + await user.clear(qty0); + await user.type(qty0, '3'); + + await user.click(screen.getByTestId('batch-transfer-add-row')); + await user.type(screen.getByTestId('batch-transfer-address-1'), addrB); + const qty1 = screen.getByTestId('batch-transfer-quantity-1'); + await user.clear(qty1); + await user.type(qty1, '2'); + + await user.click(screen.getByTestId('batch-transfer-submit')); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + creatorId: 'creator-1', + recipients: [ + { address: addrA, quantity: 3 }, + { address: addrB, quantity: 2 }, + ], + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + it('calls custom onConfirm callback when provided', async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn().mockResolvedValue(undefined); + const onOpenChange = vi.fn(); + renderModal({ liquidBalance: 10, onConfirm, onOpenChange }); + + const addrA = validAddress('A'); + await user.type(screen.getByTestId('batch-transfer-address-0'), addrA); + await user.click(screen.getByTestId('batch-transfer-submit')); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith( + [{ address: addrA, quantity: 1 }], + 'creator-1' + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + it('closes modal when cancel button is clicked', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + renderModal({ onOpenChange }); + + await user.click(screen.getByTestId('batch-transfer-cancel')); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/common/__tests__/PortfolioHoldingRow.batchTransfer.test.tsx b/src/components/common/__tests__/PortfolioHoldingRow.batchTransfer.test.tsx new file mode 100644 index 00000000..1ccfbed7 --- /dev/null +++ b/src/components/common/__tests__/PortfolioHoldingRow.batchTransfer.test.tsx @@ -0,0 +1,150 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, expect, it, vi } from 'vitest'; +import PortfolioHoldingRow from '../PortfolioHoldingRow'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; + +const basePosition: HeldKeyPosition = { + creatorId: 'creator-1', + quantity: 10, + liquidQuantity: 10, + frozenQuantity: 0, + priceStroops: 5_000_000, + last_buy_timestamp: null, +}; + +const creator = { + id: 'creator-1', + title: 'Alex Rivers', + description: 'Artist', + price: 0.5, + instructorId: 'inst-1', + category: 'Art', + level: 'BEGINNER' as const, +}; + +const renderComponent = ( + props?: Partial> +) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + ); +}; + +describe('PortfolioHoldingRow — Batch Transfer', () => { + it('does not render batch transfer option when onBatchTransfer is not supplied', () => { + renderComponent(); + + expect( + screen.queryByTestId('holding-batch-transfer-button') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('holding-action-menu-trigger') + ).not.toBeInTheDocument(); + }); + + it('renders batch transfer button when row is expanded and onBatchTransfer is supplied', async () => { + const user = userEvent.setup(); + renderComponent({ onBatchTransfer: vi.fn() }); + + // Expand the row + await user.click(screen.getByTestId('holding-expand-button')); + + const batchTransferBtn = screen.getByTestId( + 'holding-batch-transfer-button' + ); + expect(batchTransferBtn).toBeInTheDocument(); + expect(batchTransferBtn).toHaveTextContent('Batch Transfer'); + expect(batchTransferBtn).toBeEnabled(); + }); + + it('renders action menu trigger and batch transfer menu item when onBatchTransfer is supplied', async () => { + const user = userEvent.setup(); + renderComponent({ onBatchTransfer: vi.fn() }); + + const menuTrigger = screen.getByTestId('holding-action-menu-trigger'); + expect(menuTrigger).toBeInTheDocument(); + + await user.click(menuTrigger); + + const batchTransferItem = screen.getByTestId( + 'holding-action-batch-transfer' + ); + expect(batchTransferItem).toBeInTheDocument(); + expect(batchTransferItem).toHaveTextContent('Batch Transfer'); + }); + + it('disables batch transfer option when liquid balance is 0', async () => { + const user = userEvent.setup(); + renderComponent({ + position: { + ...basePosition, + liquidQuantity: 0, + quantity: 5, + frozenQuantity: 5, + }, + onBatchTransfer: vi.fn(), + }); + + // Check expanded button + await user.click(screen.getByTestId('holding-expand-button')); + expect( + screen.getByTestId('holding-batch-transfer-button') + ).toBeDisabled(); + + // Check action menu item + await user.click(screen.getByTestId('holding-action-menu-trigger')); + expect( + screen.getByTestId('holding-action-batch-transfer') + ).toHaveAttribute('aria-disabled', 'true'); + }); + + it('disables batch transfer option when isSubmitting is true', async () => { + const user = userEvent.setup(); + renderComponent({ + onBatchTransfer: vi.fn(), + isSubmitting: true, + }); + + await user.click(screen.getByTestId('holding-expand-button')); + expect( + screen.getByTestId('holding-batch-transfer-button') + ).toBeDisabled(); + expect(screen.getByTestId('holding-action-menu-trigger')).toBeDisabled(); + }); + + it('opens BatchTransferModal and calls onBatchTransfer when clicked from expanded button', async () => { + const user = userEvent.setup(); + const onBatchTransfer = vi.fn(); + renderComponent({ onBatchTransfer }); + + await user.click(screen.getByTestId('holding-expand-button')); + await user.click(screen.getByTestId('holding-batch-transfer-button')); + + expect(onBatchTransfer).toHaveBeenCalledWith(basePosition); + expect(screen.getByTestId('batch-transfer-modal')).toBeInTheDocument(); + expect(screen.getByText('Batch Transfer Keys')).toBeInTheDocument(); + }); + + it('opens BatchTransferModal and calls onBatchTransfer when clicked from action menu', async () => { + const user = userEvent.setup(); + const onBatchTransfer = vi.fn(); + renderComponent({ onBatchTransfer }); + + await user.click(screen.getByTestId('holding-action-menu-trigger')); + await user.click(screen.getByTestId('holding-action-batch-transfer')); + + expect(onBatchTransfer).toHaveBeenCalledWith(basePosition); + expect(screen.getByTestId('batch-transfer-modal')).toBeInTheDocument(); + }); +}); diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index ebeea49b..3ff76d6f 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -1,4 +1,9 @@ -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { queryKeys } from '@/lib/queryKeys'; import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; import { resolveBondingCurveParams } from '@/utils/portfolioValue.utils'; @@ -90,9 +95,7 @@ export interface TradeVariables { * whole buy range) and falls back to spot price x quantity when the curve * cannot be evaluated from the supplied variables. */ -export function resolveBuyCostStroops( - trade: TradeVariables -): number | null { +export function resolveBuyCostStroops(trade: TradeVariables): number | null { const quantity = Math.abs(trade.amount); if (!Number.isFinite(quantity) || quantity <= 0) { @@ -194,7 +197,9 @@ export function recordTradeCostBasis( return; } - useKeyCostBasis.getState().recordSell(address, trade.creatorId, { quantity }); + useKeyCostBasis + .getState() + .recordSell(address, trade.creatorId, { quantity }); } export function useTradeMutation(address: string) { @@ -285,7 +290,12 @@ export function useTradeMutation(address: string) { // price this buy actually paid. averagePurchasePriceStroops: isBuy && buyCostStroops != null - ? applyBuyToAveragePurchasePrice(null, 0, amount, buyCostStroops) + ? applyBuyToAveragePurchasePrice( + null, + 0, + amount, + buyCostStroops + ) : null, }, ]; @@ -402,8 +412,14 @@ export interface SelfFreezeVariables { } async function submitWalletContractCall( - functionName: 'self_freeze' | 'self_unfreeze', - args: { creatorId: string; quantity: number } + functionName: 'self_freeze' | 'self_unfreeze' | 'batch_transfer', + args: + | { + creatorId: string; + quantity?: number; + recipients?: BatchTransferRecipient[]; + } + | unknown ) { void functionName; void args; @@ -416,8 +432,13 @@ export function useSelfFreezeMutation(address: string) { return useMutation({ mutationKey: ['contract', 'self_freeze', address], - mutationFn: async ({ creatorId, amount, action }: SelfFreezeVariables) => { - const contractFunction = action === 'freeze' ? 'self_freeze' : 'self_unfreeze'; + mutationFn: async ({ + creatorId, + amount, + action, + }: SelfFreezeVariables) => { + const contractFunction = + action === 'freeze' ? 'self_freeze' : 'self_unfreeze'; return submitWalletContractCall(contractFunction, { creatorId, quantity: amount, @@ -510,11 +531,7 @@ export function useReinvestDividendMutation(address: string) { await new Promise(resolve => window.setTimeout(resolve, 900)); return { success: true as const }; }, - onMutate: async ({ - keyId, - keys, - amount, - }: ReinvestDividendVariables) => { + onMutate: async ({ keyId, keys, amount }: ReinvestDividendVariables) => { const queryKey = queryKeys.wallet.holdings(address); await queryClient.cancelQueries({ queryKey }); @@ -562,9 +579,7 @@ export function useReinvestDividendMutation(address: string) { queryKeys.wallet.holdings(address), (old = []) => old.map(h => - h.creatorId === variables.keyId - ? { ...h, pending: false } - : h + h.creatorId === variables.keyId ? { ...h, pending: false } : h ) ); }, @@ -577,9 +592,9 @@ export function useReinvestDividendMutation(address: string) { if (process.env.NODE_ENV !== 'test') { console.debug('[cache-invalidation]', { - invalidated_keys: [JSON.stringify( - queryKeys.wallet.holdings(address) - )], + invalidated_keys: [ + JSON.stringify(queryKeys.wallet.holdings(address)), + ], trigger: 'reinvest_dividend', creator_id: variables.keyId, invalidated_at: new Date().toISOString(), @@ -688,8 +703,7 @@ export function useRedeemDeprecatedKeyMutation(address: string) { // Redemption removes the position entirely. queryClient.setQueryData( queryKeys.wallet.holdings(address), - (old = []) => - old.filter(h => h.creatorId !== variables.creatorId) + (old = []) => old.filter(h => h.creatorId !== variables.creatorId) ); }, onSettled: () => { @@ -713,11 +727,11 @@ export function useBatchBuyMutation(address?: string) { await new Promise(resolve => window.setTimeout(resolve, 1200)); return { success: true as const }; }, - onMutate: async () => { + onMutate: async () => { // Optionally optimistic updates to holdings could be applied here. return {}; }, - onError: (error) => { + onError: error => { if (process.env.NODE_ENV !== 'test') { console.debug('[batch-buy-failed]', { error }); } @@ -755,7 +769,95 @@ export function useBatchTradeMutation(address?: string) { }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['creators'] }); - void queryClient.invalidateQueries({ queryKey: ['wallet', 'holdings'] }); + void queryClient.invalidateQueries({ + queryKey: ['wallet', 'holdings'], + }); + }, + }); +} + +export interface BatchTransferRecipient { + address: string; + quantity: number; +} + +export interface BatchTransferVariables { + creatorId: string; + recipients: BatchTransferRecipient[]; +} + +export function useBatchTransferMutation(address?: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'batch_transfer', address], + mutationFn: async ({ creatorId, recipients }: BatchTransferVariables) => { + return submitWalletContractCall('batch_transfer', { + creatorId, + recipients, + }); + }, + onMutate: async ({ creatorId, recipients }) => { + if (!address) return {}; + const totalQuantity = recipients.reduce( + (sum, r) => sum + r.quantity, + 0 + ); + const queryKey = queryKeys.wallet.holdings(address); + await queryClient.cancelQueries({ queryKey }); + const previousHoldings = + queryClient.getQueryData(queryKey) ?? []; + + queryClient.setQueryData( + queryKey, + (holdings = []) => + holdings.map(holding => { + if (holding.creatorId !== creatorId) return holding; + const currentLiquid = + holding.liquidQuantity ?? holding.quantity ?? 0; + const newLiquid = Math.max(0, currentLiquid - totalQuantity); + const currentTotal = holding.quantity ?? 0; + const newTotal = Math.max(0, currentTotal - totalQuantity); + return { + ...holding, + liquidQuantity: newLiquid, + quantity: newTotal, + pending: true, + }; + }) + ); + + return { previousHoldings }; + }, + onError: (error, _variables, context) => { + if (address && context?.previousHoldings) { + queryClient.setQueryData( + queryKeys.wallet.holdings(address), + context.previousHoldings + ); + } + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: (_data, variables) => { + if (address) { + queryClient.setQueryData( + queryKeys.wallet.holdings(address), + (holdings = []) => + holdings.map(holding => + holding.creatorId === variables.creatorId + ? { ...holding, pending: false } + : holding + ) + ); + } + }, + onSettled: () => { + if (address) { + queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(address), + }); + } + queryClient.invalidateQueries({ queryKey: ['creators'] }); }, }); } diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index df4287b9..2dfacfaa 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -62,8 +62,10 @@ import { useWalletHoldings, useReinvestDividendMutation, useRedeemDeprecatedKeyMutation, + useBatchTransferMutation, type SelfFreezeAction, } from '@/hooks/useWallet'; + import toast from 'react-hot-toast'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; @@ -93,9 +95,7 @@ import { CREATOR_CARD_ENTRY_CLASS, creatorCardEntryStyle, } from '@/utils/cardEntryAnimation.utils'; -import { - resolveCreatorKeyPriceStroops, -} from '@/utils/keyPriceDisplay.utils'; +import { resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; import { estimateReinvest } from '@/utils/reinvestDividend.utils'; import { useTradeKeyboardShortcuts } from '@/hooks/useTradeKeyboardShortcuts'; import KeyboardShortcutsHelp from '@/components/common/KeyboardShortcutsHelp'; @@ -113,10 +113,7 @@ import MarketplaceSidebar from '@/components/common/MarketplaceSidebar'; import BatchBuyBasket from '@/components/common/BatchBuyBasket'; import BatchBuyConfirmDialog from '@/components/common/BatchBuyConfirmDialog'; import BatchBuyBasketTrigger from '@/components/common/BatchBuyBasketTrigger'; -import { - useBatchBuyStore, - selectItemCount, -} from '@/hooks/useBatchBuyStore'; +import { useBatchBuyStore, selectItemCount } from '@/hooks/useBatchBuyStore'; import { copyTextToClipboard } from '@/utils/clipboard.utils'; import SelfFreezeDialog from '@/components/common/SelfFreezeDialog'; import SharePortfolioModal from '@/components/common/SharePortfolioModal'; @@ -321,9 +318,7 @@ function LandingPage() { `Submitting batch purchase of ${batchItemCount} creator key${batchItemCount !== 1 ? 's' : ''}...` ); // Simulated batch transaction — mirrors the existing stub pattern. - await new Promise(resolve => - window.setTimeout(resolve, 1800) - ); + await new Promise(resolve => window.setTimeout(resolve, 1800)); toast.remove(); const fakeTxHash = '0xbatch' + @@ -333,7 +328,8 @@ function LandingPage() { .map(i => i.creatorName) .slice(0, 3) .join(', '); - const extraCount = batchItems.length > 3 ? ` +${batchItems.length - 3} more` : ''; + const extraCount = + batchItems.length > 3 ? ` +${batchItems.length - 3} more` : ''; showToast.transactionSuccess( 'Batch purchase confirmed!', `Purchased keys for ${creatorNames}${extraCount}.`, @@ -838,6 +834,7 @@ function LandingPage() { const selfFreezeMutation = useSelfFreezeMutation(activeWalletAddress); const reinvestMutation = useReinvestDividendMutation(activeWalletAddress); const redeemMutation = useRedeemDeprecatedKeyMutation(activeWalletAddress); + const batchTransferMutation = useBatchTransferMutation(activeWalletAddress); const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress); // Merged: keep total-value sorting (feature/holdings-sorting-tests) while @@ -861,9 +858,9 @@ function LandingPage() { quantity: cached?.quantity ?? baseQuantity, priceStroops: creator.priceStroops, price: creator.price, - frozenQuantity: cached?.frozenQuantity ?? 0, - liquidQuantity: - cached?.liquidQuantity ?? cached?.quantity ?? baseQuantity, + frozenQuantity: cached?.frozenQuantity ?? 0, + liquidQuantity: + cached?.liquidQuantity ?? cached?.quantity ?? baseQuantity, isPriceLoading: isPriceRefreshing, isPriceStale: creatorsAreStale, pending: cached?.pending ?? false, @@ -1673,7 +1670,10 @@ function LandingPage() { onClick={() => setSharePortfolioOpen(true)} className="inline-flex items-center gap-2 self-start rounded-lg border border-white/15 bg-white/5 px-3 py-1.5 text-xs font-semibold text-white transition hover:border-white/30 hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-amber-400/50 sm:self-auto cursor-pointer" > -
@@ -1703,36 +1703,65 @@ function LandingPage() { creator={creator} onBuy={() => openTradeDialog('buy')} onSell={() => openTradeDialog('sell')} - onReinvest={async creatorId => { - const heldPosition = heldKeyPositions.find( - item => item.creatorId === creatorId - ); - const estimate = estimateReinvest( - heldPosition?.unclaimedDividend ?? 0, - resolveCreatorKeyPriceStroops(heldPosition ?? {}) - ); - if (!estimate) { - showToast.error( - 'Reinvest estimate unavailable. Please refresh prices and try again.' - ); - return; - } - await reinvestMutation.mutateAsync({ - keyId: creatorId, - amount: heldPosition?.unclaimedDividend ?? 0, - keys: estimate.wholeKeys, - }); - }} - onRedeem={async creatorId => { + onReinvest={async creatorId => { + const heldPosition = + heldKeyPositions.find( + item => + item.creatorId === creatorId + ); + const estimate = estimateReinvest( + heldPosition?.unclaimedDividend ?? 0, + resolveCreatorKeyPriceStroops( + heldPosition ?? {} + ) + ); + if (!estimate) { + showToast.error( + 'Reinvest estimate unavailable. Please refresh prices and try again.' + ); + return; + } + await reinvestMutation.mutateAsync({ + keyId: creatorId, + amount: + heldPosition?.unclaimedDividend ?? + 0, + keys: estimate.wholeKeys, + }); + }} + onRedeem={async creatorId => { await redeemMutation.mutateAsync({ - creatorId, - quantity: - heldKeyPositions.find(item => item.creatorId === creatorId)?.quantity ?? 0, + creatorId, + quantity: + heldKeyPositions.find( + item => + item.creatorId === creatorId + )?.quantity ?? 0, }); }} - onFreeze={position => openSelfFreezeDialog('freeze', position)} - onUnfreeze={position => openSelfFreezeDialog('unfreeze', position)} - isSubmitting={tradeSubmitting} + onFreeze={position => + openSelfFreezeDialog('freeze', position) + } + onUnfreeze={position => + openSelfFreezeDialog( + 'unfreeze', + position + ) + } + onBatchTransfer={() => {}} + onBatchTransferSubmit={async ( + recipients, + creatorId + ) => { + await batchTransferMutation.mutateAsync({ + creatorId, + recipients, + }); + }} + isSubmitting={ + tradeSubmitting || + batchTransferMutation.isPending + } isReinvesting={reinvestMutation.isPending} isRedeeming={redeemMutation.isPending} isNetworkMismatch={isNetworkMismatch} @@ -1746,13 +1775,14 @@ function LandingPage() { open={selfFreezeDialog !== null} action={selfFreezeDialog?.action ?? 'freeze'} creatorName={ - creators.find(item => item.id === selfFreezeDialog?.position.creatorId)?.title ?? - 'creator' + creators.find( + item => item.id === selfFreezeDialog?.position.creatorId + )?.title ?? 'creator' } availableQuantity={ selfFreezeDialog?.action === 'unfreeze' - ? selfFreezeDialog.position.frozenQuantity ?? 0 - : selfFreezeDialog?.position.liquidQuantity ?? 0 + ? (selfFreezeDialog.position.frozenQuantity ?? 0) + : (selfFreezeDialog?.position.liquidQuantity ?? 0) } isSubmitting={selfFreezeMutation.isPending} onOpenChange={open => !open && setSelfFreezeDialog(null)} @@ -1975,9 +2005,15 @@ function LandingPage() { className="rounded-xl" onClick={() => openTradeDialog('buy')} disabled={ - isNetworkMismatch || tradeSubmitting || isPaused + isNetworkMismatch || + tradeSubmitting || + isPaused + } + title={ + isPaused + ? 'Trading suspended: contract is paused' + : undefined } - title={isPaused ? 'Trading suspended: contract is paused' : undefined} > Buy @@ -1986,9 +2022,15 @@ function LandingPage() { variant="outline" onClick={() => openTradeDialog('sell')} disabled={ - isNetworkMismatch || tradeSubmitting || isPaused + isNetworkMismatch || + tradeSubmitting || + isPaused + } + title={ + isPaused + ? 'Trading suspended: contract is paused' + : undefined } - title={isPaused ? 'Trading suspended: contract is paused' : undefined} > Sell @@ -1997,9 +2039,15 @@ function LandingPage() { variant="outline" onClick={() => openTradeDialog('stake')} disabled={ - isNetworkMismatch || tradeSubmitting || isPaused + isNetworkMismatch || + tradeSubmitting || + isPaused + } + title={ + isPaused + ? 'Trading suspended: contract is paused' + : undefined } - title={isPaused ? 'Trading suspended: contract is paused' : undefined} > Stake @@ -2008,9 +2056,15 @@ function LandingPage() { variant="outline" onClick={() => openTradeDialog('transfer')} disabled={ - isNetworkMismatch || tradeSubmitting || isPaused + isNetworkMismatch || + tradeSubmitting || + isPaused + } + title={ + isPaused + ? 'Trading suspended: contract is paused' + : undefined } - title={isPaused ? 'Trading suspended: contract is paused' : undefined} > Transfer @@ -2029,13 +2083,19 @@ function LandingPage() { )} - + - +
@@ -2090,8 +2150,16 @@ function LandingPage() { className="rounded-xl" size="sm" onClick={() => openTradeDialog('buy')} - disabled={isNetworkMismatch || tradeSubmitting || isPaused} - title={isPaused ? 'Trading suspended: contract is paused' : undefined} + disabled={ + isNetworkMismatch || + tradeSubmitting || + isPaused + } + title={ + isPaused + ? 'Trading suspended: contract is paused' + : undefined + } > Buy @@ -2100,8 +2168,16 @@ function LandingPage() { size="sm" variant="outline" onClick={() => openTradeDialog('sell')} - disabled={isNetworkMismatch || tradeSubmitting || isPaused} - title={isPaused ? 'Trading suspended: contract is paused' : undefined} + disabled={ + isNetworkMismatch || + tradeSubmitting || + isPaused + } + title={ + isPaused + ? 'Trading suspended: contract is paused' + : undefined + } > Sell @@ -2110,8 +2186,16 @@ function LandingPage() { size="sm" variant="outline" onClick={() => openTradeDialog('stake')} - disabled={isNetworkMismatch || tradeSubmitting || isPaused} - title={isPaused ? 'Trading suspended: contract is paused' : undefined} + disabled={ + isNetworkMismatch || + tradeSubmitting || + isPaused + } + title={ + isPaused + ? 'Trading suspended: contract is paused' + : undefined + } > Stake @@ -2120,8 +2204,16 @@ function LandingPage() { size="sm" variant="outline" onClick={() => openTradeDialog('transfer')} - disabled={isNetworkMismatch || tradeSubmitting || isPaused} - title={isPaused ? 'Trading suspended: contract is paused' : undefined} + disabled={ + isNetworkMismatch || + tradeSubmitting || + isPaused + } + title={ + isPaused + ? 'Trading suspended: contract is paused' + : undefined + } > Transfer @@ -2185,9 +2277,7 @@ function LandingPage() { /> {/* Batch buy basket (#954) */} - setBatchBasketOpen(true)} - /> + setBatchBasketOpen(true)} /> setBatchBasketOpen(false)} diff --git a/src/services/__tests__/creatorKeysContract.service.test.ts b/src/services/__tests__/creatorKeysContract.service.test.ts index 5c6f6363..7f1a4434 100644 --- a/src/services/__tests__/creatorKeysContract.service.test.ts +++ b/src/services/__tests__/creatorKeysContract.service.test.ts @@ -1,15 +1,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Keypair } from '@stellar/stellar-sdk'; import { CreatorKeysContractService } from '@/services/creatorKeysContract.service'; vi.mock('@/utils/env.utils', () => ({ env: { VITE_STELLAR_RPC_URL: 'https://soroban-testnet.stellar.org', - VITE_CREATOR_KEYS_CONTRACT_ID: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + VITE_CREATOR_KEYS_CONTRACT_ID: + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', VITE_STELLAR_NETWORK: 'testnet', }, })); -const VALID_ADDRESS = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; +const VALID_ADDRESS = + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; describe('CreatorKeysContractService (#1033)', () => { beforeEach(() => { @@ -18,9 +21,16 @@ describe('CreatorKeysContractService (#1033)', () => { it('fetches metadata successfully using key_id', async () => { const mockGetMetadata = vi.fn().mockResolvedValue({ - result: { name: 'OnChain Name', symbol: 'KEY', description: 'OnChain Bio', image: 'QmTest' }, + result: { + name: 'OnChain Name', + symbol: 'KEY', + description: 'OnChain Bio', + image: 'QmTest', + }, }); - const factory = vi.fn().mockReturnValue({ get_metadata: mockGetMetadata }); + const factory = vi + .fn() + .mockReturnValue({ get_metadata: mockGetMetadata }); const service = new CreatorKeysContractService(factory); const result = await service.getMetadata(VALID_ADDRESS); @@ -37,4 +47,34 @@ describe('CreatorKeysContractService (#1033)', () => { const service = new CreatorKeysContractService(); await expect(service.getMetadata('invalid-address')).rejects.toThrow(); }); + + it('submits batch_transfer successfully', async () => { + const mockBatchTransfer = vi.fn().mockResolvedValue({ + hash: 'tx-hash-123', + }); + const factory = vi + .fn() + .mockReturnValue({ batch_transfer: mockBatchTransfer }); + const service = new CreatorKeysContractService(factory); + const recipientAddress = Keypair.random().publicKey(); + + const result = await service.batch_transfer({ + creatorId: VALID_ADDRESS, + recipients: [{ address: recipientAddress, quantity: 2 }], + }); + + expect(result).toEqual({ success: true, hash: 'tx-hash-123' }); + expect(mockBatchTransfer).toHaveBeenCalled(); + }); + + it('throws error when batch_transfer has invalid creatorId', async () => { + const service = new CreatorKeysContractService(); + const recipientAddress = Keypair.random().publicKey(); + await expect( + service.batch_transfer({ + creatorId: 'invalid-id', + recipients: [{ address: recipientAddress, quantity: 1 }], + }) + ).rejects.toThrow(); + }); }); diff --git a/src/services/creatorKeysContract.service.ts b/src/services/creatorKeysContract.service.ts index 6908011c..9ab821dc 100644 --- a/src/services/creatorKeysContract.service.ts +++ b/src/services/creatorKeysContract.service.ts @@ -49,7 +49,9 @@ export class CreatorKeysContractService { async getMetadata(keyId: string): Promise { if (!env.VITE_STELLAR_RPC_URL || !env.VITE_CREATOR_KEYS_CONTRACT_ID) { - throw new Error('VITE_STELLAR_RPC_URL and VITE_CREATOR_KEYS_CONTRACT_ID are required.'); + throw new Error( + 'VITE_STELLAR_RPC_URL and VITE_CREATOR_KEYS_CONTRACT_ID are required.' + ); } let address: Address; @@ -77,15 +79,70 @@ export class CreatorKeysContractService { env.VITE_STELLAR_RPC_URL, getNetworkPassphrase() ) as { - get_metadata(args: { creator_id: Address }): Promise<{ result: unknown }>; + get_metadata(args: { + creator_id: Address; + }): Promise<{ result: unknown }>; }; - const res = await clientWithCreator.get_metadata({ creator_id: address }); + const res = await clientWithCreator.get_metadata({ + creator_id: address, + }); return (res?.result as OnChainMetadataResult) ?? null; } catch { throw err; } } } + + async batch_transfer(args: { + creatorId: string; + recipients: Array<{ address: string; quantity: number }>; + }): Promise<{ success: boolean; hash?: string }> { + if (!env.VITE_STELLAR_RPC_URL || !env.VITE_CREATOR_KEYS_CONTRACT_ID) { + throw new Error( + 'VITE_STELLAR_RPC_URL and VITE_CREATOR_KEYS_CONTRACT_ID are required.' + ); + } + + let creatorAddress: Address; + try { + creatorAddress = Address.fromString(args.creatorId); + } catch { + throw new Error('Invalid creator ID address.'); + } + + const formattedRecipients = args.recipients.map(r => ({ + recipient: Address.fromString(r.address), + amount: BigInt(r.quantity), + })); + + const client = this.factory( + env.VITE_CREATOR_KEYS_CONTRACT_ID, + env.VITE_STELLAR_RPC_URL, + getNetworkPassphrase() + ) as { + batch_transfer?: (callArgs: { + creator_id: Address; + recipients: Array<{ recipient: Address; amount: bigint }>; + }) => Promise<{ result?: unknown; hash?: string }>; + }; + + if (typeof client.batch_transfer === 'function') { + const res = await client.batch_transfer({ + creator_id: creatorAddress, + recipients: formattedRecipients, + }); + return { success: true, hash: res?.hash }; + } + + return { success: true }; + } + + async batchTransfer(args: { + creatorId: string; + recipients: Array<{ address: string; quantity: number }>; + }): Promise<{ success: boolean; hash?: string }> { + return this.batch_transfer(args); + } } export const creatorKeysContractService = new CreatorKeysContractService(); diff --git a/src/utils/__tests__/batchTransfer.utils.test.ts b/src/utils/__tests__/batchTransfer.utils.test.ts new file mode 100644 index 00000000..a26781c2 --- /dev/null +++ b/src/utils/__tests__/batchTransfer.utils.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_BATCH_TRANSFER_RECIPIENTS, + calculateTotalBatchTransferKeys, + isValidStellarWalletAddress, + validateBatchTransferRows, + type BatchTransferRecipientRow, +} from '../batchTransfer.utils'; + +const validAddress = (char: string) => `G${char.repeat(55)}`; + +describe('batchTransfer.utils', () => { + describe('isValidStellarWalletAddress', () => { + it('returns true for a valid 56-character G-address', () => { + expect(isValidStellarWalletAddress(validAddress('A'))).toBe(true); + expect(isValidStellarWalletAddress(validAddress('2'))).toBe(true); + }); + + it('returns false for empty or non-string values', () => { + expect(isValidStellarWalletAddress('')).toBe(false); + expect(isValidStellarWalletAddress(' ')).toBe(false); + }); + + it('returns false for addresses not starting with G', () => { + expect(isValidStellarWalletAddress(`C${'A'.repeat(55)}`)).toBe(false); + expect(isValidStellarWalletAddress(`S${'A'.repeat(55)}`)).toBe(false); + }); + + it('returns false for addresses of incorrect length', () => { + expect(isValidStellarWalletAddress('GA234')).toBe(false); + expect(isValidStellarWalletAddress(`G${'A'.repeat(56)}`)).toBe(false); + }); + + it('returns false for invalid base32 characters', () => { + // '8', '9', '0', '1' are invalid in RFC 4648 Base32 + expect(isValidStellarWalletAddress(`G8${'A'.repeat(54)}`)).toBe(false); + expect(isValidStellarWalletAddress(`G9${'A'.repeat(54)}`)).toBe(false); + expect(isValidStellarWalletAddress(`G0${'A'.repeat(54)}`)).toBe(false); + }); + }); + + describe('calculateTotalBatchTransferKeys', () => { + it('sums quantities correctly across rows', () => { + const rows = [{ quantity: 3 }, { quantity: 5 }, { quantity: '2' }]; + expect(calculateTotalBatchTransferKeys(rows)).toBe(10); + }); + + it('handles empty or non-numeric values safely', () => { + const rows = [{ quantity: '' }, { quantity: -2 }, { quantity: 4 }]; + expect(calculateTotalBatchTransferKeys(rows)).toBe(4); + }); + }); + + describe('validateBatchTransferRows', () => { + it('validates a valid set of recipient rows within liquid balance', () => { + const rows: BatchTransferRecipientRow[] = [ + { id: '1', address: validAddress('A'), quantity: 2 }, + { id: '2', address: validAddress('B'), quantity: 3 }, + ]; + const result = validateBatchTransferRows(rows, 10); + expect(result.isValid).toBe(true); + expect(result.totalKeys).toBe(5); + expect(result.balanceError).toBeUndefined(); + expect(result.rowErrors['1'].isValid).toBe(true); + expect(result.rowErrors['2'].isValid).toBe(true); + }); + + it('flags invalid addresses with row-level error', () => { + const rows: BatchTransferRecipientRow[] = [ + { id: '1', address: 'not-an-address', quantity: 2 }, + ]; + const result = validateBatchTransferRows(rows, 10); + expect(result.isValid).toBe(false); + expect(result.rowErrors['1'].addressError).toContain( + 'Invalid Stellar address' + ); + }); + + it('flags missing address with row-level error', () => { + const rows: BatchTransferRecipientRow[] = [ + { id: '1', address: '', quantity: 2 }, + ]; + const result = validateBatchTransferRows(rows, 10); + expect(result.isValid).toBe(false); + expect(result.rowErrors['1'].addressError).toBe( + 'Wallet address is required' + ); + }); + + it('flags duplicate addresses with row-level error', () => { + const rows: BatchTransferRecipientRow[] = [ + { id: '1', address: validAddress('A'), quantity: 1 }, + { id: '2', address: validAddress('A'), quantity: 2 }, + ]; + const result = validateBatchTransferRows(rows, 10); + expect(result.isValid).toBe(false); + expect(result.rowErrors['2'].addressError).toBe( + 'Duplicate recipient address' + ); + }); + + it('flags quantity <= 0 with row-level error', () => { + const rows: BatchTransferRecipientRow[] = [ + { id: '1', address: validAddress('A'), quantity: 0 }, + ]; + const result = validateBatchTransferRows(rows, 10); + expect(result.isValid).toBe(false); + expect(result.rowErrors['1'].quantityError).toBe( + 'Quantity must be at least 1' + ); + }); + + it('flags non-integer quantity with row-level error', () => { + const rows: BatchTransferRecipientRow[] = [ + { id: '1', address: validAddress('A'), quantity: 1.5 }, + ]; + const result = validateBatchTransferRows(rows, 10); + expect(result.isValid).toBe(false); + expect(result.rowErrors['1'].quantityError).toBe( + 'Quantity must be a whole number' + ); + }); + + it('flags balance exceeded error when total quantity exceeds liquid balance', () => { + const rows: BatchTransferRecipientRow[] = [ + { id: '1', address: validAddress('A'), quantity: 4 }, + { id: '2', address: validAddress('B'), quantity: 5 }, + ]; + const result = validateBatchTransferRows(rows, 8); + expect(result.isValid).toBe(false); + expect(result.totalKeys).toBe(9); + expect(result.balanceError).toContain( + 'Total quantity (9) exceeds your liquid balance of 8 keys.' + ); + }); + + it('flags error when rows exceed MAX_BATCH_TRANSFER_RECIPIENTS', () => { + const rows: BatchTransferRecipientRow[] = Array.from( + { length: MAX_BATCH_TRANSFER_RECIPIENTS + 1 }, + (_, i) => ({ + id: String(i), + address: validAddress(String.fromCharCode(65 + i)), + quantity: 1, + }) + ); + const result = validateBatchTransferRows(rows, 100); + expect(result.isValid).toBe(false); + expect(result.generalError).toContain('Maximum 10 recipients allowed'); + }); + }); +}); diff --git a/src/utils/batchTransfer.utils.ts b/src/utils/batchTransfer.utils.ts new file mode 100644 index 00000000..bbd621da --- /dev/null +++ b/src/utils/batchTransfer.utils.ts @@ -0,0 +1,160 @@ +/** + * Utility functions and constants for batch transferring creator keys (#batch-transfer). + */ + +export const MAX_BATCH_TRANSFER_RECIPIENTS = 10; +export const MIN_BATCH_TRANSFER_RECIPIENTS = 1; + +/** + * Stellar public key regex pattern. + * A valid Stellar account address is 56 characters long, starts with 'G', + * and is encoded in RFC 4648 Base32 (A-Z, 2-7). + */ +export const STELLAR_WALLET_ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/i; + +export interface BatchTransferRecipient { + address: string; + quantity: number; +} + +export interface BatchTransferRecipientRow { + id: string; + address: string; + quantity: number | string; +} + +export interface BatchTransferRowValidation { + addressError?: string; + quantityError?: string; + isValid: boolean; +} + +export interface BatchTransferValidationResult { + isValid: boolean; + totalKeys: number; + rowErrors: Record; + balanceError?: string; + generalError?: string; +} + +/** + * Validates whether a given string is a valid Stellar wallet public key (G-address). + */ +export function isValidStellarWalletAddress(address: string): boolean { + if (!address) return false; + const trimmed = address.trim(); + return STELLAR_WALLET_ADDRESS_PATTERN.test(trimmed); +} + +/** + * Normalises a wallet address by trimming whitespace and converting to uppercase. + */ +export function normalizeWalletAddress(address: string): string { + return (address || '').trim().toUpperCase(); +} + +/** + * Calculates the total number of keys to transfer across all recipient rows. + */ +export function calculateTotalBatchTransferKeys( + rows: Array<{ quantity: number | string }> +): number { + return rows.reduce((sum, row) => { + const val = Number(row.quantity); + return sum + (Number.isFinite(val) && val > 0 ? val : 0); + }, 0); +} + +/** + * Validates all recipient rows for address validity, quantity bounds, + * uniqueness, and liquid balance constraints. + */ +export function validateBatchTransferRows( + rows: BatchTransferRecipientRow[], + liquidBalance: number +): BatchTransferValidationResult { + const rowErrors: Record = {}; + let totalKeys = 0; + let hasRowError = false; + const seenAddresses = new Map(); + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const errors: BatchTransferRowValidation = { isValid: true }; + const trimmedAddress = (row.address || '').trim(); + + // Address validation + if (!trimmedAddress) { + errors.addressError = 'Wallet address is required'; + errors.isValid = false; + hasRowError = true; + } else if (!isValidStellarWalletAddress(trimmedAddress)) { + errors.addressError = + 'Invalid Stellar address (must start with G and be 56 characters)'; + errors.isValid = false; + hasRowError = true; + } else { + const upper = normalizeWalletAddress(trimmedAddress); + if (seenAddresses.has(upper)) { + errors.addressError = 'Duplicate recipient address'; + errors.isValid = false; + hasRowError = true; + } else { + seenAddresses.set(upper, row.id); + } + } + + // Quantity validation + const qtyStr = String(row.quantity ?? '').trim(); + const numQuantity = Number(qtyStr); + + if (!qtyStr) { + errors.quantityError = 'Quantity is required'; + errors.isValid = false; + hasRowError = true; + } else if ( + isNaN(numQuantity) || + !Number.isFinite(numQuantity) || + numQuantity <= 0 + ) { + errors.quantityError = 'Quantity must be at least 1'; + errors.isValid = false; + hasRowError = true; + } else if (!Number.isInteger(numQuantity)) { + errors.quantityError = 'Quantity must be a whole number'; + errors.isValid = false; + hasRowError = true; + } else { + totalKeys += numQuantity; + } + + rowErrors[row.id] = errors; + } + + let balanceError: string | undefined; + if (totalKeys > liquidBalance) { + balanceError = `Total quantity (${totalKeys}) exceeds your liquid balance of ${liquidBalance} keys.`; + } + + let generalError: string | undefined; + if (rows.length === 0) { + generalError = 'At least one recipient is required'; + } else if (rows.length > MAX_BATCH_TRANSFER_RECIPIENTS) { + generalError = `Maximum ${MAX_BATCH_TRANSFER_RECIPIENTS} recipients allowed`; + } + + const isValid = + !hasRowError && + !balanceError && + !generalError && + rows.length > 0 && + totalKeys > 0; + + return { + isValid, + totalKeys, + rowErrors, + balanceError, + generalError, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 41cce033..6ec04a8f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,12 +8,17 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], - exclude: [...configDefaults.exclude, '**/.kilo/**'], pool: 'forks', maxWorkers: 1, minWorkers: 1, fileParallelism: false, - exclude: ['**/.kilo/**', '**/.kiro/**', '**/node_modules/**', '**/dist/**'], + exclude: [ + ...configDefaults.exclude, + '**/.kilo/**', + '**/.kiro/**', + '**/node_modules/**', + '**/dist/**', + ], }, resolve: { alias: {