From ea4d7d0510ed205f08328b8f0c1738a6fce0b391 Mon Sep 17 00:00:00 2001 From: Therealjhay Date: Mon, 19 Oct 2026 23:24:01 -0700 Subject: [PATCH] feat: add trade confirmation slippage modal, whitelist management, governance voting, and atomic swaps (#1112, #1128, #1126, #1129) --- .../common/AtomicSwapProposalForm.tsx | 53 +++- .../common/AtomicSwapProposalReview.tsx | 34 ++- src/components/common/BuySellKeyFlow.tsx | 29 +- src/components/common/PendingSwapsList.tsx | 244 ++++++++++++++++ src/components/common/ProposalCard.tsx | 263 +++++++++++------- src/components/common/ProposalVoteModal.tsx | 191 +++++++++++++ .../common/SlippageToleranceSelector.tsx | 16 +- .../common/TradeConfirmationModal.tsx | 137 +++++++-- src/components/common/TradeDialog.tsx | 25 +- .../common/WhitelistManagementPanel.tsx | 229 +++++++++++++-- src/hooks/useAtomicSwap.ts | 22 +- src/hooks/useCreatorWhitelist.ts | 43 ++- src/pages/AtomicSwapCreatePage.tsx | 18 +- src/pages/CreatorDashboardPage.tsx | 42 +-- src/pages/GovernancePage.tsx | 60 +++- src/pages/ProposalDetailPage.tsx | 1 + src/routes.tsx | 8 + src/services/atomicSwap.service.ts | 79 +++++- src/services/creatorKeysContract.service.ts | 47 ++++ src/services/creatorWhitelist.service.ts | 41 ++- src/services/governanceContract.service.ts | 18 ++ src/types/atomicSwap.ts | 54 +++- src/types/governance.ts | 14 +- src/utils/governance.utils.ts | 40 ++- src/utils/slippageTolerance.utils.ts | 9 +- src/utils/whitelist.utils.ts | 11 +- 26 files changed, 1495 insertions(+), 233 deletions(-) create mode 100644 src/components/common/PendingSwapsList.tsx create mode 100644 src/components/common/ProposalVoteModal.tsx diff --git a/src/components/common/AtomicSwapProposalForm.tsx b/src/components/common/AtomicSwapProposalForm.tsx index 28430286..fc926e6f 100644 --- a/src/components/common/AtomicSwapProposalForm.tsx +++ b/src/components/common/AtomicSwapProposalForm.tsx @@ -41,7 +41,8 @@ export const AtomicSwapProposalForm: React.FC = ({ 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>({}); const [generatedLink, setGeneratedLink] = useState(''); const [showLinkDialog, setShowLinkDialog] = useState(false); @@ -78,15 +79,23 @@ export const AtomicSwapProposalForm: React.FC = ({ 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) => { @@ -99,6 +108,7 @@ export const AtomicSwapProposalForm: React.FC = ({ desiredKey: true, offeredAmount: true, desiredAmount: true, + counterpartyAddress: true, expiryHours: true, }); @@ -112,6 +122,9 @@ export const AtomicSwapProposalForm: React.FC = ({ desiredKeyId, desiredKeyName: desiredKey?.title ?? 'Unknown', desiredAmount: parsedDesiredAmount, + ...(counterpartyAddress.trim() + ? { counterpartyAddress: counterpartyAddress.trim() } + : {}), expiryHours, }); @@ -123,7 +136,8 @@ export const AtomicSwapProposalForm: React.FC = ({ setOfferedAmount('1'); setDesiredKeyId(''); setDesiredAmount('1'); - setExpiryHours(24); + setCounterpartyAddress(''); + setExpiryHours(1); setTouched({}); } }; @@ -153,7 +167,8 @@ export const AtomicSwapProposalForm: React.FC = ({ setOfferedAmount('1'); setDesiredKeyId(''); setDesiredAmount('1'); - setExpiryHours(24); + setCounterpartyAddress(''); + setExpiryHours(1); setTouched({}); setGeneratedLink(''); setShowLinkDialog(false); @@ -322,6 +337,36 @@ export const AtomicSwapProposalForm: React.FC = ({ + {/* Counterparty Address */} +
+ + 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 && ( + + )} +
+ {/* Expiry */}
@@ -582,6 +712,47 @@ export const WhitelistManagementPanel: React.FC< + + {/* Enable Whitelist Mode Confirmation Modal */} + !open && setShowEnableModal(false)} + > + + + + + Enable Early Access Whitelist + + + Enabling whitelist mode restricts purchases of this creator + key to approved wallet addresses. + + + + + + + + + ); }; diff --git a/src/hooks/useAtomicSwap.ts b/src/hooks/useAtomicSwap.ts index 6555229c..9b1a04e3 100644 --- a/src/hooks/useAtomicSwap.ts +++ b/src/hooks/useAtomicSwap.ts @@ -6,6 +6,7 @@ import { createAtomicSwapProposal, fetchAtomicSwapProposal, acceptAtomicSwapProposal, + cancelAtomicSwapProposal, fetchUserAtomicSwapProposals, fetchAtomicSwapHistory, } from '@/services/atomicSwap.service'; @@ -53,7 +54,7 @@ export function useAcceptAtomicSwapProposal(address: string) { queryClient.invalidateQueries({ queryKey: ['atomic-swap', 'history', address] }); queryClient.invalidateQueries({ queryKey: queryKeys.wallet.holdings(address) }); queryClient.invalidateQueries({ queryKey: queryKeys.creators.all }); - showToast.transactionSuccess('Swap completed', 'Atomic swap executed successfully'); + showToast.transactionSuccess('SwapExecuted', 'Atomic swap executed successfully'); }, onError: (error) => { showToast.error(error instanceof Error ? error.message : 'Failed to accept proposal'); @@ -61,6 +62,25 @@ export function useAcceptAtomicSwapProposal(address: string) { }); } +export function useCancelAtomicSwapProposal(address: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['atomic-swap', 'cancel', address], + mutationFn: async (params: Parameters[0]) => { + return cancelAtomicSwapProposal(params); + }, + onSuccess: (data, variables) => { + queryClient.setQueryData(['atomic-swap', 'proposal', variables.proposalId], data.proposal); + queryClient.invalidateQueries({ queryKey: ['atomic-swap', 'proposals', address] }); + showToast.transactionSuccess('SwapCancelled', 'Atomic swap proposal cancelled'); + }, + onError: (error) => { + showToast.error(error instanceof Error ? error.message : 'Failed to cancel proposal'); + }, + }); +} + export function useUserAtomicSwapProposals(address: string, status?: AtomicSwapProposal['status']) { return useQuery({ queryKey: ['atomic-swap', 'proposals', address, status ?? 'all'], diff --git a/src/hooks/useCreatorWhitelist.ts b/src/hooks/useCreatorWhitelist.ts index 22ce0314..bdd3c8e9 100644 --- a/src/hooks/useCreatorWhitelist.ts +++ b/src/hooks/useCreatorWhitelist.ts @@ -144,6 +144,46 @@ export function useCreatorWhitelist( }, }); + const setModeMutation = useMutation({ + mutationFn: (enabled: boolean) => + creatorWhitelistService.set_whitelist_mode(keyId!, enabled), + onSuccess: updated => { + queryClient.setQueryData( + queryKeys.creators.whitelist(keyId ?? ''), + updated + ); + queryClient.setQueryData( + queryKeys.creators.detail(keyId ?? ''), + (old: Course | undefined) => + old + ? { + ...old, + isWhitelistEnabled: updated.isWhitelistEnabled, + whitelistEnabled: updated.isWhitelistEnabled, + } + : old + ); + void queryClient.invalidateQueries({ + queryKey: queryKeys.creators.whitelist(keyId ?? ''), + }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.creators.detail(keyId ?? ''), + }); + showToast.success( + updated.isWhitelistEnabled + ? 'Early access whitelist enabled' + : 'Early access whitelist disabled' + ); + }, + onError: (error: unknown) => { + const message = + error instanceof Error + ? error.message + : 'Failed to update whitelist mode'; + showToast.error(message); + }, + }); + // Derive values with fallback to initialCourse props const entries: WhitelistEntry[] = query.data?.entries ?? initialCourse?.whitelist ?? []; @@ -161,8 +201,9 @@ export function useCreatorWhitelist( addAddresses: addMutation.mutateAsync, removeAddress: removeMutation.mutateAsync, disableWhitelist: disableMutation.mutateAsync, + setWhitelistMode: setModeMutation.mutateAsync, isAdding: addMutation.isPending, isRemoving: removeMutation.isPending, - isDisabling: disableMutation.isPending, + isDisabling: disableMutation.isPending || setModeMutation.isPending, }; } diff --git a/src/pages/AtomicSwapCreatePage.tsx b/src/pages/AtomicSwapCreatePage.tsx index e23b9495..be48b4f5 100644 --- a/src/pages/AtomicSwapCreatePage.tsx +++ b/src/pages/AtomicSwapCreatePage.tsx @@ -3,6 +3,7 @@ import { useAccount } from 'wagmi'; import { courseService, type Course } from '@/services/course.service'; import { useWalletHoldings } from '@/hooks/useWallet'; import { AtomicSwapProposalForm } from '@/components/common/AtomicSwapProposalForm'; +import { PendingSwapsList } from '@/components/common/PendingSwapsList'; import { Button } from '@/components/ui/button'; import { ArrowLeft } from 'lucide-react'; import showToast from '@/utils/toast.util'; @@ -162,13 +163,6 @@ export default function AtomicSwapCreatePage() { ) : (
-
diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx index d0ae93c4..1d6f53da 100644 --- a/src/pages/CreatorDashboardPage.tsx +++ b/src/pages/CreatorDashboardPage.tsx @@ -89,6 +89,7 @@ export default function CreatorDashboardPage() { addAddresses: addWhitelistAddresses, removeAddress: removeWhitelistAddress, disableWhitelist, + setWhitelistMode, isAdding: isAddingWhitelist, isRemoving: isRemovingWhitelist, isDisabling: isDisablingWhitelist, @@ -452,24 +453,29 @@ export default function CreatorDashboardPage() { /> -
- -
+ {isKeyCreator && ( +
+ +
+ )}
= [ { label: 'All', value: 'all' }, { label: 'Active', value: 'active' }, - { label: 'Closed', value: 'closed' }, { label: 'Passed', value: 'passed' }, - { label: 'Rejected', value: 'rejected' }, + { label: 'Failed', value: 'failed' }, + { label: 'Executed', value: 'executed' }, ]; function GovernancePageContent() { const { data: proposals, isLoading, error } = useGovernanceProposals(); + const { address } = useAccount(); + const { data: holdings = [] } = useWalletHoldings(address ?? ''); const [statusFilter, setStatusFilter] = useState( 'all' ); + const [votingProposal, setVotingProposal] = useState(null); const [searchParams] = useSearchParams(); const targetProposalId = searchParams.get('proposal'); + const totalHoldingsWeight = useMemo( + () => holdings.reduce((sum, h) => sum + (h.quantity ?? 0), 0), + [holdings] + ); + + const activeProposalVoteWeight = useMemo(() => { + if (!votingProposal) return totalHoldingsWeight; + const matchingHolding = holdings.find( + h => h.creatorId === votingProposal.creatorId + ); + return matchingHolding?.quantity ?? totalHoldingsWeight; + }, [votingProposal, holdings, totalHoldingsWeight]); + useEffect(() => { if (targetProposalId && !isLoading) { const el = document.getElementById(`proposal-${targetProposalId}`); @@ -32,9 +51,13 @@ function GovernancePageContent() { } }, [targetProposalId, isLoading]); - const filtered = proposals?.filter( - p => statusFilter === 'all' || p.status === statusFilter - ); + const filtered = proposals?.filter(p => { + if (statusFilter === 'all') return true; + if (statusFilter === 'failed') { + return p.status === 'failed' || p.status === 'rejected'; + } + return p.status === statusFilter; + }); const activeCount = proposals?.filter(p => p.status === 'active').length ?? 0; @@ -67,12 +90,13 @@ function GovernancePageContent() { {/* Filters */} -
+