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}
+ >
+
+
+ Duplicate
+
+
@@ -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 (
+
+ );
+}
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({
-