Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions src/components/common/AtomicSwapProposalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export const AtomicSwapProposalForm: React.FC<AtomicSwapProposalFormProps> = ({
const [offeredAmount, setOfferedAmount] = useState('1');
const [desiredKeyId, setDesiredKeyId] = useState('');
const [desiredAmount, setDesiredAmount] = useState('1');
const [expiryHours, setExpiryHours] = useState(24);
const [counterpartyAddress, setCounterpartyAddress] = useState('');
const [expiryHours, setExpiryHours] = useState(1);
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [generatedLink, setGeneratedLink] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false);
Expand Down Expand Up @@ -78,15 +79,23 @@ export const AtomicSwapProposalForm: React.FC<AtomicSwapProposalFormProps> = ({
if (touched.expiryHours && (!expiryHours || expiryHours < 1 || expiryHours > MAX_EXPIRY_HOURS)) {
errors.expiryHours = `Expiry must be between 1 and ${MAX_EXPIRY_HOURS} hours`;
}
if (
touched.counterpartyAddress &&
counterpartyAddress.trim() &&
counterpartyAddress.trim().toLowerCase() === walletAddress.toLowerCase()
) {
errors.counterpartyAddress = 'Counterparty cannot be your own wallet address';
}

return errors;
}, [touched, offeredKeyId, desiredKeyId, offeredAmount, desiredAmount, parsedOfferedAmount, parsedDesiredAmount, offeredHolding, offeredKey?.title, expiryHours]);
}, [touched, offeredKeyId, desiredKeyId, offeredAmount, desiredAmount, parsedOfferedAmount, parsedDesiredAmount, offeredHolding, offeredKey?.title, expiryHours, counterpartyAddress, walletAddress]);

const isFormValid = offeredKeyId && desiredKeyId && offeredAmount.trim() && desiredAmount.trim() &&
Number.isFinite(parsedOfferedAmount) && Number.isFinite(parsedDesiredAmount) &&
parsedOfferedAmount > 0 && parsedDesiredAmount > 0 &&
parsedOfferedAmount <= offeredHolding &&
offeredKeyId !== desiredKeyId &&
(!counterpartyAddress.trim() || counterpartyAddress.trim().toLowerCase() !== walletAddress.toLowerCase()) &&
expiryHours >= 1 && expiryHours <= MAX_EXPIRY_HOURS;

