diff --git a/src/lib/auctionEncryption.test.ts b/src/lib/auctionEncryption.test.ts new file mode 100644 index 0000000..f780e62 --- /dev/null +++ b/src/lib/auctionEncryption.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + AUCTION_VAULT_VERSION, + decrypt, + decryptBids, + encrypt, + encryptBids, + isValidVault, + UnsupportedVersionError, + validateVault, + type EncryptedBidVault, +} from '@/lib/auctionEncryption'; + +const PASSPHRASE = 'correct-horse-battery-staple'; +const WRONG_PASSPHRASE = 'wrong-passphrase'; +const ITERATIONS = 1000; + +const bids: Record< + string, + { + name: string; + amountStroops: string; + depositStroops: string; + saltHex: string; + revealed: boolean; + } +> = { + nova: { + name: 'nova', + amountStroops: '25000000', + depositStroops: '25000000', + saltHex: 'ab'.repeat(32), + revealed: false, + }, +}; + +function tamperBase64(vault: EncryptedBidVault, index: number): EncryptedBidVault { + const data = Buffer.from(vault.data, 'base64'); + data[index] ^= 0xff; + return { ...vault, data: data.toString('base64') }; +} + +describe('auction encryption', () => { + it('encrypts and decrypts bid recovery material', async () => { + const vault = await encryptBids(PASSPHRASE, bids, ITERATIONS); + expect(vault.version).toBe(AUCTION_VAULT_VERSION); + expect(vault.kdf.iterations).toBe(ITERATIONS); + expect(typeof vault.kdf.salt).toBe('string'); + expect(typeof vault.cipher.iv).toBe('string'); + expect(typeof vault.data).toBe('string'); + + const decrypted = await decryptBids(PASSPHRASE, vault); + expect(decrypted).toEqual(bids); + }); + + it('uses a fresh random salt and IV on every encryption', async () => { + const a = await encryptBids(PASSPHRASE, bids, ITERATIONS); + const b = await encryptBids(PASSPHRASE, bids, ITERATIONS); + expect(a.kdf.salt).not.toBe(b.kdf.salt); + expect(a.cipher.iv).not.toBe(b.cipher.iv); + expect(a.data).not.toBe(b.data); + }); + + it('rejects an incorrect passphrase', async () => { + const vault = await encryptBids(PASSPHRASE, bids, ITERATIONS); + await expect(decryptBids(WRONG_PASSPHRASE, vault)).rejects.toThrow(); + }); + + it('detects corrupted ciphertext (authentication failure)', async () => { + const vault = await encryptBids(PASSPHRASE, bids, ITERATIONS); + const corrupted = tamperBase64(vault, 0); + await expect(decryptBids(PASSPHRASE, corrupted)).rejects.toThrow(); + }); + + it('detects corrupted authentication tag', async () => { + const vault = await encryptBids(PASSPHRASE, bids, ITERATIONS); + const data = Buffer.from(vault.data, 'base64'); + // Flip a byte in the final 16-byte GCM tag. + data[data.length - 1] ^= 0xff; + const tampered = { ...vault, data: data.toString('base64') }; + await expect(decryptBids(PASSPHRASE, tampered)).rejects.toThrow(); + }); + + it('rejects unsupported payload versions', async () => { + const vault = await encryptBids(PASSPHRASE, bids, ITERATIONS); + const bad = { ...vault, version: 99 }; + expect(() => validateVault(bad)).toThrow(UnsupportedVersionError); + expect(isValidVault(bad)).toBe(false); + }); + + it('rejects invalid vault structures before decryption', async () => { + expect(() => validateVault(null)).toThrow(); + expect(() => validateVault({})).toThrow(); + expect(() => validateVault({ version: 1 })).toThrow(); + expect(isValidVault({ version: 1 })).toBe(false); + expect(isValidVault(null)).toBe(false); + expect(isValidVault(undefined)).toBe(false); + }); + + it('rejects a missing or short authentication tag', async () => { + const vault = await encryptBids(PASSPHRASE, bids, ITERATIONS); + const noTag = { ...vault, data: Buffer.from('aa', 'hex').toString('base64') }; + expect(() => validateVault(noTag)).toThrow(); + }); + + it('never exposes the passphrase or plaintext in the vault payload', async () => { + const vault = await encryptBids(PASSPHRASE, bids, ITERATIONS); + const serialized = JSON.stringify(vault); + expect(serialized).not.toContain(PASSPHRASE); + expect(serialized).not.toContain(bids.nova.amountStroops); + expect(serialized).not.toContain(bids.nova.saltHex); + expect(serialized).not.toContain(bids.nova.depositStroops); + }); +}); diff --git a/src/lib/auctionEncryption.ts b/src/lib/auctionEncryption.ts new file mode 100644 index 0000000..e83e268 --- /dev/null +++ b/src/lib/auctionEncryption.ts @@ -0,0 +1,401 @@ +/** + * Encrypted storage format for Wraith Name auction recovery material. + * + * The auction watchlist persists sealed-bid recovery secrets (saltHex, bid + * amount, deposit amount) so a bid can be revealed after the commit phase. + * These values are sensitive: revealing them without authorisation lets an + * attacker claim the name or steal the deposit. They must never be written to + * localStorage in plaintext. + * + * Format (version 1): + * { + * version: 1, + * kdf: { algorithm: 'PBKDF2', hash: 'SHA-256', iterations: N, salt: base64 }, + * cipher: { algorithm: 'AES-GCM', iv: base64, keyLength: 256 }, + * data: base64 // AES-GCM ciphertext || 16-byte authentication tag + * } + * + * The plaintext payload is the JSON serialisation of the bids record. + * Only the encrypted payload and the non-secret metadata required to derive + * the key and verify the ciphertext are ever persisted. The passphrase, the + * derived key, and the plaintext bids are never written to localStorage. + */ + +export interface AuctionBid { + name: string; + amountStroops: string; + depositStroops: string; + saltHex: string; + revealed: boolean; + submissionStatus?: 'pending' | 'committed' | 'failed' | 'unknown'; + transactionHash?: string; +} + +export const AUCTION_VAULT_VERSION = 1; +export const AUCTION_STORAGE_KEY = 'wraith-name-auction-watchlist'; +export const LEGACY_STORAGE_KEY = 'wraith-name-auction-watchlist'; +export const LEGACY_FORMAT_VERSION = 0; + +// Production-grade PBKDF2 iteration count (OWASP 2023 baseline for +// PBKDF2-HMAC-SHA256). Tests override this via the `iterations` argument. +export const DEFAULT_PBKDF2_ITERATIONS = 600_000; + +export const SALT_LENGTH = 16; // bytes +export const IV_LENGTH = 12; // bytes (AES-GCM recommended nonce size) +export const KEY_BITS = 256; + +export interface EncryptedBidVault { + version: number; + kdf: { + algorithm: 'PBKDF2'; + hash: 'SHA-256'; + iterations: number; + salt: string; // base64 + }; + cipher: { + algorithm: 'AES-GCM'; + iv: string; // base64 + keyLength: number; // bits + }; + data: string; // base64 (ciphertext || 16-byte GCM tag) +} + +export interface BackupPayload { + type: 'wraith-name-auction-backup'; + version: 1; + exportedAt: number; + vault: EncryptedBidVault; +} + +export class AuctionEncryptionError extends Error { + constructor( + message: string, + public readonly code: + | 'invalid_vault' + | 'decrypt_failed' + | 'unsupported_version' = 'decrypt_failed', + ) { + super(message); + this.name = 'AuctionEncryptionError'; + } +} + +export class InvalidVaultError extends AuctionEncryptionError { + constructor(message: string) { + super(message, 'invalid_vault'); + } +} + +export class UnsupportedVersionError extends AuctionEncryptionError { + constructor(version: unknown) { + super(`Unsupported vault version: ${String(version)}`, 'unsupported_version'); + } +} + +function isSecureContextAvailable(): boolean { + return typeof globalThis.crypto?.subtle === 'object'; +} + +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); + } + // Node fallback + 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; + } + // Node fallback + return Buffer.from(input, 'base64') as unknown as Uint8Array; +} + +function getSubtle(): SubtleCrypto { + if (!isSecureContextAvailable()) { + throw new AuctionEncryptionError('Web Crypto API is not available in this environment'); + } + return globalThis.crypto.subtle!; +} + +export function generateRandomBytes(length: number): Uint8Array { + return globalThis.crypto.getRandomValues(new Uint8Array(length)); +} + +/** + * Derive an AES-GCM encryption key from a passphrase using PBKDF2-HMAC-SHA256. + * The returned CryptoKey is non-extractable so the raw key bytes never leave + * the Web Crypto API boundary. + */ +export async function deriveKey( + passphrase: string, + salt: Uint8Array, + iterations: number = DEFAULT_PBKDF2_ITERATIONS, +): Promise { + const subtle = getSubtle(); + const keyMaterial = await subtle.importKey( + 'raw', + new TextEncoder().encode(passphrase), + { name: 'PBKDF2' }, + false, + ['deriveKey'], + ); + return subtle.deriveKey( + { name: 'PBKDF2', salt: salt as unknown as BufferSource, iterations, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: KEY_BITS }, + false, + ['encrypt', 'decrypt'], + ); +} + +/** + * Encrypt a plaintext string with a passphrase. Produces a fresh random salt + * (for key derivation) and a fresh random IV (for encryption) on every call. + * Returns a versioned, self-describing encrypted vault. + */ +export async function encrypt( + passphrase: string, + plaintext: string, + iterations: number = DEFAULT_PBKDF2_ITERATIONS, +): Promise { + const salt = generateRandomBytes(SALT_LENGTH); + const iv = generateRandomBytes(IV_LENGTH); + const key = await deriveKey(passphrase, salt, iterations); + const subtle = getSubtle(); + const ciphertext = await subtle.encrypt( + { name: 'AES-GCM', iv: iv as unknown as BufferSource }, + key, + new TextEncoder().encode(plaintext), + ); + return { + version: AUCTION_VAULT_VERSION, + kdf: { algorithm: 'PBKDF2', hash: 'SHA-256', iterations, salt: toBase64(salt) }, + cipher: { algorithm: 'AES-GCM', iv: toBase64(iv), keyLength: KEY_BITS }, + data: toBase64(new Uint8Array(ciphertext)), + }; +} + +/** + * Decrypt an encrypted vault with a passphrase. Validates the payload and its + * metadata before attempting decryption. Rejects on wrong passphrase or + * tampered/corrupted ciphertext (AES-GCM authentication failure). + */ +export async function decrypt(passphrase: string, vault: EncryptedBidVault): Promise { + validateVault(vault); + const salt = fromBase64(vault.kdf.salt); + const iv = fromBase64(vault.cipher.iv); + const ciphertext = new Uint8Array(fromBase64(vault.data)); + const key = await deriveKey(passphrase, salt, vault.kdf.iterations); + const subtle = getSubtle(); + let decrypted: ArrayBuffer; + try { + decrypted = await subtle.decrypt( + { name: 'AES-GCM', iv: iv as unknown as BufferSource }, + key, + ciphertext, + ); + } catch (err) { + throw new AuctionEncryptionError( + 'Unable to decrypt recovery material: incorrect passphrase or corrupted data.', + 'decrypt_failed', + ); + } + return new TextDecoder().decode(decrypted); +} + +/** Structural validation of an encrypted vault before decryption is attempted. */ +export function validateVault(vault: unknown): asserts vault is EncryptedBidVault { + if (!vault || typeof vault !== 'object') { + throw new InvalidVaultError('Vault payload is missing or not an object.'); + } + const v = vault as Record; + if (v.version !== AUCTION_VAULT_VERSION) { + throw new UnsupportedVersionError(v.version); + } + if (!v.kdf || typeof v.kdf !== 'object') { + throw new InvalidVaultError('Vault is missing key derivation metadata.'); + } + const kdf = v.kdf as Record; + if (kdf.algorithm !== 'PBKDF2' || kdf.hash !== 'SHA-256') { + throw new InvalidVaultError('Unsupported key derivation algorithm.'); + } + if ( + typeof kdf.iterations !== 'number' || + !Number.isFinite(kdf.iterations) || + kdf.iterations < 1 + ) { + throw new InvalidVaultError('Vault key derivation iteration count is invalid.'); + } + if (typeof kdf.salt !== 'string') { + throw new InvalidVaultError('Vault is missing key derivation salt.'); + } + let saltBytes: Uint8Array; + try { + saltBytes = fromBase64(kdf.salt as string); + } catch { + throw new InvalidVaultError('Vault key derivation salt is not valid base64.'); + } + if (saltBytes.length !== SALT_LENGTH) { + throw new InvalidVaultError('Vault key derivation salt has an unexpected length.'); + } + if (!v.cipher || typeof v.cipher !== 'object') { + throw new InvalidVaultError('Vault is missing cipher metadata.'); + } + const cipher = v.cipher as Record; + if (cipher.algorithm !== 'AES-GCM') { + throw new InvalidVaultError('Unsupported cipher algorithm.'); + } + if (typeof cipher.keyLength !== 'number' || cipher.keyLength !== KEY_BITS) { + throw new InvalidVaultError('Vault cipher key length is invalid.'); + } + if (typeof cipher.iv !== 'string') { + throw new InvalidVaultError('Vault is missing encryption IV.'); + } + let ivBytes: Uint8Array; + try { + ivBytes = fromBase64(cipher.iv as string); + } catch { + throw new InvalidVaultError('Vault encryption IV is not valid base64.'); + } + if (ivBytes.length !== IV_LENGTH) { + throw new InvalidVaultError('Vault encryption IV has an unexpected length.'); + } + if (typeof v.data !== 'string') { + throw new InvalidVaultError('Vault is missing encrypted data.'); + } + let dataBytes: Uint8Array; + try { + dataBytes = fromBase64(v.data as string); + } catch { + throw new InvalidVaultError('Vault encrypted data is not valid base64.'); + } + // AES-GCM ciphertext must be at least the 16-byte authentication tag. + if (dataBytes.length < 16) { + throw new InvalidVaultError( + 'Vault encrypted data is too short to contain an authentication tag.', + ); + } +} + +export function isValidVault(vault: unknown): vault is EncryptedBidVault { + try { + validateVault(vault); + return true; + } catch { + return false; + } +} + +/** + * Encrypt the bid recovery map with a passphrase. The plaintext payload is the + * JSON serialisation of the bids record. + */ +export async function encryptBids( + passphrase: string, + bids: Record, + iterations: number = DEFAULT_PBKDF2_ITERATIONS, +): Promise { + return encrypt(passphrase, JSON.stringify(bids), iterations); +} + +/** + * Decrypt an encrypted vault into the bid recovery map. Throws + * AuctionEncryptionError on wrong passphrase, corruption, or invalid payload, + * and a plain Error if the decrypted payload is not a valid bids record. + */ +export async function decryptBids( + passphrase: string, + vault: EncryptedBidVault, +): Promise> { + const plaintext = await decrypt(passphrase, vault); + let parsed: unknown; + try { + parsed = JSON.parse(plaintext); + } catch { + throw new AuctionEncryptionError( + 'Decrypted recovery material is not valid JSON.', + 'decrypt_failed', + ); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new AuctionEncryptionError( + 'Decrypted recovery material is not a bids record.', + 'decrypt_failed', + ); + } + const bids = parsed as Record; + const invalidKeys: string[] = []; + for (const [name, value] of Object.entries(bids)) { + if (!isAuctionBid(value)) { + invalidKeys.push(name); + } + } + if (invalidKeys.length > 0) { + throw new AuctionEncryptionError( + `Decrypted recovery material contains malformed bid(s): ${invalidKeys.join(', ')}.`, + 'decrypt_failed', + ); + } + return bids as Record; +} + +/** Build a documented, versioned backup payload suitable for export and later recovery. */ +export function createBackup(vault: EncryptedBidVault): BackupPayload { + validateVault(vault); + return { + type: 'wraith-name-auction-backup', + version: 1, + exportedAt: Date.now(), + vault, + }; +} + +/** Parse and validate a backup payload (wrapper or raw vault). */ +export function parseBackup(input: unknown): BackupPayload { + if (!input || typeof input !== 'object') { + throw new InvalidVaultError('Backup payload is missing or not an object.'); + } + const p = input as Record; + if (p.type === 'wraith-name-auction-backup' && p.vault) { + const vault = p.vault as EncryptedBidVault; + validateVault(vault); + return { + type: 'wraith-name-auction-backup', + version: 1, + exportedAt: typeof p.exportedAt === 'number' ? p.exportedAt : Date.now(), + vault, + }; + } + // Backward-compatible: raw vault. + validateVault(p); + return { + type: 'wraith-name-auction-backup', + version: 1, + exportedAt: Date.now(), + vault: p as EncryptedBidVault, + }; +} + +export function isAuctionBid(value: unknown): value is AuctionBid { + if (!value || typeof value !== 'object') return false; + const b = value as Record; + return ( + typeof b.name === 'string' && + typeof b.amountStroops === 'string' && + typeof b.depositStroops === 'string' && + typeof b.saltHex === 'string' && + typeof b.revealed === 'boolean' && + (b.submissionStatus === undefined || + ['pending', 'committed', 'failed', 'unknown'].includes(String(b.submissionStatus))) && + (b.transactionHash === undefined || typeof b.transactionHash === 'string') + ); +} diff --git a/src/lib/stellar/auctionBidReconciliation.test.ts b/src/lib/stellar/auctionBidReconciliation.test.ts new file mode 100644 index 0000000..5b4dc62 --- /dev/null +++ b/src/lib/stellar/auctionBidReconciliation.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AuctionBid } from '@/lib/auctionEncryption'; +import { + reconcileUnknownBid, + reconcileUnknownBids, + type TransactionStatusLookup, +} from './auctionBidReconciliation'; + +const bid: AuctionBid = { + name: 'nova', + amountStroops: '25000000', + depositStroops: '25000000', + saltHex: 'ab'.repeat(32), + revealed: false, + submissionStatus: 'unknown', + transactionHash: 'tx-hash', +}; + +describe('auction bid transaction reconciliation', () => { + it('promotes an unknown bid to committed after an authoritative success', async () => { + const lookup = vi.fn().mockResolvedValue('SUCCESS'); + const update = vi.fn().mockResolvedValue(undefined); + + await expect(reconcileUnknownBid(bid, lookup, update)).resolves.toBe('committed'); + expect(lookup).toHaveBeenCalledWith('tx-hash'); + expect(update).toHaveBeenCalledWith('nova', 'committed', 'tx-hash'); + }); + + it('moves an unknown bid to failed only after an authoritative failure', async () => { + const lookup = vi.fn().mockResolvedValue('FAILED'); + const update = vi.fn().mockResolvedValue(undefined); + + await expect(reconcileUnknownBid(bid, lookup, update)).resolves.toBe('failed'); + expect(update).toHaveBeenCalledWith('nova', 'failed'); + }); + + it('preserves unknown when RPC cannot find the transaction yet', async () => { + const lookup = vi.fn().mockResolvedValue('NOT_FOUND'); + const update = vi.fn().mockResolvedValue(undefined); + + await expect(reconcileUnknownBid(bid, lookup, update)).resolves.toBe('unknown'); + expect(update).not.toHaveBeenCalled(); + }); + + it('preserves unknown when the network lookup fails', async () => { + const lookup = vi.fn().mockRejectedValue(new Error('offline')); + const update = vi.fn().mockResolvedValue(undefined); + + await expect(reconcileUnknownBid(bid, lookup, update)).resolves.toBe('unknown'); + expect(update).not.toHaveBeenCalled(); + }); + + it('resumes after reload from the persisted hash and reconciles eligible records', async () => { + const rehydrated = JSON.parse(JSON.stringify({ nova: bid })) as Record; + const lookup = vi.fn().mockResolvedValue('SUCCESS'); + const update = vi.fn().mockResolvedValue(undefined); + + await reconcileUnknownBids(rehydrated, lookup, update); + + expect(lookup).toHaveBeenCalledWith('tx-hash'); + expect(update).toHaveBeenCalledWith('nova', 'committed', 'tx-hash'); + }); + + it('does not reconcile committed, failed, or hashless pending records', async () => { + const lookup = vi.fn().mockResolvedValue('SUCCESS'); + const update = vi.fn().mockResolvedValue(undefined); + await reconcileUnknownBids( + { + committed: { ...bid, name: 'committed', submissionStatus: 'committed' }, + failed: { ...bid, name: 'failed', submissionStatus: 'failed' }, + pending: { ...bid, name: 'pending', submissionStatus: 'pending', transactionHash: undefined }, + }, + lookup, + update, + ); + + expect(lookup).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/stellar/auctionBidReconciliation.ts b/src/lib/stellar/auctionBidReconciliation.ts new file mode 100644 index 0000000..6106f09 --- /dev/null +++ b/src/lib/stellar/auctionBidReconciliation.ts @@ -0,0 +1,58 @@ +import type { AuctionBid } from '@/lib/auctionEncryption'; + +export type AuthoritativeTransactionStatus = 'SUCCESS' | 'FAILED' | 'NOT_FOUND'; + +export type TransactionStatusLookup = (hash: string) => Promise; + +export type BidSubmissionUpdater = ( + name: string, + status: NonNullable, + transactionHash?: string, +) => Promise; + +/** + * Resolve saved ambiguous submissions only from an authoritative Soroban + * transaction result. NOT_FOUND is deliberately inconclusive: RPC indexing, + * retention, or propagation delays must never make a retry appear safe. + */ +export async function reconcileUnknownBid( + bid: AuctionBid, + lookup: TransactionStatusLookup, + update: BidSubmissionUpdater, +): Promise<'committed' | 'failed' | 'unknown' | 'skipped'> { + if ( + (bid.submissionStatus !== 'unknown' && bid.submissionStatus !== 'pending') || + !bid.transactionHash + ) { + return 'skipped'; + } + + let status: AuthoritativeTransactionStatus; + try { + status = await lookup(bid.transactionHash); + } catch { + return 'unknown'; + } + + if (status === 'SUCCESS') { + await update(bid.name, 'committed', bid.transactionHash); + return 'committed'; + } + + if (status === 'FAILED') { + await update(bid.name, 'failed'); + return 'failed'; + } + + return 'unknown'; +} + +export async function reconcileUnknownBids( + bids: Record, + lookup: TransactionStatusLookup, + update: BidSubmissionUpdater, +): Promise { + for (const bid of Object.values(bids)) { + await reconcileUnknownBid(bid, lookup, update); + } +} diff --git a/src/lib/stellar/auctionBidSubmission.test.ts b/src/lib/stellar/auctionBidSubmission.test.ts new file mode 100644 index 0000000..e1c6c3f --- /dev/null +++ b/src/lib/stellar/auctionBidSubmission.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AuctionBid } from '@/lib/auctionEncryption'; +import { submitBidWithRecovery, TransactionSubmissionError } from './auctionBidSubmission'; + +const bid: AuctionBid = { + name: 'nova', + amountStroops: '25000000', + depositStroops: '25000000', + saltHex: 'ab'.repeat(32), + revealed: false, +}; + +function setup() { + let storedBid: AuctionBid = bid; + const saveBid = vi.fn(async (nextBid: AuctionBid) => { + storedBid = nextBid; + }); + const updateBidSubmission = vi.fn( + async ( + _name: string, + status: NonNullable, + transactionHash?: string, + ) => { + storedBid = { ...storedBid, submissionStatus: status, transactionHash }; + }, + ); + return { + saveBid, + updateBidSubmission, + getStoredBid: () => storedBid, + }; +} + +describe('auction bid submission recovery', () => { + it('stages the encrypted secret before signing and makes wallet rejection retryable', async () => { + const state = setup(); + const calls: string[] = []; + const signTransaction = vi.fn(async () => { + calls.push('sign'); + throw new Error('User rejected request'); + }); + const saveBid = vi.fn(async (nextBid: AuctionBid) => { + calls.push('save'); + state.saveBid(nextBid); + }); + const submitTransaction = vi.fn(); + + const result = await submitBidWithRecovery(bid, { + ...state, + saveBid, + buildTransaction: vi.fn().mockResolvedValue('unsigned-xdr'), + signTransaction, + submitTransaction, + }); + + expect(result.status).toBe('failed'); + expect(calls).toEqual(['save', 'sign']); + expect(saveBid).toHaveBeenCalledWith({ ...bid, submissionStatus: 'pending' }); + expect(state.getStoredBid()).toMatchObject({ + saltHex: bid.saltHex, + submissionStatus: 'failed', + }); + expect(submitTransaction).not.toHaveBeenCalled(); + }); + + it('marks a confirmed pre-broadcast rejection failed and preserves the same secret', async () => { + const state = setup(); + + const result = await submitBidWithRecovery(bid, { + ...state, + buildTransaction: vi.fn().mockResolvedValue('unsigned-xdr'), + signTransaction: vi.fn().mockResolvedValue('signed-xdr'), + submitTransaction: vi + .fn() + .mockRejectedValue(new TransactionSubmissionError('Transaction rejected', 'rejected')), + }); + + expect(result.status).toBe('failed'); + expect(state.getStoredBid()).toMatchObject({ + saltHex: bid.saltHex, + amountStroops: bid.amountStroops, + submissionStatus: 'failed', + }); + }); + + it('keeps ambiguous post-broadcast failures non-retryable', async () => { + const state = setup(); + + const result = await submitBidWithRecovery(bid, { + ...state, + buildTransaction: vi.fn().mockResolvedValue('unsigned-xdr'), + signTransaction: vi.fn().mockResolvedValue('signed-xdr'), + submitTransaction: vi + .fn() + .mockRejectedValue( + new TransactionSubmissionError('Connection timed out', 'unknown', 'possible-hash'), + ), + }); + + expect(result.status).toBe('unknown'); + expect(result.transactionHash).toBe('possible-hash'); + expect(state.getStoredBid()).toMatchObject({ + saltHex: bid.saltHex, + submissionStatus: 'unknown', + transactionHash: 'possible-hash', + }); + }); + + it('persists the transaction hash before broadcast for reload-safe reconciliation', async () => { + const state = setup(); + const calls: string[] = []; + const submitTransaction = vi.fn(async (_xdr: string, onPrepared?: (hash: string) => Promise) => { + await onPrepared?.('prepared-hash'); + calls.push('broadcast'); + return 'prepared-hash'; + }); + const updateBidSubmission = vi.fn(async ( + name: string, + status: NonNullable, + transactionHash?: string, + ) => { + calls.push(`persist:${status}:${transactionHash ?? ''}`); + await state.updateBidSubmission(name, status, transactionHash); + }); + + const result = await submitBidWithRecovery(bid, { + ...state, + updateBidSubmission, + buildTransaction: vi.fn().mockResolvedValue('unsigned-xdr'), + signTransaction: vi.fn().mockResolvedValue('signed-xdr'), + submitTransaction, + }); + + expect(result.status).toBe('unknown'); + expect(calls).toEqual([ + 'persist:pending:prepared-hash', + 'broadcast', + 'persist:unknown:prepared-hash', + ]); + expect(updateBidSubmission).toHaveBeenCalledWith('nova', 'unknown', 'prepared-hash'); + }); + + it('does not mark a submitted transaction committed before Soroban confirms execution', async () => { + const state = setup(); + + const result = await submitBidWithRecovery(bid, { + ...state, + buildTransaction: vi.fn().mockResolvedValue('unsigned-xdr'), + signTransaction: vi.fn().mockResolvedValue('signed-xdr'), + submitTransaction: vi.fn().mockResolvedValue('transaction-hash'), + }); + + expect(result.status).toBe('unknown'); + expect(result.transactionHash).toBe('transaction-hash'); + expect(state.getStoredBid()).toMatchObject({ + saltHex: bid.saltHex, + submissionStatus: 'unknown', + transactionHash: 'transaction-hash', + }); + }); +}); diff --git a/src/lib/stellar/auctionBidSubmission.ts b/src/lib/stellar/auctionBidSubmission.ts new file mode 100644 index 0000000..7ff8105 --- /dev/null +++ b/src/lib/stellar/auctionBidSubmission.ts @@ -0,0 +1,78 @@ +import type { AuctionBid } from '@/lib/auctionEncryption'; + +export class TransactionSubmissionError extends Error { + constructor( + message: string, + readonly outcome: 'rejected' | 'unknown', + readonly transactionHash?: string, + ) { + super(message); + this.name = 'TransactionSubmissionError'; + } +} + +export type BidSubmissionStatus = NonNullable; + +interface SubmitBidOptions { + saveBid: (bid: AuctionBid) => Promise; + updateBidSubmission: ( + name: string, + status: BidSubmissionStatus, + transactionHash?: string, + ) => Promise; + buildTransaction: () => Promise; + signTransaction: (xdr: string) => Promise; + submitTransaction: ( + signedXdr: string, + onPrepared?: (transactionHash: string) => Promise, + ) => Promise; +} + +export type BidSubmissionResult = + | { status: 'committed'; transactionHash: string } + | { status: 'failed' | 'unknown'; error: unknown; transactionHash?: string }; + +export async function submitBidWithRecovery( + bid: AuctionBid, + options: SubmitBidOptions, +): Promise { + const stagedBid: AuctionBid = { ...bid, submissionStatus: 'pending' }; + delete stagedBid.transactionHash; + await options.saveBid(stagedBid); + + let signedXdr: string; + try { + signedXdr = await options.signTransaction(await options.buildTransaction()); + } catch (error) { + await options.updateBidSubmission(bid.name, 'failed'); + return { status: 'failed', error }; + } + + let transactionHash: string; + try { + transactionHash = await options.submitTransaction(signedXdr, async (hash) => { + // Persist the deterministic hash before broadcast so a reload between + // submission and confirmation can still reconcile this exact transaction. + await options.updateBidSubmission(bid.name, 'pending', hash); + }); + } catch (error) { + const status = + error instanceof TransactionSubmissionError && error.outcome === 'rejected' + ? 'failed' + : 'unknown'; + const transactionHash = + error instanceof TransactionSubmissionError ? error.transactionHash : undefined; + await options.updateBidSubmission(bid.name, status, transactionHash); + return { status, error, ...(transactionHash ? { transactionHash } : {}) }; + } + + // sendTransaction returning a hash only confirms that the RPC accepted the + // submission. Soroban execution may still be pending or may fail, so keep + // the persisted bid non-committed until getTransaction confirms SUCCESS. + await options.updateBidSubmission(bid.name, 'unknown', transactionHash); + return { + status: 'unknown', + transactionHash, + error: new Error('Transaction submitted; awaiting authoritative confirmation.'), + }; +} diff --git a/src/lib/stellar/names.ts b/src/lib/stellar/names.ts index 957512a..3ae1a72 100644 --- a/src/lib/stellar/names.ts +++ b/src/lib/stellar/names.ts @@ -9,6 +9,7 @@ import { } from '@stellar/stellar-sdk'; import { Buffer } from 'buffer'; import { STELLAR_NETWORK } from '@/config'; +import { TransactionSubmissionError } from '@/lib/stellar/auctionBidSubmission'; import { getStellarContractId } from './deployments'; /** @@ -708,16 +709,74 @@ export async function getOwnedNames(ownerAddress: string): Promise { /** * Submit a signed transaction to the network */ -export async function submitTransaction(signedXdr: string): Promise { - const { rpc } = await import('@stellar/stellar-sdk'); - const server = new rpc.Server(STELLAR_NETWORK.rpcUrl); +export async function submitTransaction( + signedXdr: string, + onPrepared?: (transactionHash: string) => Promise, +): Promise { + let server: InstanceType<(typeof import('@stellar/stellar-sdk'))['rpc']['Server']>; + let tx: ReturnType; + let transactionHash: string; + try { + const { rpc } = await import('@stellar/stellar-sdk'); + server = new rpc.Server(STELLAR_NETWORK.rpcUrl); + tx = TransactionBuilder.fromXDR(signedXdr, STELLAR_NETWORK.networkPassphrase); + transactionHash = tx.hash().toString('hex'); + } catch (error) { + throw new TransactionSubmissionError( + error instanceof Error ? error.message : 'Failed to prepare transaction submission.', + 'rejected', + ); + } - const tx = TransactionBuilder.fromXDR(signedXdr, STELLAR_NETWORK.networkPassphrase); - const result = await server.sendTransaction(tx); + if (onPrepared) { + try { + await onPrepared(transactionHash); + } catch (error) { + throw new TransactionSubmissionError( + error instanceof Error + ? `Could not persist transaction recovery state: ${error.message}` + : 'Could not persist transaction recovery state.', + 'rejected', + ); + } + } - if (result.status === 'ERROR') { - throw new Error(result.errorResult?.toString() || 'Transaction failed'); + let result: Awaited>; + try { + result = await server.sendTransaction(tx); + } catch (error) { + throw new TransactionSubmissionError( + error instanceof Error ? error.message : 'Transaction submission outcome is unknown.', + 'unknown', + transactionHash, + ); + } + + if (result.status === 'ERROR' || result.status === 'TRY_AGAIN_LATER') { + throw new TransactionSubmissionError( + result.errorResult?.toString() || + (result.status === 'TRY_AGAIN_LATER' + ? 'The network did not accept the transaction; retry is safe.' + : 'Transaction was rejected before broadcast.'), + 'rejected', + ); } return result.hash; } + +/** + * Query Soroban RPC for an authoritative transaction result. NOT_FOUND is + * inconclusive and callers must leave the bid in its unknown state. + */ +export async function getNameBidTransactionStatus( + transactionHash: string, +): Promise<'SUCCESS' | 'FAILED' | 'NOT_FOUND'> { + const { rpc } = await import('@stellar/stellar-sdk'); + const server = new rpc.Server(STELLAR_NETWORK.rpcUrl); + const result = await server.getTransaction(transactionHash); + if (result.status === 'SUCCESS') return 'SUCCESS'; + if (result.status === 'FAILED') return 'FAILED'; + return 'NOT_FOUND'; +} + diff --git a/src/pages/NamesAuctions.tsx b/src/pages/NamesAuctions.tsx index e3f70df..119d8e9 100644 --- a/src/pages/NamesAuctions.tsx +++ b/src/pages/NamesAuctions.tsx @@ -1,12 +1,14 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { decodeStealthMetaAddress } from '@wraith-protocol/sdk/chains/stellar'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { useStealthKeys } from '@/context/StealthKeysContext'; import { useContacts } from '@/store/contactsStore'; import { useNameHistory } from '@/store/nameHistoryStore'; -import { useNameWatchlistStore } from '@/store/nameWatchlistStore'; +import { useNameWatchlistStore, type AuctionBid } from '@/store/nameWatchlistStore'; import { stellarTxUrl } from '@/lib/explorer'; +import { submitBidWithRecovery } from '@/lib/stellar/auctionBidSubmission'; +import { reconcileUnknownBids } from '@/lib/stellar/auctionBidReconciliation'; import { tryGetStellarContractId } from '@/lib/stellar/deployments'; import { MIN_AUCTION_BID_INCREMENT, @@ -20,12 +22,11 @@ import { getNameAuction, getNameAuctionConfig, submitTransaction, + getNameBidTransactionStatus, type NameAuction, type NameAuctionConfig, } from '@/lib/stellar/names'; -// Display-only label lookup: an undisployed names contract resolves to an empty -// address so the page still renders, while every auction call fails closed. const NAMES_CONTRACT_ID = tryGetStellarContractId('names'); const STROOPS_PER_XLM = 10_000_000n; @@ -42,6 +43,13 @@ function formatXlm(stroops: bigint) { return fraction ? `${whole}.${fraction} XLM` : `${whole} XLM`; } +function formatXlmInput(stroops: string) { + const amount = BigInt(stroops); + const whole = amount / STROOPS_PER_XLM; + const fraction = (amount % STROOPS_PER_XLM).toString().padStart(7, '0').replace(/0+$/, ''); + return fraction ? `${whole}.${fraction}` : whole.toString(); +} + function formatEndsAt(timestamp: number) { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', @@ -61,14 +69,27 @@ export default function NamesAuctions() { const { stellarMetaAddress } = useStealthKeys(); const { isKnownAddress } = useContacts(); const { isKnownRecipient } = useNameHistory(); + const watchedAuctions = useNameWatchlistStore((state) => state.watchedAuctions); const bids = useNameWatchlistStore((state) => state.bids); const watchAuction = useNameWatchlistStore((state) => state.watchAuction); const unwatchAuction = useNameWatchlistStore((state) => state.unwatchAuction); const saveBid = useNameWatchlistStore((state) => state.saveBid); + const updateBidSubmission = useNameWatchlistStore((state) => state.updateBidSubmission); const markBidRevealed = useNameWatchlistStore((state) => state.markBidRevealed); const removeBid = useNameWatchlistStore((state) => state.removeBid); + const status = useNameWatchlistStore((state) => state.status); + const hasLegacyPlaintext = useNameWatchlistStore((state) => state.hasLegacyPlaintext); + const hasEncryptedVault = useNameWatchlistStore((state) => state.hasEncryptedVault); + const inspectStorage = useNameWatchlistStore((state) => state.inspectStorage); + const setPassphrase = useNameWatchlistStore((state) => state.setPassphrase); + const unlock = useNameWatchlistStore((state) => state.unlock); + const lock = useNameWatchlistStore((state) => state.lock); + const exportBackup = useNameWatchlistStore((state) => state.exportBackup); + const importBackup = useNameWatchlistStore((state) => state.importBackup); + const clearLegacy = useNameWatchlistStore((state) => state.clearLegacy); + const [auctions, setAuctions] = useState([]); const [config, setConfig] = useState(null); const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); @@ -80,6 +101,16 @@ export default function NamesAuctions() { const [selectedAuction, setSelectedAuction] = useState(null); const [bidAmount, setBidAmount] = useState(''); + // Vault UI state + const [vaultError, setVaultError] = useState(''); + const [passphrase, setPassphraseValue] = useState(''); + const [confirmPassphrase, setConfirmPassphrase] = useState(''); + const [importing, setImporting] = useState(false); + const [importFile, setImportFile] = useState(null); + const [revealPending, setRevealPending] = useState(null); + const [backupReady, setBackupReady] = useState(null); + const importFileInput = useRef(null); + const trackedNames = useMemo( () => [...new Set([...watchedAuctions.map((item) => item.name), ...Object.keys(bids)])], [bids, watchedAuctions], @@ -87,6 +118,7 @@ export default function NamesAuctions() { const trackedNamesKey = trackedNames.join('|'); const isUnknownContract = !isKnownAddress(NAMES_CONTRACT_ID) && !isKnownRecipient(NAMES_CONTRACT_ID); + const unlocked = status === 'unlocked'; const refreshAuctions = useCallback(async () => { setIsLoading(true); @@ -105,6 +137,10 @@ export default function NamesAuctions() { } }, [trackedNamesKey]); + useEffect(() => { + inspectStorage(); + }, [inspectStorage]); + useEffect(() => { void refreshAuctions(); }, [refreshAuctions]); @@ -114,6 +150,29 @@ export default function NamesAuctions() { return () => globalThis.clearInterval(timer); }, []); + // Reconcile ambiguous submissions after unlock/reload and retry periodically. + // Only authoritative Soroban SUCCESS/FAILED results change persisted state. + useEffect(() => { + if (!unlocked) return; + let active = true; + const reconcile = async () => { + try { + await reconcileUnknownBids(bids, getNameBidTransactionStatus, async (name, nextStatus, hash) => { + if (active) await updateBidSubmission(name, nextStatus, hash); + }); + } catch { + // Keep unknown/pending state on RPC or persistence errors; the next + // interval retries without enabling reveal, refund, or another bid. + } + }; + void reconcile(); + const timer = globalThis.setInterval(() => void reconcile(), 15_000); + return () => { + active = false; + globalThis.clearInterval(timer); + }; + }, [bids, unlocked, updateBidSubmission]); + const trackAuction = async () => { const name = lookupName .trim() @@ -168,43 +227,92 @@ export default function NamesAuctions() { : parsedBid !== null && parsedBid < minimumBid ? `Minimum bid is ${formatXlm(minimumBid)}.` : ''; + const selectedBid = selectedAuction ? bids[selectedAuction.name] : undefined; + const retryingBid = selectedBid?.submissionStatus === 'failed'; + const selectedBidCannotRetry = + selectedBid?.submissionStatus === 'pending' || selectedBid?.submissionStatus === 'unknown'; const placeBid = async () => { + if (!unlocked) { + setError('Set a passphrase to secure your bid recovery material before placing a bid.'); + return; + } if (!address || !selectedAuction || parsedBid === null || bidError) return; const action = `bid:${selectedAuction.name}`; setPendingAction(action); setError(''); + setTxHash(''); try { - const salt = globalThis.crypto.getRandomValues(new Uint8Array(32)); - const saltHex = Array.from(salt, (byte) => byte.toString(16).padStart(2, '0')).join(''); + const existingBid = bids[selectedAuction.name]; + if (existingBid && existingBid.submissionStatus !== 'failed') { + throw new Error('This bid cannot be retried until its submission status is confirmed.'); + } + if ( + existingBid && + existingBid.submissionStatus === 'failed' && + parsedBid.toString() !== existingBid.amountStroops + ) { + throw new Error('A retry must use the original bid amount and recovery secret.'); + } + const saltHex = + existingBid?.submissionStatus === 'failed' + ? existingBid.saltHex + : Array.from(globalThis.crypto.getRandomValues(new Uint8Array(32)), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + const bid: AuctionBid = + existingBid?.submissionStatus === 'failed' + ? existingBid + : { + name: selectedAuction.name, + amountStroops: parsedBid.toString(), + depositStroops: parsedBid.toString(), + saltHex, + revealed: false, + }; const commitmentHex = await computeNameBidCommitment( address, selectedAuction.name, - parsedBid, + BigInt(bid.amountStroops), saltHex, ); - const submitted = await submitAuctionTransaction(action, () => - buildCommitNameBidTransaction(address, { - name: selectedAuction.name, - commitmentHex, - deposit: parsedBid, - }), - ); - if (submitted) { - saveBid({ - name: selectedAuction.name, - amountStroops: parsedBid.toString(), - depositStroops: parsedBid.toString(), - saltHex, - revealed: false, - }); + const result = await submitBidWithRecovery(bid, { + saveBid, + updateBidSubmission, + buildTransaction: () => + buildCommitNameBidTransaction(address, { + name: selectedAuction.name, + commitmentHex, + deposit: BigInt(bid.depositStroops), + }), + signTransaction, + submitTransaction, + }); + if (result.status === 'committed') { + setTxHash(result.transactionHash); watchAuction({ name: selectedAuction.name, endsAt: selectedAuction.revealEnd }); setSelectedAuction(null); setBidAmount(''); + await refreshAuctions(); + } else if (result.status === 'failed') { + const message = result.error instanceof Error ? result.error.message : 'Transaction failed'; + setError( + `Bid was not submitted: ${message} Your encrypted secret is saved; retry is safe.`, + ); + } else { + const message = + result.error instanceof Error + ? result.error.message + : 'The submission outcome is unknown.'; + setError( + `${message} Your encrypted secret is preserved. Do not retry until you confirm the transaction was not submitted.`, + ); } } catch (commitmentError) { setError( - commitmentError instanceof Error ? commitmentError.message : 'Failed to prepare bid', + commitmentError instanceof Error + ? commitmentError.message + : 'Failed to prepare or persist bid recovery material.', ); } finally { setPendingAction(''); @@ -212,9 +320,15 @@ export default function NamesAuctions() { }; const revealBid = async (auction: NameAuction) => { + if (!unlocked) { + setRevealPending(auction); + setError('Unlock your vault with your passphrase before revealing a bid.'); + return; + } if (!address) return; const bid = bids[auction.name]; if (!bid) return; + setRevealPending(auction); const submitted = await submitAuctionTransaction(`reveal:${auction.name}`, () => buildRevealNameBidTransaction(address, { name: auction.name, @@ -222,7 +336,14 @@ export default function NamesAuctions() { saltHex: bid.saltHex, }), ); - if (submitted) markBidRevealed(auction.name); + setRevealPending(null); + if (submitted) { + try { + await markBidRevealed(auction.name); + } catch { + // reveal transaction submitted; marking revealed is best-effort local state + } + } }; const settleAuction = async (auction: NameAuction) => { @@ -244,7 +365,13 @@ export default function NamesAuctions() { const submitted = await submitAuctionTransaction(`claim:${auction.name}`, () => buildClaimAuctionNameTransaction(address, auction.name, metaAddress), ); - if (submitted) removeBid(auction.name); + if (submitted) { + try { + await removeBid(auction.name); + } catch { + // claim submitted; local bid removal is best-effort + } + } }; const refundBid = async (auction: NameAuction) => { @@ -252,9 +379,124 @@ export default function NamesAuctions() { const submitted = await submitAuctionTransaction(`refund:${auction.name}`, () => buildRefundNameBidTransaction(address, auction.name), ); - if (submitted) removeBid(auction.name); + if (submitted) { + try { + await removeBid(auction.name); + } catch { + // refund submitted; local bid removal is best-effort + } + } + }; + + // Vault actions + const handleSetPassphrase = async () => { + setVaultError(''); + try { + if (passphrase !== confirmPassphrase) { + throw new Error('Passphrases do not match.'); + } + if (status === 'legacy') { + await setPassphrase(passphrase); // migrates legacy plaintext + } else { + await setPassphrase(passphrase); // creates new vault + } + setPassphraseValue(''); + setConfirmPassphrase(''); + } catch (err) { + setVaultError(err instanceof Error ? err.message : 'Failed to set up encryption.'); + } + }; + + const handleUnlock = async () => { + setVaultError(''); + try { + await unlock(passphrase); + setPassphraseValue(''); + } catch (err) { + setVaultError(err instanceof Error ? err.message : 'Unable to unlock vault.'); + } + }; + + const handleBackup = async () => { + setVaultError(''); + setBackupReady(null); + try { + const json = await exportBackup(); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `wraith-name-auction-backup-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + setBackupReady(json); + } catch (err) { + setVaultError(err instanceof Error ? err.message : 'Backup failed.'); + } + }; + + const handleImportFile = async (file: File) => { + setVaultError(''); + setImporting(true); + try { + const text = await file.text(); + // Never silently replace a non-empty current vault. Require explicit + // confirmation before importing overwrites the current recovery secrets. + let replace = false; + if (hasEncryptedVault) { + const confirmed = window.confirm( + 'Your current vault already contains bid recovery secrets. Importing this backup will replace it. Export your current vault first, or click OK to confirm replacement.', + ); + if (!confirmed) { + setImporting(false); + return; + } + replace = true; + } + await importBackup(text, passphrase, { replace }); + setPassphraseValue(''); + setImportFile(null); + } catch (err) { + setVaultError(err instanceof Error ? err.message : 'Import failed.'); + } finally { + setImporting(false); + } }; + const handleClearLegacy = async () => { + setVaultError(''); + if (!hasLegacyPlaintext) return; + if ( + !confirm( + 'This will permanently delete the unencrypted legacy bid recovery material from localStorage. The encrypted vault must already exist. Continue?', + ) + ) { + return; + } + try { + const cleared = await clearLegacy(); + if (!cleared) { + setVaultError('No legacy plaintext found to clear.'); + } + } catch (err) { + setVaultError(err instanceof Error ? err.message : 'Failed to clear legacy plaintext.'); + } + }; + + const bidCount = Object.keys(bids).length; + const statusLabel = + status === 'empty' + ? 'No vault' + : status === 'legacy' + ? 'Legacy plaintext (unmigrated)' + : status === 'locked' + ? 'Vault locked' + : status === 'unlocked' + ? 'Vault unlocked' + : 'Error'; + if (!isConnected) { return (
@@ -285,10 +527,162 @@ export default function NamesAuctions() {

Bid privately during the commit phase, reveal your bid, then claim the name or refund your - deposit after the auction. + 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' ? ( +
+
+ + setPassphraseValue(e.target.value)} + placeholder="••••••••" + className="h-10 w-full border border-outline-variant bg-surface px-3 font-mono text-sm text-primary focus:border-primary" + /> +
+ {status !== 'empty' && ( +
+ + setConfirmPassphrase(e.target.value)} + placeholder="••••••••" + className="h-10 w-full border border-outline-variant bg-surface px-3 font-mono text-sm text-primary focus:border-primary" + /> +
+ )} + +
+ ) : null} + + {status === 'unlocked' && ( +
+

+ {bidCount} bid recovery secret(s) held in memory only. +

+ + { + const file = e.target.files?.[0] ?? null; + setImportFile(file); + if (file) void handleImportFile(file); + }} + /> + + {hasLegacyPlaintext && ( + + )} + {backupReady && ( +

+ Backup exported (encrypted, passphrase required to restore). +

+ )} +
+ )} +
+ + {error && ( +

+ {error} +

+ )} + {txHash && ( +

+ Transaction submitted.{' '} + + View transaction + +

+ )} +
- {error && ( -

- {error} -

- )} - {txHash && ( -

- Transaction submitted.{' '} - - View transaction - + {revealPending && ( +

+ Ready to reveal {revealPending.name}.wraith. Unlock the vault above, then select Reveal + bid again.

)} @@ -343,6 +725,8 @@ export default function NamesAuctions() { auctions.map((auction) => { const phase = phaseFor(auction, now); const bid = bids[auction.name]; + const bidStatus = bid?.submissionStatus ?? 'committed'; + const hasCommittedBid = Boolean(bid && bidStatus === 'committed'); const isWinner = auction.highestBidder === address; const isWatched = watchedAuctions.some((item) => item.name === auction.name); return ( @@ -395,11 +779,43 @@ export default function NamesAuctions() { Your bid
- {bid ? formatXlm(BigInt(bid.amountStroops)) : '—'} + {hasCommittedBid + ? formatXlm(BigInt(bid.amountStroops)) + : bid + ? 'Not committed' + : '—'}
+ {bidStatus === 'pending' && ( +

+ Submission is pending or interrupted. Do not retry. +

+ )} + {bidStatus === 'unknown' && ( +
+

+ Submission outcome is unknown. Confirm transaction status before retrying. +

+ {bid?.transactionHash && ( + + Check transaction status + + )} +
+ )} + {bidStatus === 'failed' && ( +

+ Bid was not submitted. The saved secret is safe to retry. +

+ )} +
{phase === 'commit' && !bid && ( + )} + {phase === 'commit' && hasCommittedBid && ( Bid committed — return to reveal it )} - {phase === 'reveal' && bid && !bid.revealed && ( + {phase === 'reveal' && hasCommittedBid && bid && !bid.revealed && ( )} {phase === 'ended' && ( @@ -450,16 +882,19 @@ export default function NamesAuctions() { {pendingAction === `claim:${auction.name}` ? 'Claiming...' : 'Claim name'} )} - {(phase === 'ended' || phase === 'settled') && bid && !isWinner && ( - - )} + {(phase === 'ended' || phase === 'settled') && + hasCommittedBid && + bid && + !isWinner && ( + + )}
); @@ -495,6 +930,14 @@ export default function NamesAuctions() { + {unlocked ? null : ( +
+

+ Set up your encryption passphrase in the vault section above before placing a bid. +

+
+ )} + @@ -504,6 +947,7 @@ export default function NamesAuctions() { value={bidAmount} onChange={(event) => setBidAmount(event.target.value)} placeholder="0.0" + disabled={retryingBid} aria-invalid={Boolean(bidError)} className="mt-2 h-12 w-full border border-outline-variant bg-surface-container px-4 font-heading text-xl text-primary" /> @@ -527,8 +971,9 @@ export default function NamesAuctions() { )}

- Your amount and recovery secret stay in this browser until reveal. Clearing site data - before revealing can make the bid unrecoverable. + 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 && ( @@ -543,13 +988,17 @@ export default function NamesAuctions() { disabled={ parsedBid === null || Boolean(bidError) || - pendingAction === `bid:${selectedAuction.name}` + pendingAction === `bid:${selectedAuction.name}` || + selectedBidCannotRetry || + !unlocked } className="mt-5 h-11 w-full bg-primary font-heading text-[11px] font-semibold uppercase tracking-widest text-surface disabled:opacity-30" > {pendingAction === `bid:${selectedAuction.name}` ? 'Confirm in wallet...' - : 'Place bid'} + : retryingBid + ? 'Retry bid' + : 'Place bid'} diff --git a/src/store/nameWatchlistStore.test.ts b/src/store/nameWatchlistStore.test.ts index c0d09d3..16124ec 100644 --- a/src/store/nameWatchlistStore.test.ts +++ b/src/store/nameWatchlistStore.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { decryptBids } from '@/lib/auctionEncryption'; class MemoryStorage implements Storage { private values = new Map(); @@ -28,98 +29,550 @@ class MemoryStorage implements Storage { } } -class MockWindow { - localStorage: Storage; - private listeners: Map void)[]> = new Map(); +let storage: MemoryStorage; - constructor(storage: Storage) { - this.localStorage = storage; - } +beforeEach(() => { + vi.resetModules(); + storage = new MemoryStorage(); + vi.stubGlobal('localStorage', storage); + vi.stubGlobal('window', { localStorage: storage }); +}); - addEventListener(event: string, listener: (e: StorageEvent) => void) { - if (!this.listeners.has(event)) { - this.listeners.set(event, []); - } - this.listeners.get(event)!.push(listener); - } +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('recognizes versioned plaintext watchlist data for safe encrypted migration', async () => { + const { useNameWatchlistStore } = await loadStore(); + localStorage.setItem( + 'wraith-name-auction-watchlist', + JSON.stringify({ + version: 1, + type: 'wraith-versioned-value', + data: { + watchedAuctions: [{ name: 'nova', endsAt: 1_800_000_000 }], + bids: { nova: newBid }, + }, + }), + ); + + useNameWatchlistStore.getState().inspectStorage(); + expect(useNameWatchlistStore.getState().status).toBe('legacy'); + + await useNameWatchlistStore.getState().setPassphrase(passphrase, iterations); + expect(useNameWatchlistStore.getState().bids.nova?.saltHex).toBe(newBid.saltHex); + expect(localStorage.getItem('wraith-name-auction-watchlist')).toContain( + '"type":"wraith-versioned-value"', + ); + expect(localStorage.getItem('wraith-name-auction-watchlist.vault')).not.toContain( + newBid.saltHex, + ); + }); + + 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(); - removeEventListener(event: string, listener: (e: StorageEvent) => void) { - const eventListeners = this.listeners.get(event); - if (eventListeners) { - const index = eventListeners.indexOf(listener); - if (index > -1) { - eventListeners.splice(index, 1); - } - } + // 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(); } - dispatchEvent(event: StorageEvent) { - const eventListeners = this.listeners.get(event.type); - if (eventListeners) { - eventListeners.forEach((listener) => listener(event)); - } + 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); } -} -describe('name auction watchlist', () => { - beforeEach(() => { - vi.resetModules(); - const storage = new MemoryStorage(); - const mockWindow = new MockWindow(storage); - vi.stubGlobal('localStorage', storage); - vi.stubGlobal('window', mockWindow); + 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(); }); - afterEach(async () => { - const { cleanupNameWatchlistSync } = await import('./nameWatchlistStore'); - cleanupNameWatchlistSync(); + 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('restores watched auctions and sealed bid recovery data', async () => { - const { useNameWatchlistStore, initNameWatchlistSync } = await import('./nameWatchlistStore'); - initNameWatchlistSync(); + it('persists a decryptable, verified recovery record before it can be trusted for submission', async () => { + const { useNameWatchlistStore } = await loadStore(); const store = useNameWatchlistStore.getState(); - store.watchAuction({ name: 'NOVA', endsAt: 1_800_000_000 }); - store.saveBid({ - name: 'NOVA', - amountStroops: '25000000', - depositStroops: '25000000', - saltHex: 'ab'.repeat(32), - revealed: false, - }); + await store.setPassphrase(passphrase, iterations); + await store.saveBid(gateBid); - const persisted = localStorage.getItem('wraith-name-auction-watchlist'); - expect(persisted).not.toBeNull(); - useNameWatchlistStore.setState({ watchedAuctions: [], bids: {} }); - localStorage.setItem('wraith-name-auction-watchlist', persisted!); - await useNameWatchlistStore.persist.rehydrate(); - - expect(useNameWatchlistStore.getState().watchedAuctions).toEqual([ - { name: 'nova', endsAt: 1_800_000_000 }, - ]); - expect(useNameWatchlistStore.getState().bids.nova).toMatchObject({ - amountStroops: '25000000', - saltHex: 'ab'.repeat(32), - revealed: false, - }); + // 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); }); - it('recovers from corrupt and unknown-version persisted data', async () => { - localStorage.setItem('wraith-name-auction-watchlist', '{not-json'); - const { useNameWatchlistStore, initNameWatchlistSync } = await import('./nameWatchlistStore'); - initNameWatchlistSync(); - await useNameWatchlistStore.persist.rehydrate(); - expect(useNameWatchlistStore.getState().watchedAuctions).toEqual([]); - expect(useNameWatchlistStore.getState().bids).toEqual({}); + it('persists submission status transitions with the encrypted recovery secret', async () => { + const { useNameWatchlistStore: StoreA } = await loadStore(); + const storeA = StoreA.getState(); + await storeA.setPassphrase(passphrase, iterations); + await storeA.saveBid({ ...gateBid, submissionStatus: 'pending' }); + await storeA.updateBidSubmission('gate', 'committed', 'stellar-hash'); - localStorage.setItem( - 'wraith-name-auction-watchlist', - JSON.stringify({ - version: 99, - state: { watchedAuctions: [{ name: 'future', endsAt: 1 }], bids: {} }, - }), - ); - await useNameWatchlistStore.persist.rehydrate(); - expect(useNameWatchlistStore.getState().watchedAuctions).toEqual([]); + expect(StoreA.getState().getBids().gate).toMatchObject({ + saltHex: gateBid.saltHex, + submissionStatus: 'committed', + transactionHash: 'stellar-hash', + }); + const raw = localStorage.getItem('wraith-name-auction-watchlist'); + expect(raw).not.toContain(gateBid.saltHex); + + const { useNameWatchlistStore: StoreB } = await loadStore(); + const storeB = StoreB.getState(); + storeB.inspectStorage(); + await storeB.unlock(passphrase); + expect(storeB.getBids().gate).toMatchObject({ + saltHex: gateBid.saltHex, + submissionStatus: 'committed', + transactionHash: 'stellar-hash', + }); + await storeB.updateBidSubmission('gate', 'unknown', 'possible-hash'); + expect(storeB.getBids().gate).toMatchObject({ + saltHex: gateBid.saltHex, + submissionStatus: 'unknown', + transactionHash: 'possible-hash', + }); }); }); diff --git a/src/store/nameWatchlistStore.tsx b/src/store/nameWatchlistStore.tsx index a450779..9c23a63 100644 --- a/src/store/nameWatchlistStore.tsx +++ b/src/store/nameWatchlistStore.tsx @@ -1,158 +1,582 @@ import { create } from 'zustand'; -import { persist, type PersistStorage } from 'zustand/middleware'; + +import type { AuctionBid } from '@/lib/auctionEncryption'; import { - MAX_COLLECTION_ITEMS, - isBoundedString, - isFiniteTimestamp, - readVersionedValue, - writeVersioned, -} from '../lib/versionedStorage'; + 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 interface LocalAuctionBid { - name: string; - amountStroops: string; - depositStroops: string; - saltHex: string; - revealed: boolean; +export type AuctionStatus = 'empty' | 'legacy' | 'locked' | 'unlocked' | 'error'; + +interface ActiveRecord { + watchedAuctions: WatchedNameAuction[]; + encryptedBids: EncryptedBidVault | null; } interface NameWatchlistState { watchedAuctions: WatchedNameAuction[]; - bids: Record; + 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: LocalAuctionBid) => void; - markBidRevealed: (name: string) => void; - removeBid: (name: string) => void; -} - -type PersistedWatchlist = Pick; - -function isPersistedWatchlist(value: unknown): value is PersistedWatchlist { - if (typeof value !== 'object' || value === null) return false; - const state = value as Record; - if ( - !Array.isArray(state.watchedAuctions) || - typeof state.bids !== 'object' || - state.bids === null - ) { - return false; + saveBid: (bid: AuctionBid) => Promise; + updateBidSubmission: ( + name: string, + submissionStatus: NonNullable, + transactionHash?: string, + ) => 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; } - const watched = state.watchedAuctions as unknown[]; - const bids = state.bids as Record; - return ( - watched.length <= MAX_COLLECTION_ITEMS && - Object.keys(bids).length <= MAX_COLLECTION_ITEMS && - watched.every((item) => { - if (typeof item !== 'object' || item === null) return false; - const auction = item as Record; - return ( - isBoundedString(auction.name, 128) && - auction.name.length > 0 && - isFiniteTimestamp(auction.endsAt) - ); - }) && - Object.values(bids).every((item) => { - if (typeof item !== 'object' || item === null) return false; - const bid = item as Record; - return ( - isBoundedString(bid.name, 128) && - isBoundedString(bid.amountStroops, 80) && - isBoundedString(bid.depositStroops, 80) && - isBoundedString(bid.saltHex, 256) && - typeof bid.revealed === 'boolean' - ); - }) +} + +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>(AUCTION_STORAGE_KEY); + if (!raw) return null; + const legacyState = + raw.version === 0 && raw.state && typeof raw.state === 'object' + ? (raw.state as Record) + : raw.version === AUCTION_VAULT_VERSION && raw.data && typeof raw.data === 'object' + ? (raw.data as Record) + : null; + if (!legacyState) return null; + const watchedAuctions = Array.isArray(legacyState.watchedAuctions) + ? legacyState.watchedAuctions + : []; + const bids = legacyState.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; + data?: unknown; + watchedAuctions?: unknown; + encryptedBids?: unknown; + }>(AUCTION_STORAGE_KEY); + if (!raw || raw.version !== AUCTION_VAULT_VERSION || raw.data) 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)), + }; } -const watchlistStorage: PersistStorage = { - getItem: (name) => { - const state = readVersionedValue( - localStorage, - name, - isPersistedWatchlist, - (value) => { - const legacy = value as { state?: unknown }; - return legacy.state; - }, - ); - return state ? { state: state as NameWatchlistState, version: 1 } : null; - }, - setItem: (name, value) => - writeVersioned(localStorage, name, { - watchedAuctions: value.state.watchedAuctions, - bids: value.state.bids, - }), - removeItem: (name) => localStorage.removeItem(name), -}; +async function persistEncrypted( + watchedAuctions: WatchedNameAuction[], + bids: Record, +): Promise { + const vault = await sealBids(bids); + writeActive({ watchedAuctions, encryptedBids: vault }); +} -const normalizeName = (name: string) => name.trim().toLowerCase(); +/** 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; +} -export const useNameWatchlistStore = create()( - persist( - (set) => ({ - watchedAuctions: [], +/** 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: {}, - watchAuction: (auction: WatchedNameAuction) => - set((state: NameWatchlistState) => { - const name = normalizeName(auction.name); - return { - watchedAuctions: [ - ...state.watchedAuctions.filter((item) => item.name !== name), - { ...auction, name }, - ], - }; - }), - unwatchAuction: (name: string) => - set((state: NameWatchlistState) => ({ - watchedAuctions: state.watchedAuctions.filter( - (item) => item.name !== normalizeName(name), - ), - })), - saveBid: (bid: LocalAuctionBid) => - set((state: NameWatchlistState) => { - const name = normalizeName(bid.name); - return { bids: { ...state.bids, [name]: { ...bid, name } } }; - }), - markBidRevealed: (name: string) => - set((state: NameWatchlistState) => { - const key = normalizeName(name); - const bid = state.bids[key]; - if (!bid) return state; - return { bids: { ...state.bids, [key]: { ...bid, revealed: true } } }; - }), - removeBid: (name: string) => - set((state: NameWatchlistState) => { - const bids = { ...state.bids }; - delete bids[normalizeName(name)]; - return { bids }; - }), - }), - { name: 'wraith-name-auction-watchlist', storage: watchlistStorage, version: 1 }, - ), -); - -let storageListener: ((e: StorageEvent) => void) | null = null; + 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 updateBidSubmission(name, submissionStatus, transactionHash) { + if (get().status !== 'unlocked') { + throw new Error('Vault is locked. Unlock with your passphrase before updating a bid.'); + } + const key = normalizeName(name); + const bid = get().bids[key]; + if (!bid) { + throw new Error(`No recovery record exists for ${key}.`); + } + const updatedBid: AuctionBid = { ...bid, submissionStatus }; + if ((submissionStatus === 'committed' || submissionStatus === 'unknown' || submissionStatus === 'pending') && transactionHash) { + updatedBid.transactionHash = transactionHash; + } else { + delete updatedBid.transactionHash; + } + const bids = { ...get().bids, [key]: updatedBid }; + await persistEncrypted(get().watchedAuctions, bids); + await verifyStoredBids(bids); + set({ bids }); + }, + + 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; + }, +})); + +let storageListener: ((event: StorageEvent) => void) | null = null; export function initNameWatchlistSync() { if (typeof window === 'undefined' || storageListener !== null) return; if (typeof window.addEventListener !== 'function') return; - storageListener = (e: StorageEvent) => { - if (e.key === 'wraith-name-auction-watchlist') { - if (e.newValue) { - useNameWatchlistStore.persist.rehydrate(); - } else { - useNameWatchlistStore.setState({ watchedAuctions: [], bids: {} }); - } - } else if (e.key === null) { - useNameWatchlistStore.setState({ watchedAuctions: [], bids: {} }); + storageListener = (event) => { + if (event.key === AUCTION_STORAGE_KEY || event.key === VAULT_STORAGE_KEY) { + useNameWatchlistStore.getState().inspectStorage(); + } else if (event.key === null) { + clearActiveKey(); + useNameWatchlistStore.getState().inspectStorage(); } };