diff --git a/src/components/OfferRowCard.tsx b/src/components/OfferRowCard.tsx index 92891f502..9b3777dde 100644 --- a/src/components/OfferRowCard.tsx +++ b/src/components/OfferRowCard.tsx @@ -2,6 +2,7 @@ import { commands, OfferRecord, TransactionResponse } from '@/bindings'; import ConfirmationDialog from '@/components/ConfirmationDialog'; import { CancelOfferDialog } from '@/components/dialogs/CancelOfferDialog'; import { DeleteOfferDialog } from '@/components/dialogs/DeleteOfferDialog'; +import { DuplicateOfferDialog } from '@/components/dialogs/DuplicateOfferDialog'; import { OfferSummaryCard } from '@/components/OfferSummaryCard'; import { Button } from '@/components/ui/button'; import { @@ -24,6 +25,7 @@ import BigNumber from 'bignumber.js'; import { CircleOff, CopyIcon, + CopyPlus, MoreVertical, Tags, TrashIcon, @@ -44,6 +46,11 @@ export function OfferRowCard({ record, refresh }: OfferRowCardProps) { const { addError } = useErrors(); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isCancelOpen, setIsCancelOpen] = useState(false); + const [isDuplicateOpen, setIsDuplicateOpen] = useState(false); + + const isTokenOnlyOffer = record.summary.maker.every( + (a) => a.asset.kind === 'token', + ); const cancelSchema = z.object({ fee: amount(walletState.sync.unit.precision).refine( @@ -144,6 +151,22 @@ export function OfferRowCard({ record, refresh }: OfferRowCardProps) { Copy ID + + + + { + e.stopPropagation(); + setIsDuplicateOpen(true); + }} + disabled={!isTokenOnlyOffer} + > + @@ -181,6 +204,13 @@ export function OfferRowCard({ record, refresh }: OfferRowCardProps) { content: response && , }} /> + + ); } diff --git a/src/components/dialogs/DuplicateOfferDialog.tsx b/src/components/dialogs/DuplicateOfferDialog.tsx new file mode 100644 index 000000000..42440854f --- /dev/null +++ b/src/components/dialogs/DuplicateOfferDialog.tsx @@ -0,0 +1,446 @@ +import { commands, NetworkKind, OfferAmount, OfferRecord } from '@/bindings'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { useErrors } from '@/hooks/useErrors'; +import { useBiometric } from '@/hooks/useBiometric'; +import { marketplaces } from '@/lib/marketplaces'; +import { fromMojos, getAssetDisplayName } from '@/lib/utils'; +import { useWalletState } from '@/state'; +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import BigNumber from 'bignumber.js'; +import { LoaderCircleIcon, Minus, Plus } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +interface DuplicateOfferDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + record: OfferRecord; + onDone: () => void; +} + +type Phase = 'config' | 'progress'; + +export function DuplicateOfferDialog({ + open, + onOpenChange, + record, + onDone, +}: DuplicateOfferDialogProps) { + const walletState = useWalletState(); + const { addError } = useErrors(); + const { promptIfEnabled } = useBiometric(); + + const [copies, setCopies] = useState(1); + const [phase, setPhase] = useState('config'); + const [enabledMarketplaces, setEnabledMarketplaces] = useState< + Record + >({}); + const [balanceError, setBalanceError] = useState(null); + const [network, setNetwork] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [currentStep, setCurrentStep] = useState<'creating' | 'uploading'>( + 'creating', + ); + const [currentOfferIndex, setCurrentOfferIndex] = useState(0); + const [currentMarketplaceIndex, setCurrentMarketplaceIndex] = useState(0); + const [isDone, setIsDone] = useState(false); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setCopies(1); + setPhase('config'); + setIsCreating(false); + setIsUploading(false); + setIsDone(false); + setCurrentOfferIndex(0); + setCurrentMarketplaceIndex(0); + setCurrentStep('creating'); + setBalanceError(null); + setEnabledMarketplaces({}); + } + }, [open]); + + // Fetch network once + useEffect(() => { + if (open) { + commands.getNetwork({}).then((data) => setNetwork(data.kind)); + } + }, [open]); + + useEffect(() => { + if (!open) return; + + const validate = async () => { + // XCH check: (fee + any XCH offered) × copies ≤ selectable_balance (mojos) + const xchAsset = record.summary.maker.find( + (a) => a.asset.asset_id === null, + ); + const feeMojos = BigNumber(record.summary.fee.toString()); + const xchOfferedMojos = xchAsset + ? BigNumber(xchAsset.amount.toString()) + : BigNumber(0); + const totalXchNeeded = feeMojos + .plus(xchOfferedMojos) + .multipliedBy(copies); + const xchBalance = BigNumber(walletState.sync.selectable_balance); + + if (totalXchNeeded.gt(xchBalance)) { + const needed = fromMojos(totalXchNeeded.toString(), 12).toString(); + const have = fromMojos(xchBalance.toString(), 12).toString(); + setBalanceError( + t`Insufficient XCH balance: need ${needed} XCH for ${copies} copies, have ${have}`, + ); + return; + } + + // Per-CAT check + for (const asset of record.summary.maker.filter( + (a) => a.asset.kind === 'token' && a.asset.asset_id !== null, + )) { + const neededMojos = BigNumber(asset.amount.toString()).multipliedBy( + copies, + ); + try { + const resp = await commands.getToken({ + asset_id: asset.asset.asset_id, + }); + if (resp.token) { + const haveMojos = BigNumber(resp.token.selectable_balance); + if (neededMojos.gt(haveMojos)) { + const displayName = getAssetDisplayName( + resp.token.name, + resp.token.ticker, + 'token', + ); + const needed = fromMojos( + neededMojos.toString(), + resp.token.precision, + ).toString(); + const have = fromMojos( + haveMojos.toString(), + resp.token.precision, + ).toString(); + setBalanceError( + t`Insufficient balance: need ${needed} ${displayName} for ${copies} copies, have ${have}`, + ); + return; + } + } + } catch { + // skip check if fetch fails + } + } + + setBalanceError(null); + }; + + validate(); + }, [ + copies, + open, + record.summary.fee, + record.summary.maker, + walletState.sync.selectable_balance, + ]); + + const handleDuplicate = async () => { + // Check biometric before transitioning to progress phase + if (!(await promptIfEnabled())) { + return; + } + + setPhase('progress'); + setIsCreating(true); + setCurrentStep('creating'); + + const offeredAssets: OfferAmount[] = record.summary.maker.map((a) => ({ + asset_id: a.asset.asset_id, + amount: a.amount, + })); + const requestedAssets: OfferAmount[] = record.summary.taker.map((a) => ({ + asset_id: a.asset.asset_id, + amount: a.amount, + })); + const fee = record.summary.fee; + const expiresAtSecond = record.summary.expiration_timestamp ?? null; + + const createdOffers: string[] = []; + + try { + for (let i = 0; i < copies; i++) { + setCurrentOfferIndex(i); + const data = await commands.makeOffer({ + offered_assets: offeredAssets, + requested_assets: requestedAssets, + fee, + expires_at_second: expiresAtSecond, + }); + createdOffers.push(data.offer); + } + } catch (error) { + addError({ + kind: 'invalid', + reason: + error instanceof Error + ? t`Failed on copy ${createdOffers.length + 1} of ${copies}: ${error.message}` + : t`Failed to create offer`, + }); + onOpenChange(false); + return; + } + + setIsCreating(false); + + // Upload to marketplaces + const enabledConfigs = marketplaces.filter( + (m) => enabledMarketplaces[m.id], + ); + + if (enabledConfigs.length > 0 && network) { + setIsUploading(true); + setCurrentStep('uploading'); + + for (const [mi, marketplace] of enabledConfigs.entries()) { + setCurrentMarketplaceIndex(mi); + for (const [oi, offer] of createdOffers.entries()) { + setCurrentOfferIndex(oi); + try { + await marketplace.uploadToMarketplace(offer, network === 'testnet'); + if (oi < createdOffers.length - 1) { + await delay(500); + } + } catch (error) { + addError({ + kind: 'upload', + reason: t`Failed to upload offer ${oi + 1} to ${marketplace.name}: ${error instanceof Error ? error.message : String(error)}`, + }); + // Break inner loop only — consistent with OfferCreationProgressDialog behavior. + // Typically if one offer fails, the rest will too. + break; + } + } + } + + setIsUploading(false); + } + + setIsDone(true); + }; + + const isInProgress = isCreating || isUploading; + const supportedMarketplaces = marketplaces.filter((m) => + m.isSupported(record.summary, false), + ); + + const getProgressMessage = () => { + if (currentStep === 'creating') { + return ( + + Creating offer {currentOfferIndex + 1} of {copies}... + + ); + } + const currentMarketplace = supportedMarketplaces.filter( + (m) => enabledMarketplaces[m.id], + )[currentMarketplaceIndex]; + return ( + + Uploading offer {currentOfferIndex + 1} of {copies} to{' '} + {currentMarketplace?.name}... + + ); + }; + + return ( + { + if (!isInProgress) onOpenChange(isOpen); + }} + > + { + if (isInProgress) e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + if (isInProgress) e.preventDefault(); + }} + > + {phase === 'config' ? ( + <> + + + Duplicate Offer + + + + Create identical copies of this offer. All copies will use the + same assets and amounts as the original. Timestamp-based + expiry is preserved; block-height expiry is not supported and + will be omitted. + + + + +
+
+