const handleBlur = (field: string) => {
Expand All @@ -99,6 +108,7 @@ export const AtomicSwapProposalForm: React.FC<AtomicSwapProposalFormProps> = ({
desiredKey: true,
offeredAmount: true,
desiredAmount: true,
counterpartyAddress: true,
expiryHours: true,
});

Expand All @@ -112,6 +122,9 @@ export const AtomicSwapProposalForm: React.FC<AtomicSwapProposalFormProps> = ({
desiredKeyId,
desiredKeyName: desiredKey?.title ?? 'Unknown',
desiredAmount: parsedDesiredAmount,
...(counterpartyAddress.trim()
? { counterpartyAddress: counterpartyAddress.trim() }
: {}),
expiryHours,
});

Expand All @@ -123,7 +136,8 @@ export const AtomicSwapProposalForm: React.FC<AtomicSwapProposalFormProps> = ({
setOfferedAmount('1');
setDesiredKeyId('');
setDesiredAmount('1');
setExpiryHours(24);
setCounterpartyAddress('');
setExpiryHours(1);
setTouched({});
}
};
Expand Down Expand Up @@ -153,7 +167,8 @@ export const AtomicSwapProposalForm: React.FC<AtomicSwapProposalFormProps> = ({
setOfferedAmount('1');
setDesiredKeyId('');
setDesiredAmount('1');
setExpiryHours(24);
setCounterpartyAddress('');
setExpiryHours(1);
setTouched({});
setGeneratedLink('');
setShowLinkDialog(false);
Expand Down Expand Up @@ -322,6 +337,36 @@ export const AtomicSwapProposalForm: React.FC<AtomicSwapProposalFormProps> = ({
</div>
</fieldset>

{/* Counterparty Address */}
<div className="space-y-2">
<label htmlFor="counterparty-address" className="block text-xs font-medium text-white/70 mb-1.5">
Counterparty wallet address
</label>
<input
id="counterparty-address"
data-testid="swap-counterparty-address"
type="text"
value={counterpartyAddress}
onChange={e => setCounterpartyAddress(e.target.value)}
onBlur={() => handleBlur('counterpartyAddress')}
disabled={isSubmitting || createProposalMutation.isPending}
placeholder="GABC... (target counterparty wallet)"
className={cn(
'w-full rounded-xl border bg-white/[0.04] px-3 py-2 font-mono text-xs text-white outline-none transition-colors',
validationErrors.counterpartyAddress
? 'border-red-500/60'
: 'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15'
)}
aria-invalid={!!validationErrors.counterpartyAddress}
aria-describedby={validationErrors.counterpartyAddress ? 'counterparty-address-error' : undefined}
/>
{validationErrors.counterpartyAddress && (
<p id="counterparty-address-error" className="mt-1 text-xs text-red-300" role="alert">
{validationErrors.counterpartyAddress}
</p>
)}
</div>

{/* Expiry */}
<div className="space-y-2">
<label htmlFor="expiry-hours" className="block text-xs font-medium text-white/70 mb-1.5">
Expand Down
34 changes: 30 additions & 4 deletions src/components/common/AtomicSwapProposalReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { StableButtonContent } from '@/components/ui/stable-button-content';
import { AlertCircle, CheckCircle, Clock, XCircle } from 'lucide-react';
import { useParams, useNavigate } from 'react-router';
import { useAccount } from 'wagmi';
import { useAtomicSwapProposal, useAcceptAtomicSwapProposal } from '@/hooks/useAtomicSwap';
import { useAtomicSwapProposal, useAcceptAtomicSwapProposal, useCancelAtomicSwapProposal } from '@/hooks/useAtomicSwap';
import { copyTextToClipboard } from '@/utils/clipboard.utils';
import showToast from '@/utils/toast.util';
import { formatRelativeTime } from '@/utils/time.utils';
import { formatNumber } from '@/utils/numberFormat.utils';
import { isProposalExpired, canAcceptProposal } from '@/types/atomicSwap';
import { isProposalExpired, canAcceptProposal, canCancelProposal, formatSwapExpiryCountdown } from '@/types/atomicSwap';
import { cn } from '@/lib/utils';

export const AtomicSwapProposalReview: React.FC = () => {
Expand All @@ -20,6 +20,7 @@ export const AtomicSwapProposalReview: React.FC = () => {

const { data: proposal, isLoading, isError } = useAtomicSwapProposal(proposalId ?? '');
const acceptMutation = useAcceptAtomicSwapProposal(connectedAddress ?? '');
const cancelMutation = useCancelAtomicSwapProposal(connectedAddress ?? '');

const handleAccept = async () => {
if (!proposal || !connectedAddress) return;
Expand All @@ -30,6 +31,15 @@ export const AtomicSwapProposalReview: React.FC = () => {
await acceptMutation.mutateAsync({ proposalId: proposal.id, counterpartyAddress: connectedAddress });
};

const handleCancel = async () => {
if (!proposal || !connectedAddress) return;
if (!canCancelProposal(proposal, connectedAddress)) {
showToast.error('Cannot cancel');
return;
}
await cancelMutation.mutateAsync({ proposalId: proposal.id, proposerAddress: connectedAddress });
};

const handleCopyLink = async () => {
if (!proposal) return;
try {
Expand Down Expand Up @@ -256,9 +266,25 @@ export const AtomicSwapProposalReview: React.FC = () => {
)}

{isProposer && isPending && !isExpired && (
<div className="text-center text-white/60">
<div className="space-y-3 text-center text-white/60">
<p>You created this proposal. Share the link above with your counterparty.</p>
<p className="text-xs mt-1">Expires {formatRelativeTime(proposal.expiresAt)}</p>
<p className="text-xs mt-1" data-testid="review-swap-countdown">
Expires in {formatSwapExpiryCountdown(proposal.expiresAt)}
</p>
<Button
variant="outline"
data-testid="cancel-proposal-button"
onClick={handleCancel}
disabled={cancelMutation.isPending}
className="rounded-xl border-rose-500/30 text-rose-300 hover:bg-rose-500/10"
>
<StableButtonContent
isLoading={cancelMutation.isPending}
loadingLabel="Cancelling…"
>
Cancel Proposal
</StableButtonContent>
</Button>
</div>
)}

Expand Down
29 changes: 17 additions & 12 deletions src/components/common/BuySellKeyFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -692,20 +692,24 @@ export const BuySellKeyFlow: React.FC<BuySellKeyFlowProps> = ({
)}

{/* Slippage Tolerance Selector (0.5%, 1%, 2%, custom) */}
<div className="border-t border-white/10 pt-3">
<SlippageToleranceSelector
value={slippageTolerancePercent}
onChange={setSlippageTolerancePercent}
disabled={isSubmitting}
presets={[0.5, 1, 2]}
/>
</div>
{!confirmationOpen && (
<div className="border-t border-white/10 pt-3">
<SlippageToleranceSelector
value={slippageTolerancePercent}
onChange={setSlippageTolerancePercent}
disabled={isSubmitting}
presets={[0.5, 1, 2]}
/>
</div>
)}

{/* Price impact warning when impact exceeds the selected tolerance */}
<PriceImpactWarning
impactPercent={priceImpactPercent}
threshold={slippageTolerancePercent}
/>
{!confirmationOpen && (
<PriceImpactWarning
impactPercent={priceImpactPercent}
threshold={slippageTolerancePercent}
/>
)}
{impactWarningActive && (
<PriceImpactOverrideCheckbox
checked={impactAcknowledged}
Expand Down Expand Up @@ -763,6 +767,7 @@ export const BuySellKeyFlow: React.FC<BuySellKeyFlowProps> = ({
unitPriceStroops={keyPriceStroops}
totalStroops={totalStroops}
slippageTolerancePercent={slippageTolerancePercent}
onSlippageChange={setSlippageTolerancePercent}
maxPriceStroops={slippageBounds?.maxPriceStroops ?? null}
minPriceStroops={slippageBounds?.minPriceStroops ?? null}
priceImpactPercent={priceImpactPercent}
Expand Down
Loading
Loading