diff --git a/components/bounty-detail/bounty-detail-client.tsx b/components/bounty-detail/bounty-detail-client.tsx index b98afd56..964c475d 100644 --- a/components/bounty-detail/bounty-detail-client.tsx +++ b/components/bounty-detail/bounty-detail-client.tsx @@ -87,10 +87,7 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { {!isCancelled && pool && } - + @@ -107,4 +104,3 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { ); } - diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index ec5cdf1a..857db0eb 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -237,9 +237,7 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { onClick={handleCancel} disabled={!cancelReason.trim() || isCancelling} > - {isCancelling && ( - - )} + {isCancelling && } Cancel Bounty & Refund @@ -346,9 +344,7 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) { onClick={handleCancel} disabled={!cancelReason.trim() || isCancelling} > - {isCancelling && ( - - )} + {isCancelling && } Cancel & Refund @@ -357,4 +353,3 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) { ); } - diff --git a/components/bounty-detail/bounty-detail-submissions-card.tsx b/components/bounty-detail/bounty-detail-submissions-card.tsx index 0fe4a205..c9270a90 100644 --- a/components/bounty-detail/bounty-detail-submissions-card.tsx +++ b/components/bounty-detail/bounty-detail-submissions-card.tsx @@ -1,7 +1,5 @@ "use client"; -import { useState, useEffect } from "react"; -import { Loader2, DollarSign } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -11,17 +9,20 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { Textarea } from "@/components/ui/textarea"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { BountySubmissionType } from "@/lib/graphql/generated"; +import { TransactionLink } from "@/components/ui/stellar-link"; +import { Textarea } from "@/components/ui/textarea"; +import { useSubmissionDraft } from "@/hooks/use-submission-draft"; import { - useSubmitToBounty, - useReviewSubmission, useMarkSubmissionPaid, + useReviewSubmission, + useSubmitToBounty, } from "@/hooks/use-submission-mutations"; import { authClient } from "@/lib/auth-client"; -import { useSubmissionDraft } from "@/hooks/use-submission-draft"; +import { BountySubmissionType } from "@/lib/graphql/generated"; +import { DollarSign, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; interface ExtendedUser { id: string; @@ -321,6 +322,18 @@ export function BountyDetailSubmissionsCard({ Paid on {new Date(submission.paidAt).toLocaleDateString()} )} + + {submission.rewardTransactionHash && ( +
+ Transaction: + +
+ )} ))} diff --git a/components/ui/stellar-link.tsx b/components/ui/stellar-link.tsx new file mode 100644 index 00000000..015e587b --- /dev/null +++ b/components/ui/stellar-link.tsx @@ -0,0 +1,221 @@ +"use client"; + +import React, { useState } from "react"; +import { Copy, ExternalLink, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + getTransactionUrl, + getAccountUrl, + getContractUrl, + getStellarNetwork, + StellarNetwork, + isValidStellarAddress, + isValidStellarTxHash, + isValidStellarContractId, +} from "@/lib/utils/stellar-explorer"; + +export type StellarLinkType = "transaction" | "account" | "contract"; + +interface StellarLinkProps { + /** The value to link to (transaction hash, address, or contract ID) */ + value: string; + /** The type of link */ + type: StellarLinkType; + /** Optional network override */ + network?: StellarNetwork; + /** Optional explorer override */ + explorer?: string; + /** Maximum characters to display before truncating */ + maxLength?: number; + /** Whether to show the copy button */ + showCopy?: boolean; + /** Whether to show the external link icon */ + showExternalIcon?: boolean; + /** Custom CSS classes */ + className?: string; + /** Custom link text (overrides truncation) */ + linkText?: string; + /** Tooltip text prefix */ + tooltipPrefix?: string; +} + +export function StellarLink({ + value, + type, + network, + explorer = "stellar.expert", + maxLength = 12, + showCopy = true, + showExternalIcon = true, + className, + linkText, + tooltipPrefix, +}: StellarLinkProps) { + const [copied, setCopied] = useState(false); + const [copyError, setCopyError] = useState(false); + + // Validate the input + const isValid = React.useMemo(() => { + switch (type) { + case "transaction": + return isValidStellarTxHash(value); + case "account": + return isValidStellarAddress(value); + case "contract": + return isValidStellarContractId(value); + default: + return false; + } + }, [value, type]); + + // Generate the appropriate URL + const url = React.useMemo(() => { + if (!isValid) return ""; + + try { + const detectedNetwork = network || getStellarNetwork(); + + switch (type) { + case "transaction": + return getTransactionUrl(value, detectedNetwork, explorer); + case "account": + return getAccountUrl(value, detectedNetwork, explorer); + case "contract": + return getContractUrl(value, detectedNetwork, explorer); + default: + return ""; + } + } catch (error) { + console.error("Error generating Stellar URL:", error); + return ""; + } + }, [value, type, network, explorer, isValid]); + + // Truncate the display value + const displayValue = React.useMemo(() => { + if (linkText) return linkText; + if (!value) return ""; + if (value.length <= maxLength) return value; + + const start = value.slice(0, Math.ceil(maxLength / 2)); + const end = value.slice(-Math.floor(maxLength / 2)); + return `${start}...${end}`; + }, [value, maxLength, linkText]); + + // Copy to clipboard handler + const handleCopy = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setCopyError(false); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy:", error); + setCopyError(true); + setTimeout(() => setCopyError(false), 2000); + } + }; + + // Generate tooltip text + const tooltipText = React.useMemo(() => { + if (!isValid) return `Invalid ${type}`; + const prefix = tooltipPrefix || `View ${type}`; + const networkText = network || getStellarNetwork(); + return `${prefix} on ${networkText} • ${explorer}`; + }, [type, network, explorer, isValid, tooltipPrefix, value]); + + if (!value || !isValid) { + return ( + + {displayValue || "Invalid"} + + ); + } + + return ( + +
+ + + + {displayValue} + {showExternalIcon && ( + + )} + + + +

{tooltipText}

+
+
+ + {showCopy && ( + + + + + +

+ {copied ? "Copied!" : copyError ? "Failed" : `Copy ${type}`} +

+
+
+ )} +
+
+ ); +} + +// Convenience components for specific types +export function TransactionLink(props: Omit) { + return ; +} + +export function AccountLink(props: Omit) { + return ; +} + +export function ContractLink(props: Omit) { + return ; +} diff --git a/components/wallet/transaction-history.tsx b/components/wallet/transaction-history.tsx index aabbdd55..cefbe603 100644 --- a/components/wallet/transaction-history.tsx +++ b/components/wallet/transaction-history.tsx @@ -1,169 +1,237 @@ "use client"; -import { useState } from "react"; -import { WalletActivity } from "@/types/wallet"; -import { Input } from "@/components/ui/input"; -import { Search, Download, ArrowUpRight, ArrowDownLeft } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { TransactionLink } from "@/components/ui/stellar-link"; +import { WalletActivity } from "@/types/wallet"; import { format, isValid } from "date-fns"; -import { Badge } from "@/components/ui/badge"; +import { ArrowDownLeft, ArrowUpRight, Download, Search } from "lucide-react"; +import { useState } from "react"; interface TransactionHistoryProps { - activity: WalletActivity[]; + activity: WalletActivity[]; } export function TransactionHistory({ activity }: TransactionHistoryProps) { - const [search, setSearch] = useState(""); + const [search, setSearch] = useState(""); - const formatSafeDate = (dateString: string, formatString: string) => { - const date = new Date(dateString); - return isValid(date) ? format(date, formatString) : "—"; - }; + const formatSafeDate = (dateString: string, formatString: string) => { + const date = new Date(dateString); + return isValid(date) ? format(date, formatString) : "—"; + }; - const filteredActivity = activity.filter(item => - item.description?.toLowerCase().includes(search.toLowerCase()) || - item.type.toLowerCase().includes(search.toLowerCase()) || - item.currency.toLowerCase().includes(search.toLowerCase()) - ); + const filteredActivity = activity.filter( + (item) => + item.description?.toLowerCase().includes(search.toLowerCase()) || + item.type.toLowerCase().includes(search.toLowerCase()) || + item.currency.toLowerCase().includes(search.toLowerCase()), + ); - const formatCurrency = (amount: number, currency: string) => { - if (currency === "USD" || currency === "USDC") { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(amount); - } - return `${amount.toLocaleString()} ${currency}`; - }; + const formatCurrency = (amount: number, currency: string) => { + if (currency === "USD" || currency === "USDC") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount); + } + return `${amount.toLocaleString()} ${currency}`; + }; - const getStatusColor = (status: string) => { - switch (status) { - case 'completed': return 'bg-green-500/10 text-green-500 border-green-500/20'; - case 'pending': return 'bg-amber-500/10 text-amber-500 border-amber-500/20'; - case 'failed': return 'bg-red-500/10 text-red-500 border-red-500/20'; - default: return 'bg-muted text-muted-foreground'; - } - }; + const getStatusColor = (status: string) => { + switch (status) { + case "completed": + return "bg-green-500/10 text-green-500 border-green-500/20"; + case "pending": + return "bg-amber-500/10 text-amber-500 border-amber-500/20"; + case "failed": + return "bg-red-500/10 text-red-500 border-red-500/20"; + default: + return "bg-muted text-muted-foreground"; + } + }; - const handleExportCsv = () => { - const headers = ["ID", "Type", "Description", "Amount", "Currency", "Date", "Status"]; - const rows = filteredActivity.map(item => [ - item.id, - item.type, - item.description || "", - item.amount.toString(), - item.currency, - format(new Date(item.date), 'yyyy-MM-dd HH:mm:ss'), - item.status - ]); + const handleExportCsv = () => { + const headers = [ + "ID", + "Type", + "Description", + "Amount", + "Currency", + "Date", + "Transaction", + "Status", + ]; + const rows = filteredActivity.map((item) => [ + item.id, + item.type, + item.description || "", + item.amount.toString(), + item.currency, + formatSafeDate(item.date, "yyyy-MM-dd HH:mm:ss"), + item.transactionHash || "", + item.status, + ]); - const csvContent = [ - headers.join(","), - ...rows.map(row => row.map(cell => { - // Sanitize to prevent CSV injection - const sanitized = cell.replace(/"/g, '""'); - return /^[=+\-@]/.test(sanitized) ? `"'${sanitized}"` : `"${sanitized}"`; - }).join(",")) - ].join("\n"); + const csvContent = [ + headers.join(","), + ...rows.map((row) => + row + .map((cell) => { + // Sanitize to prevent CSV injection + const sanitized = cell.replace(/"/g, '""'); + return /^[=+\-@]/.test(sanitized) + ? `"'${sanitized}"` + : `"${sanitized}"`; + }) + .join(","), + ), + ].join("\n"); - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.setAttribute("href", url); - link.setAttribute("download", `transactions_${formatSafeDate(new Date().toISOString(), 'yyyyMMdd_HHmm')}.csv`); - link.style.visibility = 'hidden'; - document.body.appendChild(link); - link.click(); + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute( + "download", + `transactions_${formatSafeDate(new Date().toISOString(), "yyyyMMdd_HHmm")}.csv`, + ); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); - // Cleanup - document.body.removeChild(link); - setTimeout(() => URL.revokeObjectURL(url), 100); - }; + // Cleanup + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(url), 100); + }; - return ( -
-
-
- - setSearch(e.target.value)} - aria-label="Search transactions" - /> -
-
- -
-
+ return ( +
+
+
+ + setSearch(e.target.value)} + aria-label="Search transactions" + /> +
+
+ +
+
-
-
- - - - - - - - - - - - {filteredActivity.length === 0 ? ( - - - - ) : ( - filteredActivity.map((item) => ( - - - - - - - - )) - )} - -
TypeDescriptionAmountDateStatus
- No activity found. -
-
-
- {item.type === 'earning' ? ( - - ) : ( - - )} -
- {item.type} -
-
- {item.description || 'No description'} - - - {item.type === 'earning' ? '+' : '-'} {formatCurrency(item.amount, item.currency)} - - - {formatSafeDate(item.date, 'MMM d, yyyy')} - - - {item.status} - -
-
-
+
+
+ + + + + + + + + + + + + {filteredActivity.length === 0 ? ( + + + + ) : ( + filteredActivity.map((item) => ( + + + + + + + + + )) + )} + +
+ Type + + Description + + Amount + + Date + + Transaction + + Status +
+ No activity found. +
+
+
+ {item.type === "earning" ? ( + + ) : ( + + )} +
+ + {item.type} + +
+
+ {item.description || "No description"} + + + {item.type === "earning" ? "+" : "-"}{" "} + {formatCurrency(item.amount, item.currency)} + + + {formatSafeDate(item.date, "MMM d, yyyy")} + + {item.transactionHash ? ( + + ) : ( + + )} + + + {item.status} + +
- ); +
+
+ ); } diff --git a/components/wallet/wallet-overview.tsx b/components/wallet/wallet-overview.tsx index 2d0e454d..e6204229 100644 --- a/components/wallet/wallet-overview.tsx +++ b/components/wallet/wallet-overview.tsx @@ -1,88 +1,71 @@ "use client"; -import { useState } from "react"; -import { WalletInfo } from "@/types/wallet"; -import { truncateStellarAddress } from "@/lib/mock-wallet"; import { Button } from "@/components/ui/button"; -import { Copy, Check, ExternalLink, ShieldCheck, Calendar } from "lucide-react"; +import { AccountLink } from "@/components/ui/stellar-link"; +import { getAccountUrl } from "@/lib/utils/stellar-explorer"; +import { WalletInfo } from "@/types/wallet"; +import { Calendar, ShieldCheck } from "lucide-react"; interface WalletOverviewProps { - walletInfo: WalletInfo; + walletInfo: WalletInfo; } export function WalletOverview({ walletInfo }: WalletOverviewProps) { - const [copied, setCopied] = useState(false); - - const handleCopyAddress = async () => { - try { - await navigator.clipboard.writeText(walletInfo.address); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error("Failed to copy address:", error); - } - }; - - return ( -
-
-

Account Information

- -
-
-
Status
-
- - Active & Secured -
-
+ return ( +
+
+

+ Account Information +

-
-
Wallet Address
-
-
- {truncateStellarAddress(walletInfo.address)} -
- -
-
+
+
+
Status
+
+ + Active & Secured +
+
-
-
- - Abstracted Wallet -
-
No setup required
-
+
+
Wallet Address
+ +
-
-
- - Created -
-
May 2024
-
-
+
+
+ + Abstracted Wallet +
+
+ No setup required
+
- +
+
+ + Created +
+
May 2024
+
- ); +
+ + +
+ ); } diff --git a/components/wallet/wallet-sheet.tsx b/components/wallet/wallet-sheet.tsx index 7006c2f7..5a681b09 100644 --- a/components/wallet/wallet-sheet.tsx +++ b/components/wallet/wallet-sheet.tsx @@ -1,9 +1,9 @@ "use client"; import React from "react"; - -import { useState } from "react"; import Link from "next/link"; + +import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, @@ -11,21 +11,19 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; -import { Button } from "@/components/ui/button"; +import { AccountLink } from "@/components/ui/stellar-link"; +import { WalletInfo } from "@/types/wallet"; +import { formatDistanceToNow } from "date-fns"; import { - Wallet, - Copy, + ArrowDownLeft, + ArrowUpRight, Check, - Shield, ExternalLink, - ArrowUpRight, - ArrowDownLeft, Loader2, LogOut, + Shield, + Wallet, } from "lucide-react"; -import { WalletInfo } from "@/types/wallet"; -import { truncateStellarAddress } from "@/lib/mock-wallet"; -import { formatDistanceToNow } from "date-fns"; import { useSmartWallet } from "@/components/providers/smart-wallet-provider"; interface WalletSheetProps { @@ -35,19 +33,6 @@ interface WalletSheetProps { export function WalletSheet({ walletInfo, trigger }: WalletSheetProps) { const { disconnect, isLoading } = useSmartWallet(); - const [copied, setCopied] = useState(false); - - // ... (rest of the component) - - const handleCopyAddress = async () => { - try { - await navigator.clipboard.writeText(walletInfo.address); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error("Failed to copy address:", error); - } - }; const formatCurrency = (amount: number, currency: string = "USD") => { if (currency === "USD") { @@ -89,30 +74,15 @@ export function WalletSheet({ walletInfo, trigger }: WalletSheetProps) {
- {/* Integrated Address with Copy */} -
-
-
- Wallet Address -
-
- {truncateStellarAddress(walletInfo.address)} -
-
- - + {/* Integrated Address with AccountLink */} +
+
Wallet Address
+
diff --git a/hooks/use-bounty-mutations.ts b/hooks/use-bounty-mutations.ts index 2c90b387..973c4f12 100644 --- a/hooks/use-bounty-mutations.ts +++ b/hooks/use-bounty-mutations.ts @@ -59,10 +59,8 @@ export function useCreateBounty() { return { ...mutation, - mutate: ( - input: CreateBountyInput, - options?: CreateBountyMutateOptions, - ) => mutation.mutate({ input }, options), + mutate: (input: CreateBountyInput, options?: CreateBountyMutateOptions) => + mutation.mutate({ input }, options), mutateAsync: ( input: CreateBountyInput, options?: CreateBountyMutateOptions, @@ -193,14 +191,10 @@ export function useDeleteBounty() { return { ...mutation, - mutate: ( - id: string, - options?: DeleteBountyMutateOptions, - ) => mutation.mutate({ id }, options), - mutateAsync: ( - id: string, - options?: DeleteBountyMutateOptions, - ) => mutation.mutateAsync({ id }, options), + mutate: (id: string, options?: DeleteBountyMutateOptions) => + mutation.mutate({ id }, options), + mutateAsync: (id: string, options?: DeleteBountyMutateOptions) => + mutation.mutateAsync({ id }, options), }; } @@ -229,14 +223,9 @@ export function useClaimBounty() { return { ...mutation, - mutate: ( - id: string, - options?: UpdateBountyMutateOptions, - ) => mutation.mutate({ input: { id, status: "IN_PROGRESS" } }, options), - mutateAsync: ( - id: string, - options?: UpdateBountyMutateOptions, - ) => + mutate: (id: string, options?: UpdateBountyMutateOptions) => + mutation.mutate({ input: { id, status: "IN_PROGRESS" } }, options), + mutateAsync: (id: string, options?: UpdateBountyMutateOptions) => mutation.mutateAsync({ input: { id, status: "IN_PROGRESS" } }, options), }; } @@ -313,4 +302,3 @@ export function useCancelBounty() { mutation.mutateAsync({ input: { id, status: "CANCELLED" } }, options), }; } - diff --git a/hooks/use-cancel-bounty-dialog.ts b/hooks/use-cancel-bounty-dialog.ts index 4f7b92f2..1cd6dd1a 100644 --- a/hooks/use-cancel-bounty-dialog.ts +++ b/hooks/use-cancel-bounty-dialog.ts @@ -41,7 +41,10 @@ export function useCancelBountyDialog( reason: cancelReason.trim(), }); } catch (mutationErr) { - console.error("GraphQL mutation failed, reverting escrow:", mutationErr); + console.error( + "GraphQL mutation failed, reverting escrow:", + mutationErr, + ); await EscrowService.revertCancel(bountyId); throw mutationErr; } diff --git a/hooks/use-escrow.ts b/hooks/use-escrow.ts index e62e8026..4c5c5748 100644 --- a/hooks/use-escrow.ts +++ b/hooks/use-escrow.ts @@ -55,4 +55,3 @@ export function useCancellation(bountyId: string, enabled = true) { enabled: !!bountyId && enabled, }); } - diff --git a/lib/smart-wallet/config.ts b/lib/smart-wallet/config.ts index 7f252e7a..60d0c0ea 100644 --- a/lib/smart-wallet/config.ts +++ b/lib/smart-wallet/config.ts @@ -1,11 +1,11 @@ import { Networks } from "@stellar/stellar-sdk"; -const requireEnv = (name: string, fallback: string) => { +const requireEnv = (name: string, fallback?: string) => { const value = process.env[name]; - if (process.env.NODE_ENV === "production" && !value) { + if (!value && !fallback) { throw new Error(`Missing required environment variable: ${name}`); } - return value || fallback; + return value || fallback || ""; }; export const SMART_WALLET_CONFIG = { diff --git a/lib/utils/stellar-explorer.ts b/lib/utils/stellar-explorer.ts new file mode 100644 index 00000000..6445dd79 --- /dev/null +++ b/lib/utils/stellar-explorer.ts @@ -0,0 +1,197 @@ +/** + * Stellar Explorer Integration Utilities + * + * Provides URL generation for Stellar explorers to link transactions, + * accounts, and contracts for transparency and verification. + * + * Uses @stellar/stellar-sdk StrKey for proper checksum validation. + */ + +import { StrKey } from "@stellar/stellar-sdk"; + +export type StellarNetwork = "public" | "testnet"; +export type ExplorerType = "transaction" | "account" | "contract"; + +export interface ExplorerConfig { + name: string; + baseUrl: string; + /** Path template per network: keys are "public" and "testnet" */ + networkPaths: Record< + StellarNetwork, + { + transaction: string; + account: string; + contract: string; + } + >; +} + +// Supported Stellar explorers with per-network path configuration +const EXPLORERS: Record = { + "stellar.expert": { + name: "Stellar Expert", + baseUrl: "https://stellar.expert", + networkPaths: { + public: { + transaction: "/explorer/public/tx/", + account: "/explorer/public/account/", + contract: "/explorer/public/contract/", + }, + testnet: { + transaction: "/explorer/testnet/tx/", + account: "/explorer/testnet/account/", + contract: "/explorer/testnet/contract/", + }, + }, + }, + "stellarchain.io": { + name: "Stellar Chain", + baseUrl: "https://stellarchain.io", + networkPaths: { + public: { + transaction: "/tx/", + account: "/account/", + contract: "/contract/", + }, + testnet: { + transaction: "/tx/", + account: "/account/", + contract: "/contract/", + }, + }, + }, +}; + +const DEFAULT_EXPLORER = "stellar.expert"; + +/** + * Returns the configured Stellar network from environment variable. + * Maps "mainnet" / "public" → "public", defaults to "testnet" otherwise. + */ +export function getStellarNetwork(): StellarNetwork { + const raw = process.env.NEXT_PUBLIC_STELLAR_NETWORK || "testnet"; + return raw === "public" || raw === "mainnet" ? "public" : "testnet"; +} + +/** + * Gets the base URL for the given network and explorer. + * stellarchain.io uses a subdomain for testnet; stellar.expert uses paths. + */ +function getExplorerBaseUrl(explorer: string, network: StellarNetwork): string { + const config = EXPLORERS[explorer] || EXPLORERS[DEFAULT_EXPLORER]; + + if (network === "testnet" && explorer === "stellarchain.io") { + return "https://testnet.stellarchain.io"; + } + + return config.baseUrl; +} + +/** + * Gets the explorer paths for the given network. + */ +function getExplorerPaths( + explorer: string, + network: StellarNetwork, +): ExplorerConfig["networkPaths"]["public"] { + const config = EXPLORERS[explorer] || EXPLORERS[DEFAULT_EXPLORER]; + return config.networkPaths[network]; +} + +/** + * Generates a Stellar explorer URL for a transaction. + */ +export function getTransactionUrl( + txHash: string, + network?: StellarNetwork, + explorer: string = DEFAULT_EXPLORER, +): string { + if (!txHash) { + throw new Error("Transaction hash is required"); + } + + const resolvedNetwork = network || getStellarNetwork(); + const baseUrl = getExplorerBaseUrl(explorer, resolvedNetwork); + const paths = getExplorerPaths(explorer, resolvedNetwork); + + return `${baseUrl}${paths.transaction}${txHash}`; +} + +/** + * Generates a Stellar explorer URL for an account. + */ +export function getAccountUrl( + address: string, + network?: StellarNetwork, + explorer: string = DEFAULT_EXPLORER, +): string { + if (!address) { + throw new Error("Account address is required"); + } + + const resolvedNetwork = network || getStellarNetwork(); + const baseUrl = getExplorerBaseUrl(explorer, resolvedNetwork); + const paths = getExplorerPaths(explorer, resolvedNetwork); + + return `${baseUrl}${paths.account}${address}`; +} + +/** + * Generates a Stellar explorer URL for a contract. + */ +export function getContractUrl( + contractId: string, + network?: StellarNetwork, + explorer: string = DEFAULT_EXPLORER, +): string { + if (!contractId) { + throw new Error("Contract ID is required"); + } + + const resolvedNetwork = network || getStellarNetwork(); + const baseUrl = getExplorerBaseUrl(explorer, resolvedNetwork); + const paths = getExplorerPaths(explorer, resolvedNetwork); + + return `${baseUrl}${paths.contract}${contractId}`; +} + +/** + * Gets a list of available explorers. + */ +export function getAvailableExplorers(): string[] { + return Object.keys(EXPLORERS); +} + +/** + * Gets the configuration for a specific explorer. + */ +export function getExplorerConfig(explorer: string): ExplorerConfig | null { + return EXPLORERS[explorer] || null; +} + +/** + * Validates if a string is a valid Stellar account address (G-address). + * Uses StrKey checksum validation from @stellar/stellar-sdk. + */ +export function isValidStellarAddress(address: string): boolean { + if (!address || typeof address !== "string") return false; + return StrKey.isValidEd25519PublicKey(address.trim()); +} + +/** + * Validates if a string is a valid Stellar transaction hash. + * Transaction hashes are 64-character lowercase hex strings. + */ +export function isValidStellarTxHash(hash: string): boolean { + if (!hash || typeof hash !== "string") return false; + return /^[a-f0-9]{64}$/i.test(hash.trim()); +} + +/** + * Validates if a string is a valid Stellar/Soroban contract ID (C-address). + * Uses StrKey checksum validation from @stellar/stellar-sdk. + */ +export function isValidStellarContractId(contractId: string): boolean { + if (!contractId || typeof contractId !== "string") return false; + return StrKey.isValidContract(contractId.trim()); +} diff --git a/types/wallet.ts b/types/wallet.ts index 13acab04..7fe1aeee 100644 --- a/types/wallet.ts +++ b/types/wallet.ts @@ -1,32 +1,33 @@ export interface WalletAsset { - id: string - tokenSymbol: string - tokenName: string - tokenIcon?: string - amount: number - usdValue: number + id: string; + tokenSymbol: string; + tokenName: string; + tokenIcon?: string; + amount: number; + usdValue: number; } -export type ActivityType = 'earning' | 'withdrawal' | 'deposit' -export type ActivityStatus = 'completed' | 'pending' | 'failed' +export type ActivityType = "earning" | "withdrawal" | "deposit"; +export type ActivityStatus = "completed" | "pending" | "failed"; export interface WalletActivity { - id: string - type: ActivityType - amount: number - currency: string - date: string - status: ActivityStatus - description?: string + id: string; + type: ActivityType; + amount: number; + currency: string; + date: string; + status: ActivityStatus; + description?: string; + transactionHash?: string; // Stellar transaction hash for explorer links } export interface WalletInfo { - address: string // Stellar public key - displayName: string - balance: number - balanceCurrency: 'USD' | 'USDC' | 'XLM' - assets: WalletAsset[] - recentActivity: WalletActivity[] - has2FA: boolean - isConnected: boolean + address: string; // Stellar public key + displayName: string; + balance: number; + balanceCurrency: "USD" | "USDC" | "XLM"; + assets: WalletAsset[]; + recentActivity: WalletActivity[]; + has2FA: boolean; + isConnected: boolean; }