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.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,
+ );