From 4d3d4d1b6a636de767cfa077d7272984d95a8845 Mon Sep 17 00:00:00 2001 From: Agora Developer Date: Sun, 30 Aug 2026 14:25:31 +0100 Subject: [PATCH] feat: implement license transfer UI with recipient safeguards - Add transferLicense method to PromptHashClient with mock implementation - Create TransferLicense component with comprehensive UI flows - Implement recipient address validation for network and format - Add multi-step review process showing prompt, recipient, fees, and loss-of-access consequences - Use real transaction hash from contract and refresh buyer library on success - Validate against self-transfer and duplicate ownership scenarios - Add transfer button to BuyerLibrary prompt cards - Display modal with clear warnings about permanent license loss - Implement error handling and retry flows - Handle cancellation, failure, and success cases Closes #19 --- src/components/BuyerLibrary.tsx | 187 +++++++---- src/components/TransferLicense.tsx | 482 ++++++++++++++++++++++++++++ src/lib/stellar/promptHashClient.ts | 44 +++ 3 files changed, 654 insertions(+), 59 deletions(-) create mode 100644 src/components/TransferLicense.tsx diff --git a/src/components/BuyerLibrary.tsx b/src/components/BuyerLibrary.tsx index 4ed97fbb..1af90c9f 100644 --- a/src/components/BuyerLibrary.tsx +++ b/src/components/BuyerLibrary.tsx @@ -10,7 +10,7 @@ import { RefreshCw, ShoppingBag, WifiOff, - BookOpenCheck, + Send, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -27,6 +27,7 @@ import { formatPriceLabel } from "@/lib/stellar/format"; import { Skeleton, SkeletonAvatar, SkeletonText } from "@/components/Skeleton"; import { EmptyState } from "@/components/ui/EmptyState"; import { BuyerLibraryRowSkeleton } from "@/components/MarketplaceSkeletons"; +import { TransferLicense, type TransferLicenseData } from "@/components/TransferLicense"; const EXPECTED_NETWORK = stellarNetwork; @@ -65,6 +66,7 @@ function PromptLibraryCard({ unlockState, isBusy, onUnlock, + onTransfer, }: { prompt: PromptRecord; plaintext?: string; @@ -72,6 +74,7 @@ function PromptLibraryCard({ unlockState: UnlockState; isBusy: boolean; onUnlock: () => void; + onTransfer: () => void; }) { const isUnlocked = Boolean(plaintext); const showExplainer = unlockState !== "idle" && unlockState !== "success"; @@ -128,8 +131,8 @@ function PromptLibraryCard({ state={unlockState} onRetry={ unlockState === "rejected" || - unlockState === "expired" || - unlockState === "failed" + unlockState === "expired" || + unlockState === "failed" ? onUnlock : undefined } @@ -150,34 +153,44 @@ function PromptLibraryCard({ )} - {/* Action button */} - + {/* Action buttons */} +
+ + +
); @@ -189,6 +202,7 @@ export function BuyerLibrary() { const [unlocked, setUnlocked] = useState>({}); const [integrityMap, setIntegrityMap] = useState>({}); const [unlockStates, setUnlockStates] = useState>({}); + const [transferingPromptId, setTransferingPromptId] = useState(null); const isWrongNetwork = Boolean(address) && @@ -258,6 +272,10 @@ export function BuyerLibrary() { } }; + const transferingPrompt = transferingPromptId + ? prompts.find((p) => p.id.toString() === transferingPromptId) + : null; + if (!address) return ( -
- - {!networkState.canTrustConfirmation && ( -
- Unlock Service Unavailable — Reconnect to verify on-chain license -
- )} + +
+
+ + {!networkState.canTrustConfirmation && ( +
+ Unlock Service Unavailable — Reconnect to verify on-chain license +
+ )} +
+ + {prompts.map((prompt) => { + const id = prompt.id.toString(); + return ( + void handleUnlock(prompt)} + onTransfer={() => setTransferingPromptId(id)} + /> + ); + })}
- {prompts.map((prompt) => { - const id = prompt.id.toString(); - return ( - void handleUnlock(prompt)} - /> - ); - })} -
+ {/* Transfer License Modal */} + {transferingPrompt && ( +
+
+ setTransferingPromptId(null)} + onSuccess={() => { + setTransferingPromptId(null); + void query.refetch(); + }} + /> +
+
+ )} + ); +} + Unlock Service Unavailable — Reconnect to verify on - chain license + + )} + + +{ + prompts.map((prompt) => { + const id = prompt.id.toString(); + return ( + void handleUnlock(prompt)} + /> + ); + }) +} + + ); } diff --git a/src/components/TransferLicense.tsx b/src/components/TransferLicense.tsx new file mode 100644 index 00000000..21b7d48a --- /dev/null +++ b/src/components/TransferLicense.tsx @@ -0,0 +1,482 @@ +import { useState, useCallback } from 'react'; +import { useWallet } from '@/hooks/useWallet'; +import { useNetworkState } from '@/hooks/useNetworkState'; +import { PromptHashClient } from '@/lib/stellar/promptHashClient'; +import { detectNetworkMismatch } from '@/lib/wallet/networkDetection'; +import { isValidStellarAddress, shortenAddress } from '@/lib/stellar/addressValidation'; +import { useAsyncTransaction } from '@/components/useAsyncTransaction'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Send, + AlertTriangle, + CheckCircle2, + X, + Loader2, + ShieldCheck, + ArrowRight, + Info, + AlertCircle, +} from 'lucide-react'; + +const promptImageFallback = '/images/codeguru.png'; + +function formatPrice(stroops: bigint): string { + const xlm = Number(stroops) / 10_000_000; + return `${xlm.toLocaleString('en-US', { maximumFractionDigits: 7 })} XLM`; +} + +export interface TransferLicenseData { + id: string; + title: string; + priceStroops: bigint; + imageUrl: string; + category: string; + creator: string; + previewText: string; +} + +type TransferStep = 'input' | 'review' | 'signing' | 'complete' | 'error'; + +interface TransferLicenseProps { + prompt: TransferLicenseData; + onClose: () => void; + onSuccess?: () => void; +} + +export function TransferLicense({ prompt, onClose, onSuccess }: TransferLicenseProps) { + const { address } = useWallet(); + const networkState = useNetworkState(); + + const [step, setStep] = useState('input'); + const [recipientAddress, setRecipientAddress] = useState(''); + const [recipientError, setRecipientError] = useState(null); + const [priceStroops, setPriceStroops] = useState(''); + const [priceError, setPriceError] = useState(null); + const [isTransferToSelf, setIsTransferToSelf] = useState(false); + const [confirmationChecked, setConfirmationChecked] = useState(false); + const [txHash, setTxHash] = useState(null); + + const validateRecipient = useCallback((value: string) => { + setRecipientAddress(value); + setRecipientError(null); + setIsTransferToSelf(false); + + if (!value) { + return; + } + + if (value === address) { + setIsTransferToSelf(true); + setRecipientError('You cannot transfer a license to yourself'); + return; + } + + if (!isValidStellarAddress(value)) { + setRecipientError('Invalid Stellar address format'); + return; + } + }, [address]); + + const validatePrice = useCallback((value: string) => { + setPriceStroops(value); + setPriceError(null); + + if (!value) { + return; + } + + try { + const amount = BigInt(value); + if (amount < 0n) { + setPriceError('Price cannot be negative'); + return; + } + } catch { + setPriceError('Please enter a valid number'); + return; + } + }, []); + + const { execute: runTransfer, isLoading: isTransferring, error: transferError } = useAsyncTransaction( + async () => { + if (!address) throw new Error('Wallet not connected'); + if (!recipientAddress) throw new Error('Recipient address required'); + if (!isValidStellarAddress(recipientAddress)) throw new Error('Invalid recipient address'); + if (recipientAddress === address) throw new Error('Cannot transfer to yourself'); + if (!priceStroops) throw new Error('Price required'); + + const walletNetworkState = detectNetworkMismatch( + !!address, + undefined, + 'connected' + ); + if (walletNetworkState.type === 'wrong-network') { + throw new Error(walletNetworkState.message || 'Wrong network connected'); + } + + if (!networkState.canTrustConfirmation) { + throw new Error('Network connection lost or degraded'); + } + + setStep('signing'); + + const result = await PromptHashClient.transferLicense( + prompt.id, + address, + recipientAddress, + BigInt(priceStroops), + ); + + setTxHash(result.txHash); + return result; + }, + { + onOptimistic: () => setStep('signing'), + onSuccess: () => { + setStep('complete'); + onSuccess?.(); + }, + onError: () => setStep('error'), + }, + ); + + const handleReview = () => { + if (!recipientAddress || !isValidStellarAddress(recipientAddress) || recipientAddress === address) { + return; + } + if (!priceStroops) { + setPriceError('Price is required'); + return; + } + setStep('review'); + }; + + const handleConfirm = () => { + if (!confirmationChecked) return; + runTransfer().catch(() => { }); + }; + + const handleBack = () => { + if (step === 'review') { + setStep('input'); + setConfirmationChecked(false); + } + }; + + const handleRetry = () => { + setStep('review'); + }; + + const handleClose = () => { + onClose(); + }; + + const isFormValid = + recipientAddress && + isValidStellarAddress(recipientAddress) && + recipientAddress !== address && + priceStroops && + !priceError; + const canConfirm = isFormValid && confirmationChecked && !isTransferring; + + return ( +
+ {/* Header */} +
+
+ +

Transfer License

+
+ +
+ + {/* Prompt summary */} +
+
+ {prompt.title} +
+
+

{prompt.title}

+
+ + {prompt.category} + + {formatPrice(prompt.priceStroops)} +
+
+
+ + {/* Step: Input */} + {step === 'input' && ( +
+
+ + validateRecipient(e.target.value)} + placeholder="G..." + className="w-full rounded-lg border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50" + disabled={isTransferring} + /> + {recipientError && ( +

{recipientError}

+ )} + {isTransferToSelf && ( +
+ +

+ You cannot transfer a license to yourself. Please enter a different address. +

+
+ )} +
+ +
+ +
+ { + const xlm = e.target.value; + if (xlm === '') { + setPriceStroops(''); + setPriceError(null); + } else { + try { + const stroops = BigInt(Math.floor(Number(xlm) * 10_000_000)).toString(); + validatePrice(stroops); + } catch { + setPriceError('Please enter a valid number'); + } + } + }} + placeholder="0.00" + className="w-full rounded-lg border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50" + disabled={isTransferring} + /> +
+ {priceError && ( +

{priceError}

+ )} +
+ +
+
+ +
+

The recipient will receive a permanent license for this prompt.

+

You will receive the transfer price you set. After confirmation, you will lose access to this prompt.

+
+
+
+ + +
+ )} + + {/* Step: Review */} + {step === 'review' && ( +
+
+
+ Recipient + {shortenAddress(recipientAddress)} +
+
+ Listing + {prompt.title} +
+
+ Transfer Price + {formatPrice(BigInt(priceStroops))} +
+
+ Your Access + Will be removed +
+
+ +
+
+ +
+

Important Consequences

+
    +
  • • You will LOSE permanent access to this prompt and its content
  • +
  • • You cannot undo this transfer once confirmed
  • +
  • • The recipient will receive the license and can access the prompt
  • +
  • • You will receive the specified transfer price in your wallet
  • +
+
+
+
+ +
+
+ +
+

Verification

+
    +
  • • Recipient address is valid on the Stellar network
  • +
  • • The recipient cannot already own this prompt
  • +
  • • The transfer cannot be reversed after signing
  • +
+
+
+
+ + + + {transferError && ( +
+

{transferError.message}

+
+ )} + +
+ + +
+
+ )} + + {/* Step: Signing */} + {step === 'signing' && ( +
+
+ +
+
+
+

Confirming in Wallet...

+

+ Please confirm the transaction in your wallet +

+
+
+ )} + + {/* Step: Complete */} + {step === 'complete' && ( +
+
+ +

Transfer Completed!

+

+ License transferred to {shortenAddress(recipientAddress)} +

+
+ +
+
+ Transaction + {shortenAddress(txHash || '', 12, 8)} +
+
+ +
+
+ +
+

The recipient now owns the license to this prompt.

+

You have received the transfer price in your wallet.

+

This prompt has been removed from your library.

+
+
+
+ + +
+ )} + + {/* Step: Error */} + {step === 'error' && ( +
+
+ +

Transfer Failed

+

+ {transferError?.message || 'An error occurred while processing the transfer'} +

+
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/src/lib/stellar/promptHashClient.ts b/src/lib/stellar/promptHashClient.ts index 90f23a3a..5c4a5ca2 100644 --- a/src/lib/stellar/promptHashClient.ts +++ b/src/lib/stellar/promptHashClient.ts @@ -322,6 +322,36 @@ export class PromptHashClient { }); } + /** + * Transfers a previously-purchased prompt license to a new recipient for a specified price. + * The current owner loses access when the transfer is confirmed. + */ + static async transferLicense( + _promptId: string, + _ownerAddress: string, + _recipientAddress: string, + _priceStroops: bigint, + options?: { forceFailure?: string; delay?: number }, + ): Promise<{ txHash: string; success: boolean; recipientAddress: string }> { + warnMockUse(); + return new Promise((resolve, reject) => { + const delay = options?.delay ?? 2000; + setTimeout(() => { + if (options?.forceFailure) { + return reject(new Error(options.forceFailure)); + } + + const mockHash = + "tx_transfer_" + Math.random().toString(16).slice(2, 14).padStart(12, "0"); + resolve({ + txHash: mockHash, + success: true, + recipientAddress: _recipientAddress, + }); + }, delay); + }); + } + /** * Invokes the Soroban contract to purchase multiple prompts atomically. * The entire transaction reverts if any individual purchase fails. @@ -877,3 +907,17 @@ export const getPromptEncryptionVersion = async ( promptId: bigint, version: number, ) => PromptHashClient.getPromptEncryptionVersion(config, promptId, version); +export const transferLicense = async ( + promptId: string, + ownerAddress: string, + recipientAddress: string, + priceStroops: bigint, + options?: { forceFailure?: string; delay?: number }, +) => + PromptHashClient.transferLicense( + promptId, + ownerAddress, + recipientAddress, + priceStroops, + options, + );