+ Connect your Stellar wallet to bid on premium Wraith Names.
+
+
+ );
+ }
+
+ return (
+
+
+
+ Stellar / Names / Auctions
+
+
+ Name Auctions
+
+
+ Bid privately during the commit phase, reveal your bid, then claim the name or refund your
+ deposit after the auction. Bid recovery material (salt, amount, deposit) is encrypted with
+ your passphrase and never written to localStorage in plaintext.
+
+
+
+ {/* Recovery vault controls */}
+
+
+
+
+ Recovery vault
+
+
+ [{statusLabel}]
+
+
+ {status === 'unlocked' && (
+
+ )}
+
+
+ {vaultError && (
+
+ {vaultError}
+
+ )}
+
+ {status === 'empty' || status === 'legacy' || status === 'locked' ? (
+
+ This bid sends funds to the Wraith Names contract. Verify the contract and
+ network before approving the transaction.
+
+
+
+ )}
+
+
+ Your amount and recovery secret stay encrypted in this browser until reveal. Clearing
+ site data before revealing can make the bid unrecoverable. Export a backup after
+ setting your passphrase.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/store/nameWatchlistStore.test.ts b/src/store/nameWatchlistStore.test.ts
new file mode 100644
index 0000000..22a5863
--- /dev/null
+++ b/src/store/nameWatchlistStore.test.ts
@@ -0,0 +1,519 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { decryptBids } from '@/lib/auctionEncryption';
+
+class MemoryStorage implements Storage {
+ private values = new Map();
+
+ get length() {
+ return this.values.size;
+ }
+
+ clear() {
+ this.values.clear();
+ }
+
+ getItem(key: string) {
+ return this.values.get(key) ?? null;
+ }
+
+ key(index: number) {
+ return [...this.values.keys()][index] ?? null;
+ }
+
+ removeItem(key: string) {
+ this.values.delete(key);
+ }
+
+ setItem(key: string, value: string) {
+ this.values.set(key, value);
+ }
+}
+
+let storage: MemoryStorage;
+
+beforeEach(() => {
+ vi.resetModules();
+ storage = new MemoryStorage();
+ vi.stubGlobal('localStorage', storage);
+ vi.stubGlobal('window', { localStorage: storage });
+});
+
+async function loadStore() {
+ return await import('./nameWatchlistStore');
+}
+
+const legacyPayload = {
+ version: 0,
+ state: {
+ watchedAuctions: [{ name: 'NOVA', endsAt: 1_800_000_000 }],
+ bids: {
+ nova: {
+ name: 'nova',
+ amountStroops: '25000000',
+ depositStroops: '25000000',
+ saltHex: 'ab'.repeat(32),
+ revealed: false,
+ },
+ },
+ },
+};
+
+const passphrase = 'Passw0rd!Passw0rd!';
+const iterations = 1000;
+
+const newBid = {
+ name: 'nova',
+ amountStroops: '25000000',
+ depositStroops: '25000000',
+ saltHex: 'ab'.repeat(32),
+ revealed: false,
+};
+
+describe('name auction watchlist (encrypted recovery)', () => {
+ it('starts empty when there is no stored vault or legacy data', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ store.inspectStorage();
+ const state = useNameWatchlistStore.getState();
+ expect(state.getStatus()).toBe('empty');
+ expect(state.getBids()).toEqual({});
+ expect(state.hasLegacyPlaintext).toBe(false);
+ expect(state.hasEncryptedVault).toBe(false);
+ });
+
+ it('restores watched auctions without decrypting bids while locked', async () => {
+ const { useNameWatchlistStore: StoreA } = await loadStore();
+ const storeA = StoreA.getState();
+ await storeA.setPassphrase(passphrase, iterations);
+ storeA.watchAuction({ name: 'nova', endsAt: 1_800_000_000 });
+ storeA.lock();
+
+ // Simulate a page reload: fresh module instance, same localStorage.
+ const { useNameWatchlistStore: StoreB } = await loadStore();
+ const storeB = StoreB.getState();
+ storeB.inspectStorage();
+ const state = StoreB.getState();
+ expect(state.getStatus()).toBe('locked');
+ expect(state.getBids()).toEqual({});
+ expect(state.watchedAuctions).toEqual([{ name: 'nova', endsAt: 1_800_000_000 }]);
+ expect(state.hasEncryptedVault).toBe(true);
+ });
+
+ it('detects existing legacy plaintext as legacy status', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ localStorage.setItem('wraith-name-auction-watchlist', JSON.stringify(legacyPayload));
+ store.inspectStorage();
+ const state = useNameWatchlistStore.getState();
+ expect(state.getStatus()).toBe('legacy');
+ expect(state.hasLegacyPlaintext).toBe(true);
+ expect(state.hasEncryptedVault).toBe(false);
+ });
+
+ it('creates an encrypted vault without writing plaintext secrets to localStorage', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ store.inspectStorage();
+ await store.setPassphrase(passphrase, iterations);
+ const state = useNameWatchlistStore.getState();
+ expect(state.getStatus()).toBe('unlocked');
+
+ const raw = localStorage.getItem('wraith-name-auction-watchlist');
+ expect(raw).not.toBeNull();
+ const parsed = JSON.parse(raw!);
+ expect(parsed.version).toBe(1);
+ expect(parsed.encryptedBids).toBeDefined();
+
+ // Plaintext recovery secrets must never appear in localStorage.
+ expect(raw).not.toContain(passphrase);
+ });
+
+ it('persists a new bid only in encrypted form', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ await store.setPassphrase(passphrase, iterations);
+ await store.saveBid(newBid);
+ const state = useNameWatchlistStore.getState();
+ expect(state.getBids().nova?.amountStroops).toBe('25000000');
+ expect(state.getBids().nova?.saltHex).toBe('ab'.repeat(32));
+
+ const raw = localStorage.getItem('wraith-name-auction-watchlist');
+ expect(raw).not.toContain('25000000');
+ expect(raw).not.toContain('abababab');
+ const parsed = JSON.parse(raw!);
+ expect(parsed.encryptedBids).toBeDefined();
+ });
+
+ it('rejects saving a bid while the vault is locked', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ store.inspectStorage();
+ await expect(store.saveBid(newBid)).rejects.toThrow(/locked/i);
+ });
+
+ it('unlocks an existing vault after a simulated reload', async () => {
+ const { useNameWatchlistStore: StoreA } = await loadStore();
+ const storeA = StoreA.getState();
+ await storeA.setPassphrase(passphrase, iterations);
+ await storeA.saveBid(newBid);
+
+ // Simulate a page reload: fresh module instance, same localStorage.
+ const { useNameWatchlistStore: StoreB } = await loadStore();
+ const storeB = StoreB.getState();
+ storeB.inspectStorage();
+ let state = StoreB.getState();
+ expect(state.getStatus()).toBe('locked');
+ expect(state.getBids()).toEqual({});
+
+ await storeB.unlock(passphrase);
+ state = StoreB.getState();
+ expect(state.getStatus()).toBe('unlocked');
+ expect(state.getBids().nova?.amountStroops).toBe('25000000');
+ expect(state.getBids().nova?.saltHex).toBe('ab'.repeat(32));
+ });
+
+ it('rejects an incorrect passphrase on unlock', async () => {
+ const { useNameWatchlistStore: StoreA } = await loadStore();
+ const storeA = StoreA.getState();
+ await storeA.setPassphrase(passphrase, iterations);
+
+ const { useNameWatchlistStore: StoreB } = await loadStore();
+ const storeB = StoreB.getState();
+ storeB.inspectStorage();
+ await expect(storeB.unlock('wrong-passphrase!')).rejects.toThrow();
+ const state = StoreB.getState();
+ expect(state.getStatus()).toBe('error');
+ expect(state.getBids()).toEqual({});
+ });
+
+ it('migrates legacy plaintext bids into an encrypted vault', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ localStorage.setItem('wraith-name-auction-watchlist', JSON.stringify(legacyPayload));
+ store.inspectStorage();
+ let state = useNameWatchlistStore.getState();
+ expect(state.getStatus()).toBe('legacy');
+
+ await store.setPassphrase(passphrase, iterations);
+ state = useNameWatchlistStore.getState();
+ expect(state.getStatus()).toBe('unlocked');
+ expect(state.getBids().nova?.amountStroops).toBe('25000000');
+ expect(state.getBids().nova?.saltHex).toBe('ab'.repeat(32));
+
+ // The encrypted replacement exists in the active record.
+ expect(state.hasEncryptedVault).toBe(true);
+
+ // Legacy plaintext remains in the canonical store until explicitly cleared.
+ const raw = localStorage.getItem('wraith-name-auction-watchlist');
+ expect(raw).toContain('"version":0');
+ expect(raw).toContain('25000000');
+ });
+
+ it('migration keeps legacy plaintext until explicitly cleared', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ localStorage.setItem('wraith-name-auction-watchlist', JSON.stringify(legacyPayload));
+ store.inspectStorage();
+ await store.setPassphrase(passphrase, iterations);
+
+ // Legacy plaintext still present in localStorage (not yet cleared).
+ const raw = localStorage.getItem('wraith-name-auction-watchlist');
+ expect(raw).toContain('"version":0');
+
+ // Clear is an explicit user action; it requires a verified encrypted replacement.
+ const cleared = await store.clearLegacy();
+ expect(cleared).toBe(true);
+
+ const state = useNameWatchlistStore.getState();
+ expect(state.hasLegacyPlaintext).toBe(false);
+
+ const after = localStorage.getItem('wraith-name-auction-watchlist');
+ const parsed = JSON.parse(after!);
+ expect(parsed.version).toBe(1);
+ expect(parsed.encryptedBids).toBeDefined();
+ expect(after).not.toContain('"version":0');
+ expect(after).not.toContain('25000000');
+ });
+
+ it('refuses to clear legacy plaintext without a verified encrypted replacement', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ localStorage.setItem('wraith-name-auction-watchlist', JSON.stringify(legacyPayload));
+ store.inspectStorage();
+
+ await expect(store.clearLegacy()).rejects.toThrow(/encrypted replacement/i);
+ // Original legacy data is untouched.
+ const raw = localStorage.getItem('wraith-name-auction-watchlist');
+ expect(raw).toBe(JSON.stringify(legacyPayload));
+ });
+
+ it('clearing legacy is idempotent and safe to repeat', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ localStorage.setItem('wraith-name-auction-watchlist', JSON.stringify(legacyPayload));
+ store.inspectStorage();
+ await store.setPassphrase(passphrase, iterations);
+ await store.clearLegacy();
+
+ // Second attempt: nothing legacy left, safe no-op.
+ const again = await store.clearLegacy();
+ expect(again).toBe(false);
+ });
+
+ it('migration failure leaves the original plaintext intact', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ localStorage.setItem('wraith-name-auction-watchlist', JSON.stringify(legacyPayload));
+ store.inspectStorage();
+
+ // Passphrase too short -> setup fails before touching storage.
+ await expect(store.setPassphrase('short', iterations)).rejects.toThrow(/at least 8/);
+ const raw = localStorage.getItem('wraith-name-auction-watchlist');
+ expect(raw).toBe(JSON.stringify(legacyPayload));
+ });
+
+ it('exports a documented, versioned backup and recovers from it', async () => {
+ const { useNameWatchlistStore: StoreA } = await loadStore();
+ const storeA = StoreA.getState();
+ await storeA.setPassphrase(passphrase, iterations);
+ await storeA.saveBid(newBid);
+
+ const backup = await storeA.exportBackup();
+ const parsed = JSON.parse(backup);
+ expect(parsed.type).toBe('wraith-name-auction-backup');
+ expect(parsed.version).toBe(1);
+ expect(parsed.vault.version).toBe(1);
+
+ // Fresh session: import the backup and recover into an empty vault.
+ const { useNameWatchlistStore: StoreB } = await loadStore();
+ const storeB = StoreB.getState();
+ localStorage.clear();
+ await storeB.importBackup(backup, passphrase);
+ const state = StoreB.getState();
+ expect(state.getStatus()).toBe('unlocked');
+ expect(state.getBids().nova?.amountStroops).toBe('25000000');
+ expect(state.getBids().nova?.saltHex).toBe('ab'.repeat(32));
+ });
+
+ it('rejects backup import with an incorrect passphrase', async () => {
+ const { useNameWatchlistStore: StoreA } = await loadStore();
+ const storeA = StoreA.getState();
+ await storeA.setPassphrase(passphrase, iterations);
+ const backup = await storeA.exportBackup();
+
+ const { useNameWatchlistStore: StoreB } = await loadStore();
+ const storeB = StoreB.getState();
+ await expect(storeB.importBackup(backup, 'wrong-passphrase!')).rejects.toThrow();
+ const state = StoreB.getState();
+ expect(state.getStatus()).toBe('error');
+ });
+
+ it('rejects import of a corrupted backup', async () => {
+ const { useNameWatchlistStore: StoreA } = await loadStore();
+ const storeA = StoreA.getState();
+ await storeA.setPassphrase(passphrase, iterations);
+ const backup = JSON.parse(await storeA.exportBackup());
+ backup.vault.data = 'AAAA'; // corrupted
+ const corrupted = JSON.stringify(backup);
+
+ const { useNameWatchlistStore: StoreB } = await loadStore();
+ const storeB = StoreB.getState();
+ await expect(storeB.importBackup(corrupted, passphrase)).rejects.toThrow();
+ });
+
+ it('lock clears decrypted bids from memory', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ await store.setPassphrase(passphrase, iterations);
+ await store.saveBid(newBid);
+ let state = useNameWatchlistStore.getState();
+ expect(state.getBids().nova).toBeDefined();
+
+ store.lock();
+ state = useNameWatchlistStore.getState();
+ expect(state.getStatus()).toBe('locked');
+ expect(state.getBids()).toEqual({});
+ });
+
+ it('marks a bid revealed and persists the change encrypted', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ await store.setPassphrase(passphrase, iterations);
+ await store.saveBid(newBid);
+ expect(useNameWatchlistStore.getState().getBids().nova?.revealed).toBe(false);
+
+ await store.markBidRevealed('nova');
+ expect(useNameWatchlistStore.getState().getBids().nova?.revealed).toBe(true);
+ const raw = localStorage.getItem('wraith-name-auction-watchlist');
+ expect(raw).not.toContain('25000000');
+ });
+
+ it('removes a bid and persists the change encrypted', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ await store.setPassphrase(passphrase, iterations);
+ await store.saveBid(newBid);
+ await store.removeBid('nova');
+ expect(useNameWatchlistStore.getState().getBids().nova).toBeUndefined();
+ });
+
+ it('watch and unwatch track auctions without exposing bid secrets', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ store.inspectStorage();
+ await store.setPassphrase(passphrase, iterations);
+
+ store.watchAuction({ name: 'nova', endsAt: 1_800_000_000 });
+ expect(useNameWatchlistStore.getState().getBids()).toEqual({});
+ const raw = localStorage.getItem('wraith-name-auction-watchlist');
+ expect(raw).not.toContain('25000000');
+
+ store.unwatchAuction('nova');
+ });
+});
+
+describe('import backup safety (no silent vault destruction)', () => {
+ const currentBid = {
+ name: 'alpha',
+ amountStroops: '10000000',
+ depositStroops: '10000000',
+ saltHex: 'cd'.repeat(32),
+ revealed: false,
+ };
+ const importedBid = {
+ name: 'beta',
+ amountStroops: '20000000',
+ depositStroops: '20000000',
+ saltHex: 'ef'.repeat(32),
+ revealed: false,
+ };
+
+ async function exportImportedBackup(): Promise {
+ const { useNameWatchlistStore: StoreC } = await loadStore();
+ const storeC = StoreC.getState();
+ await storeC.setPassphrase(passphrase, iterations);
+ await storeC.saveBid(importedBid);
+ return await storeC.exportBackup();
+ }
+
+ async function establishCurrentVault(): Promise {
+ // Start from a clean slate: the exported backup may have left a vault in
+ // shared localStorage, which setPassphrase would otherwise reject.
+ localStorage.clear();
+ const { useNameWatchlistStore: StoreA } = await loadStore();
+ const storeA = StoreA.getState();
+ await storeA.setPassphrase(passphrase, iterations);
+ await storeA.saveBid(currentBid);
+ }
+
+ it('refuses to replace a non-empty current vault without explicit confirmation', async () => {
+ const backup = await exportImportedBackup();
+ await establishCurrentVault();
+
+ // Fresh session (simulated reload): current vault still holds currentBid.
+ const { useNameWatchlistStore: StoreB } = await loadStore();
+ const storeB = StoreB.getState();
+ storeB.inspectStorage();
+ expect(storeB.getStatus()).toBe('locked');
+
+ await expect(storeB.importBackup(backup, passphrase)).rejects.toThrow(
+ /current vault|replacement/i,
+ );
+
+ // Current vault untouched: currentBid still recoverable after unlock.
+ await storeB.unlock(passphrase);
+ expect(storeB.getBids().alpha?.saltHex).toBe(currentBid.saltHex);
+ expect(storeB.getBids().beta).toBeUndefined();
+ });
+
+ it('replaces the current vault only when replacement is explicitly confirmed', async () => {
+ const backup = await exportImportedBackup();
+ await establishCurrentVault();
+
+ const { useNameWatchlistStore: StoreA } = await loadStore();
+ const storeA = StoreA.getState();
+ storeA.inspectStorage();
+ expect(storeA.getStatus()).toBe('locked');
+
+ await storeA.importBackup(backup, passphrase, { replace: true });
+ const state = StoreA.getState();
+ expect(state.getStatus()).toBe('unlocked');
+ expect(state.getBids().beta?.saltHex).toBe(importedBid.saltHex);
+ expect(state.getBids().alpha).toBeUndefined();
+
+ // The replacement is durable across a simulated reload.
+ const { useNameWatchlistStore: StoreB } = await loadStore();
+ const storeB = StoreB.getState();
+ storeB.inspectStorage();
+ await storeB.unlock(passphrase);
+ expect(storeB.getBids().beta?.amountStroops).toBe('20000000');
+ expect(storeB.getBids().alpha).toBeUndefined();
+ });
+
+ it('preserves the current vault when import uses the wrong passphrase', async () => {
+ const backup = await exportImportedBackup();
+ await establishCurrentVault();
+
+ const { useNameWatchlistStore: StoreA } = await loadStore();
+ const storeA = StoreA.getState();
+ storeA.inspectStorage();
+
+ await expect(
+ storeA.importBackup(backup, 'wrong-passphrase!', { replace: true }),
+ ).rejects.toThrow();
+ // Current vault preserved: unlock with the original passphrase still recovers currentBid.
+ await storeA.unlock(passphrase);
+ expect(storeA.getBids().alpha?.saltHex).toBe(currentBid.saltHex);
+ expect(storeA.getBids().beta).toBeUndefined();
+ });
+
+ it('preserves the current vault when importing a corrupted backup', async () => {
+ const parsed = JSON.parse(await exportImportedBackup());
+ parsed.vault.data = 'AAAA'; // corrupted ciphertext
+ const corrupted = JSON.stringify(parsed);
+ await establishCurrentVault();
+
+ const { useNameWatchlistStore: StoreA } = await loadStore();
+ const storeA = StoreA.getState();
+ storeA.inspectStorage();
+
+ await expect(storeA.importBackup(corrupted, passphrase, { replace: true })).rejects.toThrow();
+ // Current vault preserved.
+ await storeA.unlock(passphrase);
+ expect(storeA.getBids().alpha?.saltHex).toBe(currentBid.saltHex);
+ expect(storeA.getBids().beta).toBeUndefined();
+ });
+});
+
+describe('saveBid read-back verification (submission gate)', () => {
+ const gateBid = {
+ name: 'gate',
+ amountStroops: '7000000',
+ depositStroops: '7000000',
+ saltHex: '01'.repeat(32),
+ revealed: false,
+ };
+
+ it('persists a decryptable, verified recovery record before it can be trusted for submission', async () => {
+ const { useNameWatchlistStore } = await loadStore();
+ const store = useNameWatchlistStore.getState();
+ await store.setPassphrase(passphrase, iterations);
+ await store.saveBid(gateBid);
+
+ // The persisted vault must be decryptable and contain the exact recovery
+ // material (salt + amount) — this is the verification gate that must pass
+ // before an on-chain commit is ever submitted.
+ const raw = localStorage.getItem('wraith-name-auction-watchlist');
+ expect(raw).not.toBeNull();
+ const parsed = JSON.parse(raw!);
+ expect(parsed.encryptedBids).toBeDefined();
+ // Plaintext recovery secrets must never appear in localStorage.
+ expect(raw).not.toContain(gateBid.saltHex);
+ expect(raw).not.toContain(gateBid.amountStroops);
+
+ const decrypted = await decryptBids(passphrase, parsed.encryptedBids);
+ expect(decrypted.gate?.saltHex).toBe(gateBid.saltHex);
+ expect(decrypted.gate?.amountStroops).toBe(gateBid.amountStroops);
+ expect(decrypted.gate?.revealed).toBe(false);
+ });
+});
diff --git a/src/store/nameWatchlistStore.tsx b/src/store/nameWatchlistStore.tsx
new file mode 100644
index 0000000..5f73586
--- /dev/null
+++ b/src/store/nameWatchlistStore.tsx
@@ -0,0 +1,533 @@
+import { create } from 'zustand';
+
+import type { AuctionBid } from '@/lib/auctionEncryption';
+import {
+ AUCTION_STORAGE_KEY,
+ AUCTION_VAULT_VERSION,
+ DEFAULT_PBKDF2_ITERATIONS,
+ createBackup,
+ decryptBids,
+ deriveKey,
+ encryptBids,
+ isValidVault,
+ parseBackup,
+ type EncryptedBidVault,
+} from '@/lib/auctionEncryption';
+
+/**
+ * Staging/active storage key for the encrypted bid recovery vault while legacy
+ * plaintext has not yet been explicitly cleared. While legacy plaintext is
+ * present, the canonical key (`wraith-name-auction-watchlist`) holds the
+ * legacy v0 payload untouched and the active record (watched auctions +
+ * encrypted bids) is staged under this vault key. Once the user explicitly
+ * clears the legacy plaintext, the active record is written to the canonical
+ * key and this vault key is removed.
+ */
+export const VAULT_STORAGE_KEY = `${AUCTION_STORAGE_KEY}.vault`;
+
+export type { AuctionBid };
+
+export interface WatchedNameAuction {
+ name: string;
+ endsAt: number;
+}
+
+export type AuctionStatus = 'empty' | 'legacy' | 'locked' | 'unlocked' | 'error';
+
+interface ActiveRecord {
+ watchedAuctions: WatchedNameAuction[];
+ encryptedBids: EncryptedBidVault | null;
+}
+
+interface NameWatchlistState {
+ watchedAuctions: WatchedNameAuction[];
+ bids: Record;
+ status: AuctionStatus;
+ error: string | null;
+ hasLegacyPlaintext: boolean;
+ hasEncryptedVault: boolean;
+ inspectStorage: () => void;
+ setPassphrase: (passphrase: string, iterations?: number) => Promise;
+ unlock: (passphrase: string) => Promise;
+ lock: () => void;
+ watchAuction: (auction: WatchedNameAuction) => void;
+ unwatchAuction: (name: string) => void;
+ saveBid: (bid: AuctionBid) => Promise;
+ markBidRevealed: (name: string) => Promise;
+ removeBid: (name: string) => Promise;
+ exportBackup: () => Promise;
+ importBackup: (
+ backup: string,
+ passphrase: string,
+ options?: { replace?: boolean },
+ ) => Promise;
+ clearLegacy: () => Promise;
+ getBids: () => Record;
+ getStatus: () => AuctionStatus;
+}
+
+const normalizeName = (name: string) => name.trim().toLowerCase();
+
+/**
+ * In-memory derived encryption key, held only while the vault is unlocked.
+ * Cleared on lock, page reload, or logout. Never persisted to localStorage.
+ */
+let activeKey: CryptoKey | null = null;
+let activeKdfMeta: { salt: string; iterations: number } | null = null;
+
+function clearActiveKey() {
+ activeKey = null;
+ activeKdfMeta = null;
+}
+
+function readRaw(key: string): T | null {
+ try {
+ const raw = localStorage.getItem(key);
+ if (raw === null) return null;
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+}
+
+function writeRaw(key: string, value: unknown): void {
+ localStorage.setItem(key, JSON.stringify(value));
+}
+
+function removeRaw(key: string): void {
+ localStorage.removeItem(key);
+}
+
+/** Legacy zustand-persist format: { version: 0, state: { watchedAuctions, bids } } */
+function readLegacy(): {
+ bids: Record;
+ watchedAuctions: WatchedNameAuction[];
+} | null {
+ const raw = readRaw<{
+ version: number;
+ state: { watchedAuctions?: unknown; bids?: unknown };
+ }>(AUCTION_STORAGE_KEY);
+ if (!raw || raw.version !== 0 || !raw.state) return null;
+ const watchedAuctions = Array.isArray(raw.state.watchedAuctions) ? raw.state.watchedAuctions : [];
+ const bids = raw.state.bids;
+ if (!bids || typeof bids !== 'object' || Array.isArray(bids)) {
+ return { bids: {}, watchedAuctions };
+ }
+ return { bids: bids as Record, watchedAuctions };
+}
+
+/** New v1 format: { version: 1, watchedAuctions, encryptedBids: EncryptedBidVault | null } */
+function readCanonicalV1(): ActiveRecord | null {
+ const raw = readRaw<{
+ version: number;
+ watchedAuctions?: unknown;
+ encryptedBids?: unknown;
+ }>(AUCTION_STORAGE_KEY);
+ if (!raw || raw.version !== AUCTION_VAULT_VERSION) return null;
+ const watchedAuctions = Array.isArray(raw.watchedAuctions) ? raw.watchedAuctions : [];
+ const encryptedBids = isValidVault(raw.encryptedBids) ? raw.encryptedBids : null;
+ return { watchedAuctions, encryptedBids };
+}
+
+/** Active record staged under the vault key (pre-clear migration). */
+function readVaultKeyRecord(): ActiveRecord | null {
+ const raw = readRaw(VAULT_STORAGE_KEY);
+ if (!raw) return null;
+ let vault: EncryptedBidVault | null = null;
+ let watchedAuctions: unknown = null;
+ try {
+ const parsed = parseBackup(raw);
+ vault = parsed.vault;
+ } catch {
+ if (isValidVault(raw)) {
+ vault = raw as EncryptedBidVault;
+ } else if (raw && typeof raw === 'object' && 'encryptedBids' in raw) {
+ const rec = raw as { watchedAuctions?: unknown; encryptedBids?: unknown };
+ vault = isValidVault(rec.encryptedBids) ? rec.encryptedBids : null;
+ watchedAuctions = rec.watchedAuctions;
+ }
+ }
+ if (!vault) return null;
+ return {
+ watchedAuctions: Array.isArray(watchedAuctions) ? watchedAuctions : [],
+ encryptedBids: vault,
+ };
+}
+
+/** The currently active record: vault key (pre-clear) else canonical v1 else legacy. */
+function readActive(): (ActiveRecord & { isLegacy: boolean }) | null {
+ const vault = readVaultKeyRecord();
+ if (vault) return { ...vault, isLegacy: false };
+ const canonical = readCanonicalV1();
+ if (canonical) return { ...canonical, isLegacy: false };
+ const legacy = readLegacy();
+ if (legacy)
+ return { watchedAuctions: legacy.watchedAuctions, encryptedBids: null, isLegacy: true };
+ return null;
+}
+
+function activeEncryptedVault(): EncryptedBidVault | null {
+ const active = readActive();
+ return active?.encryptedBids ?? null;
+}
+
+function writeActive(record: ActiveRecord): void {
+ // While legacy plaintext exists, stage under the vault key and leave the
+ // canonical legacy untouched until explicitly cleared.
+ if (readLegacy()) {
+ writeRaw(VAULT_STORAGE_KEY, record);
+ } else {
+ writeRaw(AUCTION_STORAGE_KEY, { version: AUCTION_VAULT_VERSION, ...record });
+ }
+}
+
+function toBase64(bytes: Uint8Array): string {
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
+ if (typeof btoa === 'function') {
+ return btoa(binary);
+ }
+ return Buffer.from(binary, 'binary').toString('base64');
+}
+
+function fromBase64(input: string): Uint8Array {
+ if (typeof atob === 'function') {
+ const binary = atob(input);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+ }
+ return Buffer.from(input, 'base64') as unknown as Uint8Array;
+}
+
+/** Re-encrypt bids using the in-memory derived key with a fresh IV every time. */
+async function sealBids(bids: Record): Promise {
+ if (!activeKey || !activeKdfMeta) {
+ throw new Error('Vault is not unlocked. Set a passphrase before saving bids.');
+ }
+ const subtle = globalThis.crypto.subtle;
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
+ const ciphertext = await subtle.encrypt(
+ { name: 'AES-GCM', iv: iv as unknown as BufferSource },
+ activeKey,
+ new TextEncoder().encode(JSON.stringify(bids)),
+ );
+ return {
+ version: AUCTION_VAULT_VERSION,
+ kdf: {
+ algorithm: 'PBKDF2',
+ hash: 'SHA-256',
+ iterations: activeKdfMeta.iterations,
+ salt: activeKdfMeta.salt,
+ },
+ cipher: { algorithm: 'AES-GCM', iv: toBase64(iv), keyLength: 256 },
+ data: toBase64(new Uint8Array(ciphertext)),
+ };
+}
+
+async function persistEncrypted(
+ watchedAuctions: WatchedNameAuction[],
+ bids: Record,
+): Promise {
+ const vault = await sealBids(bids);
+ writeActive({ watchedAuctions, encryptedBids: vault });
+}
+
+/** Decrypt a stored vault using the in-memory derived key (vault must be unlocked). */
+async function decryptVaultWithActiveKey(
+ vault: EncryptedBidVault,
+): Promise> {
+ if (!activeKey) {
+ throw new Error('Vault is not unlocked.');
+ }
+ const subtle = globalThis.crypto.subtle;
+ const iv = fromBase64(vault.cipher.iv);
+ const ciphertext = new Uint8Array(fromBase64(vault.data));
+ const decrypted = await subtle.decrypt(
+ { name: 'AES-GCM', iv: iv as unknown as BufferSource },
+ activeKey,
+ ciphertext,
+ );
+ const plaintext = new TextDecoder().decode(decrypted);
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(plaintext);
+ } catch {
+ throw new Error('Persisted recovery record is not valid JSON.');
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('Persisted recovery record is not a bids record.');
+ }
+ return parsed as Record;
+}
+
+/** Read back the persisted vault and verify every expected bid is durably stored. */
+async function verifyStoredBids(expected: Record): Promise {
+ if (!activeKey) {
+ throw new Error('Vault is not unlocked; cannot verify persisted recovery record.');
+ }
+ const active = readActive();
+ const vault = active?.encryptedBids ?? null;
+ if (!vault) {
+ throw new Error('No encrypted vault found after persisting bid.');
+ }
+ const decrypted = await decryptVaultWithActiveKey(vault);
+ for (const [name, bid] of Object.entries(expected)) {
+ const stored = decrypted[name];
+ if (!stored || JSON.stringify(stored) !== JSON.stringify(bid)) {
+ throw new Error(`Persisted recovery record verification failed for ${name}.`);
+ }
+ }
+}
+
+export const useNameWatchlistStore = create((set, get) => ({
+ watchedAuctions: [],
+ bids: {},
+ status: 'empty',
+ error: null,
+ hasLegacyPlaintext: false,
+ hasEncryptedVault: false,
+
+ inspectStorage() {
+ const active = readActive();
+ const hasLegacy = Boolean(active?.isLegacy);
+ const hasEncrypted = Boolean(active?.encryptedBids);
+ set({
+ hasLegacyPlaintext: hasLegacy,
+ hasEncryptedVault: hasEncrypted,
+ watchedAuctions: active?.watchedAuctions ?? [],
+ bids: {},
+ status: hasLegacy ? 'legacy' : hasEncrypted ? 'locked' : 'empty',
+ error: null,
+ });
+ },
+
+ async setPassphrase(passphrase: string, iterations: number = DEFAULT_PBKDF2_ITERATIONS) {
+ if (!passphrase || passphrase.length < 8) {
+ throw new Error('Passphrase must be at least 8 characters.');
+ }
+ set({ error: null });
+ clearActiveKey();
+ try {
+ const legacy = readLegacy();
+ const existingVault = activeEncryptedVault();
+
+ if (existingVault && !legacy) {
+ throw new Error('An encrypted vault already exists. Use Unlock instead.');
+ }
+
+ let bidsToEncrypt: Record;
+ let watchedAuctions = get().watchedAuctions;
+
+ if (legacy) {
+ // Migration: encrypt the legacy plaintext bids.
+ bidsToEncrypt = legacy.bids;
+ watchedAuctions = legacy.watchedAuctions;
+ } else {
+ bidsToEncrypt = {};
+ }
+
+ const vault = await encryptBids(passphrase, bidsToEncrypt, iterations);
+ // Verify the replacement can be decrypted back before trusting it.
+ const verified = await decryptBids(passphrase, vault);
+ if (JSON.stringify(verified) !== JSON.stringify(bidsToEncrypt)) {
+ throw new Error('Encrypted replacement failed verification.');
+ }
+
+ const salt = fromBase64(vault.kdf.salt);
+ activeKey = await deriveKey(passphrase, salt, vault.kdf.iterations);
+ activeKdfMeta = { salt: vault.kdf.salt, iterations: vault.kdf.iterations };
+
+ writeActive({ watchedAuctions, encryptedBids: vault });
+
+ set({
+ bids: bidsToEncrypt,
+ watchedAuctions,
+ status: 'unlocked',
+ error: null,
+ hasLegacyPlaintext: Boolean(legacy),
+ hasEncryptedVault: true,
+ });
+ } catch (err) {
+ clearActiveKey();
+ const message = err instanceof Error ? err.message : 'Failed to set up encryption.';
+ set({ status: 'error', error: message });
+ throw err;
+ }
+ },
+
+ async unlock(passphrase) {
+ set({ error: null });
+ clearActiveKey();
+ try {
+ const vault = activeEncryptedVault();
+ if (!vault) {
+ throw new Error('No encrypted vault to unlock.');
+ }
+ const decrypted = await decryptBids(passphrase, vault);
+ const salt = fromBase64(vault.kdf.salt);
+ activeKey = await deriveKey(passphrase, salt, vault.kdf.iterations);
+ activeKdfMeta = { salt: vault.kdf.salt, iterations: vault.kdf.iterations };
+ set({
+ bids: decrypted,
+ watchedAuctions: readActive()?.watchedAuctions ?? [],
+ status: 'unlocked',
+ error: null,
+ hasEncryptedVault: true,
+ });
+ } catch (err) {
+ clearActiveKey();
+ const message = err instanceof Error ? err.message : 'Unable to unlock vault.';
+ set({ status: 'error', error: message });
+ throw err;
+ }
+ },
+
+ lock() {
+ clearActiveKey();
+ set({ bids: {}, status: 'locked', error: null });
+ },
+
+ watchAuction(auction) {
+ const name = normalizeName(auction.name);
+ const watched = [
+ ...get().watchedAuctions.filter((item) => item.name !== name),
+ { ...auction, name },
+ ];
+ const active = readActive();
+ writeActive({ watchedAuctions: watched, encryptedBids: active?.encryptedBids ?? null });
+ set({ watchedAuctions: watched });
+ },
+
+ unwatchAuction(name) {
+ const watched = get().watchedAuctions.filter((item) => item.name !== normalizeName(name));
+ const active = readActive();
+ writeActive({ watchedAuctions: watched, encryptedBids: active?.encryptedBids ?? null });
+ set({ watchedAuctions: watched });
+ },
+
+ async saveBid(bid) {
+ if (get().status !== 'unlocked') {
+ throw new Error('Vault is locked. Unlock with your passphrase before saving a bid.');
+ }
+ const name = normalizeName(bid.name);
+ const bids = { ...get().bids, [name]: { ...bid, name } };
+ const watched = [...get().watchedAuctions];
+ if (!watched.some((item) => item.name === name)) {
+ watched.push({ name, endsAt: 0 });
+ }
+ await persistEncrypted(watched, bids);
+ // Read back and verify the recovery record is durably stored and decryptable
+ // before it is trusted for on-chain submission.
+ await verifyStoredBids(bids);
+ set({ bids, watchedAuctions: watched });
+ },
+
+ async markBidRevealed(name) {
+ if (get().status !== 'unlocked') {
+ throw new Error('Vault is locked. Unlock with your passphrase before revealing a bid.');
+ }
+ const key = normalizeName(name);
+ const bid = get().bids[key];
+ if (!bid) return;
+ const bids = { ...get().bids, [key]: { ...bid, revealed: true } };
+ await persistEncrypted(get().watchedAuctions, bids);
+ set({ bids });
+ },
+
+ async removeBid(name) {
+ if (get().status !== 'unlocked') {
+ throw new Error('Vault is locked. Unlock with your passphrase before removing a bid.');
+ }
+ const key = normalizeName(name);
+ const bids = { ...get().bids };
+ delete bids[key];
+ await persistEncrypted(get().watchedAuctions, bids);
+ set({ bids });
+ },
+
+ async exportBackup() {
+ const vault = activeEncryptedVault();
+ if (!vault) {
+ throw new Error('No encrypted vault to back up.');
+ }
+ return JSON.stringify(createBackup(vault));
+ },
+
+ async importBackup(backup, passphrase, { replace = false } = {}) {
+ if (!passphrase || passphrase.length < 8) {
+ throw new Error('Passphrase must be at least 8 characters.');
+ }
+ set({ error: null });
+
+ // Refuse to replace a non-empty current vault without explicit confirmation.
+ // This check runs before any decryption or key derivation so the current
+ // vault and its in-memory key are preserved on refusal.
+ const currentVault = activeEncryptedVault();
+ if (currentVault && !replace) {
+ const message =
+ 'Current vault already contains bid recovery secrets. Export your current vault first, or confirm replacement.';
+ set({ status: 'error', error: message });
+ throw new Error(message);
+ }
+
+ clearActiveKey();
+ try {
+ const input: unknown = typeof backup === 'string' ? JSON.parse(backup) : backup;
+ const parsed = parseBackup(input);
+ // Decrypt the imported backup to validate the passphrase and ciphertext and
+ // inspect the recovered bids. This does not touch the current vault.
+ const decrypted = await decryptBids(passphrase, parsed.vault);
+ const salt = fromBase64(parsed.vault.kdf.salt);
+ activeKey = await deriveKey(passphrase, salt, parsed.vault.kdf.iterations);
+ activeKdfMeta = { salt: parsed.vault.kdf.salt, iterations: parsed.vault.kdf.iterations };
+ // Only now, after successful validation, overwrite the active record.
+ writeActive({ watchedAuctions: [], encryptedBids: parsed.vault });
+ set({
+ bids: decrypted,
+ watchedAuctions: [],
+ status: 'unlocked',
+ error: null,
+ hasEncryptedVault: true,
+ hasLegacyPlaintext: Boolean(readLegacy()),
+ });
+ } catch (err) {
+ clearActiveKey();
+ const message = err instanceof Error ? err.message : 'Failed to import backup.';
+ set({ status: 'error', error: message });
+ throw err;
+ }
+ },
+
+ async clearLegacy() {
+ const legacy = readLegacy();
+ if (!legacy) {
+ // Nothing legacy to clear. Safe no-op for repeated attempts.
+ return false;
+ }
+ const vault = activeEncryptedVault();
+ if (!vault) {
+ throw new Error(
+ 'Cannot clear legacy plaintext: no verified encrypted replacement exists. Back up and migrate first.',
+ );
+ }
+ const active = readActive();
+ // Replace the canonical legacy plaintext with the verified encrypted vault.
+ writeRaw(AUCTION_STORAGE_KEY, {
+ version: AUCTION_VAULT_VERSION,
+ watchedAuctions: active?.watchedAuctions ?? legacy.watchedAuctions,
+ encryptedBids: vault,
+ });
+ removeRaw(VAULT_STORAGE_KEY);
+ set({ hasLegacyPlaintext: false, hasEncryptedVault: true });
+ return true;
+ },
+
+ getBids() {
+ return get().bids;
+ },
+
+ getStatus() {
+ return get().status;
+ },
+}));