+ Number of Copies +

+
+ + {copies} + +
+ {balanceError && ( +

+ {balanceError} +

+ )} +
+ + {supportedMarketplaces.length > 0 && ( +
+ {supportedMarketplaces.map((marketplace) => ( +
+ + setEnabledMarketplaces({ + ...enabledMarketplaces, + [marketplace.id]: checked, + }) + } + /> + +
+ ))} +
+ )} +
+ + + + + + + ) : ( + <> + + + {isInProgress ? ( +
+
+ ) : ( + Offers Created + )} +
+ + {isInProgress ? ( +
+

+ + Please wait while your offers are being{' '} + {currentStep === 'creating' ? 'created' : 'uploaded'} + ... + +

+

+ {getProgressMessage()} +

+
+ ) : ( + + {copies} offer{copies > 1 ? 's have' : ' has'} been created + successfully + {Object.values(enabledMarketplaces).some(Boolean) + ? ' and uploaded to the selected marketplaces' + : ''} + . + + )} +
+
+ + {isDone && ( + + )} + + + )} +
+
+ ); +} diff --git a/src/components/dialogs/MakeOfferConfirmationDialog.tsx b/src/components/dialogs/MakeOfferConfirmationDialog.tsx index 368f50d70..56ad6a549 100644 --- a/src/components/dialogs/MakeOfferConfirmationDialog.tsx +++ b/src/components/dialogs/MakeOfferConfirmationDialog.tsx @@ -12,12 +12,17 @@ import { import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { marketplaces } from '@/lib/marketplaces'; -import { emptyNftRecord, getAssetDisplayName } from '@/lib/utils'; -import { Assets, OfferState, TokenAmount } from '@/state'; +import { + emptyNftRecord, + fromMojos, + getAssetDisplayName, + toMojos, +} from '@/lib/utils'; +import { Assets, OfferState, TokenAmount, useWalletState } from '@/state'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import BigNumber from 'bignumber.js'; -import { AlertTriangle } from 'lucide-react'; +import { AlertTriangle, Minus, Plus } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { NumberFormat } from '../NumberFormat'; import { ScrollArea } from '../ui/scroll-area'; @@ -31,6 +36,8 @@ interface MakeOfferConfirmationDialogProps { fee: string; enabledMarketplaces?: Record; setEnabledMarketplaces?: (marketplaces: Record) => void; + copies: number; + onCopiesChange?: (copies: number) => void; } interface TokenWithName extends TokenAmount { displayName?: string; @@ -371,8 +378,94 @@ export function MakeOfferConfirmationDialog({ fee, enabledMarketplaces, setEnabledMarketplaces, + copies, + onCopiesChange, }: MakeOfferConfirmationDialogProps) { const [xchToken, setXchToken] = useState(null); + const walletState = useWalletState(); + const [balanceError, setBalanceError] = useState(null); + + useEffect(() => { + // Balance validation only runs when copies > 1 — single-copy offers + // rely on the blockchain to reject insufficient balance at signing time. + if (!open || copies <= 1) { + setBalanceError(null); + return; + } + + const validate = async () => { + // XCH check: (fee + any XCH offered) × copies ≤ selectable_balance (mojos) + const xchToken = offerState.offered.tokens.find( + (t) => t.asset_id === null, + ); + const xchOfferedMojos = xchToken + ? BigNumber(toMojos(xchToken.amount?.toString() || '0', 12)) + : BigNumber(0); + const feeMojos = BigNumber(toMojos(fee || '0', 12)); + const totalXchNeeded = xchOfferedMojos + .plus(feeMojos) + .multipliedBy(copies); + const xchBalance = BigNumber(walletState.sync.selectable_balance); + + if (totalXchNeeded.gt(xchBalance)) { + const needed = fromMojos(totalXchNeeded.toString(), 12).toString(); + const have = fromMojos(xchBalance.toString(), 12).toString(); + setBalanceError( + t`Insufficient XCH balance: need ${needed} XCH for ${copies} copies, have ${have}`, + ); + return; + } + + // Per-CAT check: cat_amount × copies ≤ cat.selectable_balance (mojos) + for (const token of offerState.offered.tokens.filter( + (t) => t.asset_id !== null, + )) { + try { + const resp = await commands.getToken({ asset_id: token.asset_id }); + if (resp.token) { + const neededMojos = BigNumber( + toMojos( + token.amount?.toString() || '0', + resp.token.precision, + ), + ).multipliedBy(copies); + const haveMojos = BigNumber(resp.token.selectable_balance); + if (neededMojos.gt(haveMojos)) { + const displayName = getAssetDisplayName( + resp.token.name, + resp.token.ticker, + 'token', + ); + const needed = fromMojos( + neededMojos.toString(), + resp.token.precision, + ).toString(); + const have = fromMojos( + haveMojos.toString(), + resp.token.precision, + ).toString(); + setBalanceError( + t`Insufficient balance: need ${needed} ${displayName} for ${copies} copies, have ${have}`, + ); + return; + } + } + } catch { + // skip check if fetch fails + } + } + + setBalanceError(null); + }; + + validate(); + }, [ + copies, + open, + offerState.offered.tokens, + fee, + walletState.sync.selectable_balance, + ]); useEffect(() => { const fetchXchToken = async () => { @@ -436,6 +529,13 @@ export function MakeOfferConfirmationDialog({ of the selected NFTs.

+ ) : copies > 1 ? ( +

+ + You are about to create{' '} + {copies} identical offers. + +

) : (

@@ -533,21 +633,42 @@ export function MakeOfferConfirmationDialog({

) : ( -

- Network Fee:{' '} - {' '} - {xchToken - ? getAssetDisplayName( - xchToken.name, - xchToken.ticker, - 'token', - ) - : 'XCH'} -

+ <> +

+ Network Fee:{' '} + {' '} + {xchToken + ? getAssetDisplayName( + xchToken.name, + xchToken.ticker, + 'token', + ) + : 'XCH'} +

+ {!isSplitting && copies > 1 && ( +

+ Total fees for {copies} copies:{' '} + {' '} + {xchToken + ? getAssetDisplayName( + xchToken.name, + xchToken.ticker, + 'token', + ) + : 'XCH'} +

+ )} + )} {(fee || '0') === '0' && (

@@ -559,6 +680,40 @@ export function MakeOfferConfirmationDialog({ )} + {!isSplitting && onCopiesChange != null && ( +

+

+ Number of Copies +

+
+ + {copies} + +
+ {balanceError && ( +

{balanceError}

+ )} +
+ )} + {enabledMarketplaces && (
{marketplaces.map((marketplace) => { @@ -610,7 +765,7 @@ export function MakeOfferConfirmationDialog({ - diff --git a/src/components/dialogs/OfferCreationProgressDialog.tsx b/src/components/dialogs/OfferCreationProgressDialog.tsx index 791ece823..c28c43167 100644 --- a/src/components/dialogs/OfferCreationProgressDialog.tsx +++ b/src/components/dialogs/OfferCreationProgressDialog.tsx @@ -28,6 +28,7 @@ interface OfferCreationProgressDialogProps { enabledMarketplaces?: Record; clearOfferState: (offers: string[]) => void; isSwap?: boolean; + copies?: number; } export function OfferCreationProgressDialog({ @@ -38,6 +39,7 @@ export function OfferCreationProgressDialog({ enabledMarketplaces, clearOfferState, isSwap, + copies = 1, }: OfferCreationProgressDialogProps) { const { addError } = useErrors(); const [network, setNetwork] = useState(null); @@ -51,7 +53,7 @@ export function OfferCreationProgressDialog({ const [currentOfferIndex, setCurrentOfferIndex] = useState(0); const totalOffers = splitNftOffers ? offerState.offered.nfts.filter((n) => n).length - : 1; + : copies; const { createdOffers, @@ -62,6 +64,7 @@ export function OfferCreationProgressDialog({ } = useOfferProcessor({ offerState, splitNftOffers, + copies, onProcessingEnd: () => { // Don't auto-close on success }, @@ -248,12 +251,12 @@ export function OfferCreationProgressDialog({ aria-hidden='true' /> {currentStep === 'creating' ? ( - splitNftOffers ? ( + splitNftOffers || copies > 1 ? ( Creating Offers ) : ( Creating Offer ) - ) : splitNftOffers ? ( + ) : splitNftOffers || copies > 1 ? ( Uploading Offers ) : ( Uploading Offer @@ -271,7 +274,10 @@ export function OfferCreationProgressDialog({

Please wait while{' '} - {splitNftOffers ? 'your offers are' : 'your offer is'} being + {splitNftOffers || copies > 1 + ? 'your offers are' + : 'your offer is'}{' '} + being {currentStep === 'creating' ? ' created' : ' uploaded'} {currentStep === 'creating' && Object.values(enabledMarketplaces ?? {}).some(Boolean) diff --git a/src/hooks/useOfferProcessor.ts b/src/hooks/useOfferProcessor.ts index 2f0aa21cd..d50e0abac 100644 --- a/src/hooks/useOfferProcessor.ts +++ b/src/hooks/useOfferProcessor.ts @@ -8,6 +8,7 @@ import { useCallback, useRef, useState } from 'react'; interface UseOfferProcessorProps { offerState: OfferState; splitNftOffers: boolean; + copies?: number; // NEW — default 1 onProcessingEnd?: () => void; // Callback for when offer processing (success or fail) is done onProgress?: (index: number) => void; // Callback for progress updates } @@ -23,6 +24,7 @@ interface UseOfferProcessorReturn { export function useOfferProcessor({ offerState, splitNftOffers, + copies = 1, // NEW onProcessingEnd, onProgress, }: UseOfferProcessorProps): UseOfferProcessorReturn { @@ -127,43 +129,54 @@ export function useOfferProcessor({ setCreatedOffers(newOffers); } } else { - onProgress?.(0); - - const offeredAssets: OfferAmount[] = [ - ...offeredTokens, - ...offerState.offered.nfts.map((nft) => ({ - asset_id: nft, - amount: 1, - })), - ...offerState.offered.options.map((option) => ({ - asset_id: option, - amount: 1, - })), - ]; - - const requestedAssets: OfferAmount[] = [ - ...requestedTokens, - ...offerState.requested.nfts.map((nft) => ({ - asset_id: nft, - amount: 1, - })), - ...offerState.requested.options.map((option) => ({ - asset_id: option, - amount: 1, - })), - ]; - - const data = await commands.makeOffer({ - offered_assets: offeredAssets, - requested_assets: requestedAssets, - fee: toMojos( - (offerState.fee || '0').toString(), - walletState.sync.unit.precision, - ), - expires_at_second: expiresAtSecond, - }); + const newOffers: string[] = []; + + for (let copyIndex = 0; copyIndex < copies; copyIndex++) { + if (isCancelled.current) break; + + onProgress?.(copyIndex); + + const offeredAssets: OfferAmount[] = [ + ...offeredTokens, + ...offerState.offered.nfts.map((nft) => ({ + asset_id: nft, + amount: 1, + })), + ...offerState.offered.options.map((option) => ({ + asset_id: option, + amount: 1, + })), + ]; + + const requestedAssets: OfferAmount[] = [ + ...requestedTokens, + ...offerState.requested.nfts.map((nft) => ({ + asset_id: nft, + amount: 1, + })), + ...offerState.requested.options.map((option) => ({ + asset_id: option, + amount: 1, + })), + ]; + + const data = await commands.makeOffer({ + offered_assets: offeredAssets, + requested_assets: requestedAssets, + fee: toMojos( + (offerState.fee || '0').toString(), + walletState.sync.unit.precision, + ), + expires_at_second: expiresAtSecond, + }); + + if (!isCancelled.current) { + newOffers.push(data.offer); + } + } + if (!isCancelled.current) { - setCreatedOffers([data.offer]); + setCreatedOffers(newOffers); } } } catch (err) { @@ -179,6 +192,7 @@ export function useOfferProcessor({ }, [ offerState, splitNftOffers, + copies, walletState.sync.unit.precision, promptIfEnabled, onProcessingEnd, diff --git a/src/pages/MakeOffer.tsx b/src/pages/MakeOffer.tsx index 993ed2c83..c43bbb165 100644 --- a/src/pages/MakeOffer.tsx +++ b/src/pages/MakeOffer.tsx @@ -15,7 +15,7 @@ import { useWalletState } from '@/state'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { HandCoins, Handshake } from 'lucide-react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; export function MakeOffer() { @@ -33,6 +33,8 @@ export function MakeOffer() { const [enabledMarketplaces, setEnabledMarketplaces] = useState< Record >({}); + const [copies, setCopies] = useState(1); + const confirmedRef = useRef(false); const makeAction = () => { if (state.expiration !== null) { @@ -104,6 +106,7 @@ export function MakeOffer() { }; const handleConfirm = () => { + confirmedRef.current = true; setIsProgressDialogOpen(true); }; @@ -314,13 +317,19 @@ export function MakeOffer() { { + setIsConfirmDialogOpen(open); + if (!open && !confirmedRef.current) setCopies(1); + confirmedRef.current = false; + }} onConfirm={handleConfirm} offerState={state} splitNftOffers={splitNftOffers} fee={state.fee || '0'} enabledMarketplaces={enabledMarketplaces} setEnabledMarketplaces={setEnabledMarketplaces} + copies={copies} + onCopiesChange={setCopies} /> { setState(null); navigate('/offers', { replace: true }); diff --git a/src/pages/Swap.tsx b/src/pages/Swap.tsx index b4cc53257..f2b3849f9 100644 --- a/src/pages/Swap.tsx +++ b/src/pages/Swap.tsx @@ -340,6 +340,7 @@ export function Swap() { offerState={offerState} splitNftOffers={false} fee={fee || '0'} + copies={1} />