From 7e95632c7a78e5192b942655c0591f9520e5a129 Mon Sep 17 00:00:00 2001 From: Cristian Chinchilla Valerin Date: Sun, 31 May 2026 09:22:46 -0600 Subject: [PATCH 1/2] feat: stealth account labeling + personal organization (#20) - Add localStorage-backed label storage keyed by walletPubkey:stealthAddress - Add inline editable labels with pencil icon, save on blur/Enter, 64 char cap - Add tag chips with click-to-filter and add/remove functionality - Add search input filtering by label, tag, or address - Add hide/archive per row with show hidden toggle - Add export labels as JSON file download - Add import labels with file picker and conflict resolution modal - Add one-time privacy warning tooltip on first label save - All labels are 100% client-side, never leak to network calls - Storage is per-wallet isolated --- src/components/ImportConflictModal.tsx | 71 +++++++ src/components/PrivacyTooltip.tsx | 27 +++ src/components/StellarMatchCard.tsx | 273 ++++++++++++++++++++++++- src/components/StellarReceive.tsx | 207 +++++++++++++++++-- src/components/StellarReceiveView.tsx | 200 ++++++++++++++++++ src/hooks/useStealthLabels.ts | 116 +++++++++++ src/lib/stealthLabels.ts | 173 ++++++++++++++++ 7 files changed, 1046 insertions(+), 21 deletions(-) create mode 100644 src/components/ImportConflictModal.tsx create mode 100644 src/components/PrivacyTooltip.tsx create mode 100644 src/hooks/useStealthLabels.ts create mode 100644 src/lib/stealthLabels.ts diff --git a/src/components/ImportConflictModal.tsx b/src/components/ImportConflictModal.tsx new file mode 100644 index 0000000..6e98bf7 --- /dev/null +++ b/src/components/ImportConflictModal.tsx @@ -0,0 +1,71 @@ +import { type ImportResult } from '@/lib/stealthLabels'; + +interface ImportConflictModalProps { + conflicts: ImportResult['conflicts']; + onResolve: (action: 'keep-all' | 'overwrite-all') => void; + onClose: () => void; +} + +export function ImportConflictModal({ conflicts, onResolve, onClose }: ImportConflictModalProps) { + return ( +
+
+

+ Import Conflicts +

+

+ {conflicts.length} label{conflicts.length !== 1 ? 's' : ''} already exist with different + values. +

+ +
+ {conflicts.map((c) => ( +
+ + {c.stealthAddress} + +
+
+ + Current + +

{c.existingLabel || '(empty)'}

+
+
+ + Incoming + +

{c.incomingLabel || '(empty)'}

+
+
+
+ ))} +
+ +
+ + + +
+
+
+ ); +} diff --git a/src/components/PrivacyTooltip.tsx b/src/components/PrivacyTooltip.tsx new file mode 100644 index 0000000..43ad632 --- /dev/null +++ b/src/components/PrivacyTooltip.tsx @@ -0,0 +1,27 @@ +interface PrivacyTooltipProps { + onDismiss: () => void; +} + +export function PrivacyTooltip({ onDismiss }: PrivacyTooltipProps) { + return ( +
+
+
+ + Privacy Notice + +

+ Labels are stored only in this browser. Clear browser data = lose labels. Wraith never + sees them. +

+
+ +
+
+ ); +} diff --git a/src/components/StellarMatchCard.tsx b/src/components/StellarMatchCard.tsx index ff99830..58ee6f4 100644 --- a/src/components/StellarMatchCard.tsx +++ b/src/components/StellarMatchCard.tsx @@ -1,5 +1,7 @@ +import { useState, useEffect, useRef } from 'react'; import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; import { CopyButton } from '@/components/CopyButton'; +import { PrivacyTooltip } from '@/components/PrivacyTooltip'; export interface StellarMatchCardProps { stealthAddress: string; @@ -18,6 +20,13 @@ export interface StellarMatchCardProps { onSponsoredWithdraw: () => void; onCancelSponsor: () => void; onRevealKey: () => void; + labelData?: { label: string; tags: string[]; hiddenAt?: number } | null; + onSaveLabel?: (label: string, tags: string[]) => void; + onHide?: () => void; + onUnhide?: () => void; + onTagClick?: (tag: string) => void; + showPrivacyWarning?: boolean; + onDismissPrivacyWarning?: () => void; } export function StellarMatchCard({ @@ -37,11 +46,273 @@ export function StellarMatchCard({ onSponsoredWithdraw, onCancelSponsor, onRevealKey, + labelData, + onSaveLabel, + onHide, + onUnhide, + onTagClick, + showPrivacyWarning, + onDismissPrivacyWarning, }: StellarMatchCardProps) { const hasBalance = balanceState === 'loaded' && balance != null && parseFloat(balance) > 0; + const isHidden = !!labelData?.hiddenAt; + const currentLabel = labelData?.label ?? ''; + const currentTags = labelData?.tags ?? []; + + const [isEditingLabel, setIsEditingLabel] = useState(false); + const [editLabelValue, setEditLabelValue] = useState(currentLabel); + const [isAddingTag, setIsAddingTag] = useState(false); + const [newTagValue, setNewTagValue] = useState(''); + const [showPrivacyBanner, setShowPrivacyBanner] = useState(false); + const labelInputRef = useRef(null); + const tagInputRef = useRef(null); + + useEffect(() => { + setEditLabelValue(currentLabel); + }, [currentLabel]); + + useEffect(() => { + if (isEditingLabel && labelInputRef.current) { + labelInputRef.current.focus(); + } + }, [isEditingLabel]); + + useEffect(() => { + if (isAddingTag && tagInputRef.current) { + tagInputRef.current.focus(); + } + }, [isAddingTag]); + + const commitLabel = () => { + if (!onSaveLabel) return; + const trimmed = editLabelValue.trim().slice(0, 64); + if (trimmed !== currentLabel) { + if (showPrivacyWarning && !currentLabel) { + setShowPrivacyBanner(true); + } + onSaveLabel(trimmed, currentTags); + } + setIsEditingLabel(false); + }; + + const addTag = () => { + if (!onSaveLabel) return; + const trimmed = newTagValue.trim().slice(0, 64); + if (trimmed && !currentTags.includes(trimmed)) { + const updatedTags = [...currentTags, trimmed]; + onSaveLabel(currentLabel, updatedTags); + } + setNewTagValue(''); + setIsAddingTag(false); + }; + + const removeTag = (tag: string) => { + if (!onSaveLabel) return; + const updatedTags = currentTags.filter((t) => t !== tag); + onSaveLabel(currentLabel, updatedTags); + }; return ( -
+
+ {/* Label section */} + {onSaveLabel && ( +
+
+ {isEditingLabel ? ( + setEditLabelValue(e.target.value.slice(0, 64))} + onBlur={commitLabel} + onKeyDown={(e) => { + if (e.key === 'Enter') commitLabel(); + if (e.key === 'Escape') { + setEditLabelValue(currentLabel); + setIsEditingLabel(false); + } + }} + placeholder="Add a label..." + maxLength={64} + className="h-7 flex-1 border border-outline-variant bg-surface px-2 font-body text-sm text-on-surface placeholder:text-outline focus:border-primary" + /> + ) : ( +
+ {currentLabel ? ( + {currentLabel} + ) : ( + No label + )} + +
+ )} + + {isHidden ? ( + onUnhide && ( + + ) + ) : ( + onHide && ( + + ) + )} +
+ + {/* Tags */} + {(currentTags.length > 0 || !isHidden) && ( +
+ {currentTags.map((tag) => ( + + + + + ))} + {!isHidden && + (isAddingTag ? ( + setNewTagValue(e.target.value.slice(0, 64))} + onBlur={addTag} + onKeyDown={(e) => { + if (e.key === 'Enter') addTag(); + if (e.key === 'Escape') { + setNewTagValue(''); + setIsAddingTag(false); + } + }} + placeholder="tag name" + maxLength={64} + className="h-5 w-20 border border-outline-variant bg-surface px-1 font-mono text-[10px] text-on-surface placeholder:text-outline focus:border-primary" + /> + ) : ( + + ))} +
+ )} + + {/* Privacy warning */} + {showPrivacyBanner && onDismissPrivacyWarning && ( + { + setShowPrivacyBanner(false); + onDismissPrivacyWarning(); + }} + /> + )} +
+ )} +
diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index ea4ace1..4bca35a 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { TransactionBuilder, Operation, @@ -21,8 +21,12 @@ import { useStealthKeys } from '@/context/StealthKeysContext'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { StellarMatchCard } from '@/components/StellarMatchCard'; import { StellarReceiveView } from '@/components/StellarReceiveView'; +import { PrivacyTooltip } from '@/components/PrivacyTooltip'; +import { ImportConflictModal } from '@/components/ImportConflictModal'; +import { useStealthLabels } from '@/hooks/useStealthLabels'; import { STELLAR_NETWORK } from '@/config'; import { useActivityStore } from '@/stores/activityStore'; +import type { ImportResult } from '@/lib/stealthLabels'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5'; @@ -30,9 +34,23 @@ const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PY function StellarMatchCardContainer({ match, onWithdrawn, + labelData, + onSaveLabel, + onHide, + onUnhide, + onTagClick, + showPrivacyWarning, + onDismissPrivacyWarning, }: { match: MatchedAnnouncement; onWithdrawn: () => void; + labelData: { label: string; tags: string[]; hiddenAt?: number } | null; + onSaveLabel: (label: string, tags: string[]) => void; + onHide: () => void; + onUnhide: () => void; + onTagClick: (tag: string) => void; + showPrivacyWarning: boolean; + onDismissPrivacyWarning: () => void; }) { const { address, signTransaction } = useStellarWallet(); const [balance, setBalance] = useState(null); @@ -284,6 +302,13 @@ function StellarMatchCardContainer({ onSponsoredWithdraw={handleSponsoredWithdraw} onCancelSponsor={() => setShowSponsorPrompt(false)} onRevealKey={() => setShowKey(true)} + labelData={labelData} + onSaveLabel={onSaveLabel} + onHide={onHide} + onUnhide={onUnhide} + onTagClick={onTagClick} + showPrivacyWarning={showPrivacyWarning} + onDismissPrivacyWarning={onDismissPrivacyWarning} /> ); } @@ -314,6 +339,54 @@ export function StellarReceive() { const [regHash, setRegHash] = useState(null); const [isAlreadyRegistered, setIsAlreadyRegistered] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [activeTag, setActiveTag] = useState(null); + const [showHidden, setShowHidden] = useState(false); + const [importConflicts, setImportConflicts] = useState(null); + const [pendingImportJson, setPendingImportJson] = useState(null); + const [importMessage, setImportMessage] = useState(null); + const fileInputRef = useRef(null); + + const { + labels, + saveLabel, + hideAddress, + unhideAddress, + exportLabels, + importLabels, + shouldShowPrivacyWarning, + dismissPrivacyWarning, + getAllTags, + } = useStealthLabels(address); + + const allTags = useMemo(() => getAllTags(), [getAllTags, labels]); + + const filteredMatched = useMemo(() => { + return matched.filter((m) => { + const labelData = labels[m.stealthAddress]; + + if (!showHidden && labelData?.hiddenAt) return false; + + if (searchQuery) { + const q = searchQuery.toLowerCase(); + const matchesLabel = labelData?.label?.toLowerCase().includes(q); + const matchesTag = labelData?.tags?.some((t) => t.toLowerCase().includes(q)); + const matchesAddress = m.stealthAddress.toLowerCase().includes(q); + if (!matchesLabel && !matchesTag && !matchesAddress) return false; + } + + if (activeTag) { + if (!labelData?.tags?.includes(activeTag)) return false; + } + + return true; + }); + }, [matched, labels, showHidden, searchQuery, activeTag]); + + const hiddenCount = useMemo(() => { + return matched.filter((m) => labels[m.stealthAddress]?.hiddenAt).length; + }, [matched, labels]); + // Check if already registered on-chain useEffect(() => { if (!address) return; @@ -520,25 +593,119 @@ export function StellarReceive() { } }, [stellarKeys]); + const handleExport = () => { + const json = exportLabels(); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `wraith-labels-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleImportFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const json = ev.target?.result as string; + JSON.parse(json); + const result = importLabels(json, false); + if (result.conflicts.length > 0) { + setImportConflicts(result.conflicts); + setPendingImportJson(json); + } else { + setImportMessage(`Imported ${result.imported} label${result.imported !== 1 ? 's' : ''}.`); + setTimeout(() => setImportMessage(null), 3000); + } + } catch { + setImportMessage('Invalid JSON file.'); + setTimeout(() => setImportMessage(null), 3000); + } + }; + reader.readAsText(file); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const handleConflictResolve = (action: 'keep-all' | 'overwrite-all') => { + if (action === 'overwrite-all' && pendingImportJson) { + const result = importLabels(pendingImportJson, true); + setImportMessage( + `Imported ${result.imported} label${result.imported !== 1 ? 's' : ''} (overwritten).`, + ); + } else { + setImportMessage('Kept existing labels.'); + } + setImportConflicts(null); + setPendingImportJson(null); + setTimeout(() => setImportMessage(null), 3000); + }; + return ( - ( - {}} /> - ))} - /> + <> + + setActiveTag(activeTag === tag ? null : tag)} + showHidden={showHidden} + hiddenCount={hiddenCount} + onToggleShowHidden={() => setShowHidden(!showHidden)} + onExport={handleExport} + onImport={() => fileInputRef.current?.click()} + importMessage={importMessage} + importConflicts={importConflicts} + onImportConflictResolve={handleConflictResolve} + onCloseImportModal={() => { + setImportConflicts(null); + setPendingImportJson(null); + }} + matches={ + filteredMatched.length > 0 ? ( +
+ {filteredMatched.map((m, i) => ( + {}} + labelData={labels[m.stealthAddress] ?? null} + onSaveLabel={(label, tags) => saveLabel(m.stealthAddress, label, tags)} + onHide={() => hideAddress(m.stealthAddress)} + onUnhide={() => unhideAddress(m.stealthAddress)} + onTagClick={(tag) => setActiveTag(activeTag === tag ? null : tag)} + showPrivacyWarning={shouldShowPrivacyWarning} + onDismissPrivacyWarning={dismissPrivacyWarning} + /> + ))} +
+ ) : null + } + /> + ); } diff --git a/src/components/StellarReceiveView.tsx b/src/components/StellarReceiveView.tsx index b9ec73b..119312c 100644 --- a/src/components/StellarReceiveView.tsx +++ b/src/components/StellarReceiveView.tsx @@ -2,6 +2,8 @@ import type { ReactNode } from 'react'; import { stellarTxUrl } from '@/lib/explorer'; import { CopyButton } from '@/components/CopyButton'; import { StellarPaymentLink } from '@/components/StellarPaymentLink'; +import { ImportConflictModal } from '@/components/ImportConflictModal'; +import type { ImportResult } from '@/lib/stealthLabels'; export interface StellarReceiveViewProps { isConnected: boolean; @@ -19,6 +21,21 @@ export interface StellarReceiveViewProps { onDeriveKeys: () => void; onRegister: () => void; onScan: () => void; + searchQuery?: string; + onSearchChange?: (value: string) => void; + filteredMatchCount?: number; + activeTag?: string | null; + allTags?: string[]; + onTagClick?: (tag: string) => void; + showHidden?: boolean; + hiddenCount?: number; + onToggleShowHidden?: () => void; + onExport?: () => void; + onImport?: () => void; + importMessage?: string | null; + importConflicts?: ImportResult['conflicts'] | null; + onImportConflictResolve?: (action: 'keep-all' | 'overwrite-all') => void; + onCloseImportModal?: () => void; } export function StellarReceiveView({ @@ -37,6 +54,21 @@ export function StellarReceiveView({ onDeriveKeys, onRegister, onScan, + searchQuery, + onSearchChange, + filteredMatchCount, + activeTag, + allTags, + onTagClick, + showHidden, + hiddenCount, + onToggleShowHidden, + onExport, + onImport, + importMessage, + importConflicts, + onImportConflictResolve, + onCloseImportModal, }: StellarReceiveViewProps) { if (!isConnected) { return ( @@ -154,8 +186,167 @@ export function StellarReceiveView({ {error &&

{error}

} + {/* Search, filter, and toolbar */} + {hasScanned && matchCount > 0 && ( +
+ {onSearchChange && ( +
+
+ + + + + onSearchChange(e.target.value)} + placeholder="Search by label, tag, or address..." + className="h-9 w-full border border-outline-variant bg-surface pl-8 pr-3 font-body text-xs text-on-surface placeholder:text-outline focus:border-primary" + /> +
+ {onExport && ( + + )} + {onImport && ( + + )} +
+ )} + + {importMessage &&

{importMessage}

} + + {allTags && allTags.length > 0 && ( +
+ + Tags: + + {allTags.map((tag) => ( + + ))} + {activeTag && ( + + )} +
+ )} + + {hiddenCount != null && hiddenCount > 0 && onToggleShowHidden && ( + + )} +
+ )} + + {/* Matches */} {matchCount > 0 &&
{matches}
} + {hasScanned && matchCount > 0 && filteredMatchCount === 0 && ( +
+

+ No matching transfers +

+

+ Try adjusting your search or filters. +

+
+ )} + {hasScanned && matchCount === 0 && (

@@ -166,6 +357,15 @@ export function StellarReceiveView({

)} + + {/* Import conflict modal */} + {importConflicts && onImportConflictResolve && onCloseImportModal && ( + + )} )} diff --git a/src/hooks/useStealthLabels.ts b/src/hooks/useStealthLabels.ts new file mode 100644 index 0000000..ab1fbe2 --- /dev/null +++ b/src/hooks/useStealthLabels.ts @@ -0,0 +1,116 @@ +import { useState, useCallback } from 'react'; +import { + getLabels, + saveLabel as storageSaveLabel, + hideAddress as storageHideAddress, + unhideAddress as storageUnhideAddress, + deleteLabel as storageDeleteLabel, + getAllTags as storageGetAllTags, + exportLabels as storageExportLabels, + importLabels as storageImportLabels, + mergeConflicts as storageMergeConflicts, + hasShownPrivacyWarning, + markPrivacyWarningShown, + type StealthLabel, + type ImportResult, +} from '@/lib/stealthLabels'; + +export function useStealthLabels(walletPubkey: string | null) { + const [labels, setLabels] = useState>(() => + walletPubkey ? getLabels(walletPubkey) : {}, + ); + const [privacyWarningDismissed, setPrivacyWarningDismissed] = useState(hasShownPrivacyWarning); + + const refresh = useCallback(() => { + if (walletPubkey) { + setLabels(getLabels(walletPubkey)); + } + }, [walletPubkey]); + + const saveLabel = useCallback( + (stealthAddress: string, label: string, tags: string[]) => { + if (!walletPubkey) return; + storageSaveLabel(walletPubkey, stealthAddress, label, tags); + refresh(); + }, + [walletPubkey, refresh], + ); + + const hideAddress = useCallback( + (stealthAddress: string) => { + if (!walletPubkey) return; + storageHideAddress(walletPubkey, stealthAddress); + refresh(); + }, + [walletPubkey, refresh], + ); + + const unhideAddress = useCallback( + (stealthAddress: string) => { + if (!walletPubkey) return; + storageUnhideAddress(walletPubkey, stealthAddress); + refresh(); + }, + [walletPubkey, refresh], + ); + + const removeLabel = useCallback( + (stealthAddress: string) => { + if (!walletPubkey) return; + storageDeleteLabel(walletPubkey, stealthAddress); + refresh(); + }, + [walletPubkey, refresh], + ); + + const getAllTags = useCallback((): string[] => { + if (!walletPubkey) return []; + return storageGetAllTags(walletPubkey); + }, [walletPubkey]); + + const doExportLabels = useCallback((): string => { + if (!walletPubkey) return '{}'; + return storageExportLabels(walletPubkey); + }, [walletPubkey]); + + const doImportLabels = useCallback( + (json: string, overwriteConflicts: boolean = false): ImportResult => { + if (!walletPubkey) return { imported: 0, conflicts: [] }; + const result = storageImportLabels(walletPubkey, json, overwriteConflicts); + refresh(); + return result; + }, + [walletPubkey, refresh], + ); + + const doMergeConflicts = useCallback( + (resolutions: Record) => { + if (!walletPubkey) return; + storageMergeConflicts(walletPubkey, resolutions); + refresh(); + }, + [walletPubkey, refresh], + ); + + const shouldShowPrivacyWarning = !privacyWarningDismissed; + + const dismissPrivacyWarning = useCallback(() => { + markPrivacyWarningShown(); + setPrivacyWarningDismissed(true); + }, []); + + return { + labels, + saveLabel, + hideAddress, + unhideAddress, + removeLabel, + getAllTags, + exportLabels: doExportLabels, + importLabels: doImportLabels, + mergeConflicts: doMergeConflicts, + shouldShowPrivacyWarning, + dismissPrivacyWarning, + refresh, + }; +} diff --git a/src/lib/stealthLabels.ts b/src/lib/stealthLabels.ts new file mode 100644 index 0000000..efd9087 --- /dev/null +++ b/src/lib/stealthLabels.ts @@ -0,0 +1,173 @@ +const PRIVACY_WARNING_KEY = 'wraith:labels:privacy-warning-shown'; +const MAX_LABEL_LENGTH = 64; + +export interface StealthLabel { + stealthAddress: string; + label: string; + tags: string[]; + hiddenAt?: number; + createdAt: number; +} + +function storageKey(walletPubkey: string, stealthAddress: string): string { + return `${walletPubkey}:${stealthAddress}`; +} + +function isLabelKey(key: string, walletPubkey: string): boolean { + return key.startsWith(`${walletPubkey}:`) && key !== PRIVACY_WARNING_KEY; +} + +function parseEntry(raw: string | null): StealthLabel | null { + if (!raw) return null; + try { + return JSON.parse(raw) as StealthLabel; + } catch { + return null; + } +} + +export function getLabels(walletPubkey: string): Record { + const result: Record = {}; + const prefix = `${walletPubkey}:`; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key || !isLabelKey(key, walletPubkey)) continue; + const entry = parseEntry(localStorage.getItem(key)); + if (entry) { + result[entry.stealthAddress] = entry; + } + } + return result; +} + +export function getLabel(walletPubkey: string, stealthAddress: string): StealthLabel | null { + return parseEntry(localStorage.getItem(storageKey(walletPubkey, stealthAddress))); +} + +export function saveLabel( + walletPubkey: string, + stealthAddress: string, + label: string, + tags: string[], +): StealthLabel { + const key = storageKey(walletPubkey, stealthAddress); + const existing = parseEntry(localStorage.getItem(key)); + const trimmedLabel = label.slice(0, MAX_LABEL_LENGTH); + const cleanTags = tags.map((t) => t.trim().slice(0, MAX_LABEL_LENGTH)).filter(Boolean); + + const entry: StealthLabel = { + stealthAddress, + label: trimmedLabel, + tags: cleanTags, + hiddenAt: existing?.hiddenAt, + createdAt: existing?.createdAt ?? Date.now(), + }; + + localStorage.setItem(key, JSON.stringify(entry)); + return entry; +} + +export function hideAddress(walletPubkey: string, stealthAddress: string): void { + const key = storageKey(walletPubkey, stealthAddress); + const existing = parseEntry(localStorage.getItem(key)); + if (existing) { + existing.hiddenAt = Date.now(); + localStorage.setItem(key, JSON.stringify(existing)); + } else { + const entry: StealthLabel = { + stealthAddress, + label: '', + tags: [], + hiddenAt: Date.now(), + createdAt: Date.now(), + }; + localStorage.setItem(key, JSON.stringify(entry)); + } +} + +export function unhideAddress(walletPubkey: string, stealthAddress: string): void { + const key = storageKey(walletPubkey, stealthAddress); + const existing = parseEntry(localStorage.getItem(key)); + if (existing) { + delete existing.hiddenAt; + localStorage.setItem(key, JSON.stringify(existing)); + } +} + +export function deleteLabel(walletPubkey: string, stealthAddress: string): void { + localStorage.removeItem(storageKey(walletPubkey, stealthAddress)); +} + +export function getAllTags(walletPubkey: string): string[] { + const labels = getLabels(walletPubkey); + const tagSet = new Set(); + for (const entry of Object.values(labels)) { + for (const tag of entry.tags) { + tagSet.add(tag); + } + } + return Array.from(tagSet).sort(); +} + +export function exportLabels(walletPubkey: string): string { + const labels = getLabels(walletPubkey); + return JSON.stringify(labels, null, 2); +} + +export interface ImportResult { + imported: number; + conflicts: Array<{ + stealthAddress: string; + existingLabel: string; + incomingLabel: string; + }>; +} + +export function importLabels( + walletPubkey: string, + json: string, + overwriteConflicts: boolean = false, +): ImportResult { + const incoming = JSON.parse(json) as Record; + const conflicts: ImportResult['conflicts'] = []; + let imported = 0; + + for (const [addr, entry] of Object.entries(incoming)) { + const key = storageKey(walletPubkey, addr); + const existing = parseEntry(localStorage.getItem(key)); + if (existing && existing.label && existing.label !== entry.label) { + if (overwriteConflicts) { + localStorage.setItem(key, JSON.stringify(entry)); + imported++; + } else { + conflicts.push({ + stealthAddress: addr, + existingLabel: existing.label, + incomingLabel: entry.label, + }); + } + } else { + localStorage.setItem(key, JSON.stringify(entry)); + imported++; + } + } + + return { imported, conflicts }; +} + +export function mergeConflicts( + walletPubkey: string, + resolutions: Record, +): void { + for (const [addr, entry] of Object.entries(resolutions)) { + localStorage.setItem(storageKey(walletPubkey, addr), JSON.stringify(entry)); + } +} + +export function hasShownPrivacyWarning(): boolean { + return localStorage.getItem(PRIVACY_WARNING_KEY) === 'true'; +} + +export function markPrivacyWarningShown(): void { + localStorage.setItem(PRIVACY_WARNING_KEY, 'true'); +} From 97c3eb2b79a620613168d5d1acc189f4367cef13 Mon Sep 17 00:00:00 2001 From: Cristian Chinchilla Valerin Date: Tue, 2 Jun 2026 20:09:13 -0600 Subject: [PATCH 2/2] fix: resolve signStellarTransaction type error and clean unused import - Pass tx.hash() as Uint8Array instead of toString('hex') string to match signStellarTransaction's type signature - Remove unused prefix variable in stealthLabels.ts - Add develop branch to CI workflow triggers --- .github/workflows/ci.yml | 4 ++-- src/components/StellarReceive.tsx | 7 +++---- src/lib/stealthLabels.ts | 1 - 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab26f74..56c7440 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] jobs: build: diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index 4bca35a..53db289 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -21,8 +21,6 @@ import { useStealthKeys } from '@/context/StealthKeysContext'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { StellarMatchCard } from '@/components/StellarMatchCard'; import { StellarReceiveView } from '@/components/StellarReceiveView'; -import { PrivacyTooltip } from '@/components/PrivacyTooltip'; -import { ImportConflictModal } from '@/components/ImportConflictModal'; import { useStealthLabels } from '@/hooks/useStealthLabels'; import { STELLAR_NETWORK } from '@/config'; import { useActivityStore } from '@/stores/activityStore'; @@ -138,15 +136,16 @@ function StellarMatchCardContainer({ .setTimeout(30) .build(); - const txHashHex = tx.hash().toString('hex'); + const txHash = tx.hash(); const signature = signStellarTransaction( - txHashHex, + txHash, match.stealthPrivateScalar, match.stealthPubKeyBytes, ); const signatureBase64 = Buffer.from(signature).toString('base64'); tx.addSignature(match.stealthAddress, signatureBase64); + const txHashHex = Buffer.from(txHash).toString('hex'); const signedXdrStr = encodeURIComponent(tx.toXDR()); addActivity({ id: txHashHex, diff --git a/src/lib/stealthLabels.ts b/src/lib/stealthLabels.ts index efd9087..1b506fc 100644 --- a/src/lib/stealthLabels.ts +++ b/src/lib/stealthLabels.ts @@ -28,7 +28,6 @@ function parseEntry(raw: string | null): StealthLabel | null { export function getLabels(walletPubkey: string): Record { const result: Record = {}; - const prefix = `${walletPubkey}:`; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key || !isLabelKey(key, walletPubkey)) continue;