diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95edd00..80c046f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,25 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm format:check - run: pnpm build + + unit-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable corepack (installs pnpm from packageManager field) + run: | + corepack enable + corepack prepare pnpm@10.28.2 --activate + pnpm --version + + - run: pnpm install --frozen-lockfile + - name: Run unit tests + run: pnpm test:unit + env: + CI: true diff --git a/src/App.tsx b/src/App.tsx index 0a664f0..f92e9fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { useNotificationSW } from '@/hooks/useNotificationSW'; import Schedule from '@/pages/Schedule'; import StellarSplit from '@/pages/StellarSplit'; import Names from '@/pages/Names'; +import NamesAuctions from '@/pages/NamesAuctions'; import Activity from '@/pages/Activity'; import Debug from '@/pages/Debug'; @@ -37,6 +38,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> 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..be9234a --- /dev/null +++ b/src/lib/auctionEncryption.ts @@ -0,0 +1,396 @@ +/** + * 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; +} + +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' + ); +} diff --git a/src/lib/stellar/names.ts b/src/lib/stellar/names.ts index d03aa7e..469a88b 100644 --- a/src/lib/stellar/names.ts +++ b/src/lib/stellar/names.ts @@ -3,14 +3,17 @@ import { Account, Contract, nativeToScVal, + scValToNative, Address, xdr, } from '@stellar/stellar-sdk'; +import { Buffer } from 'buffer'; import { STELLAR_NETWORK } from '@/config'; -// Wraith Names contract ID on Stellar Testnet -// TODO: Replace with actual contract ID from deployment -export const NAMES_CONTRACT_ID = 'CD3Z7J2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; +// Override for futurenet recordings or a new deployment without rebuilding this module. +export const NAMES_CONTRACT_ID = + import.meta.env.VITE_WRAITH_NAMES_CONTRACT_ID || + 'CD3Z7J2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; export interface NameMetadata { avatar_url?: string; @@ -45,6 +48,236 @@ export interface MetadataParams { metadata: NameMetadata; } +export interface NameAuction { + name: string; + commitEnd: number; + revealEnd: number; + highestBidder: string | null; + highestAmount: bigint; + settled: boolean; +} + +export interface NameAuctionConfig { + reservePrice: bigint; + minBidIncrement: bigint; + commitSecs: number; + revealSecs: number; +} + +export interface CommitBidParams { + name: string; + commitmentHex: string; + deposit: bigint; +} + +export interface RevealBidParams { + name: string; + amount: bigint; + saltHex: string; +} + +export const MIN_AUCTION_BID_INCREMENT = 1_000_000n; // 0.1 XLM in stroops + +async function getSourceAccount(fromAddress: string) { + const accountRes = await fetch(`${STELLAR_NETWORK.horizonUrl}/accounts/${fromAddress}`); + if (!accountRes.ok) throw new Error('Failed to load account'); + const accountData = (await accountRes.json()) as { sequence: string }; + return new Account(fromAddress, accountData.sequence); +} + +async function simulateRead(operation: xdr.Operation): Promise { + const { rpc } = await import('@stellar/stellar-sdk'); + const server = new rpc.Server(STELLAR_NETWORK.rpcUrl); + const tx = new TransactionBuilder( + new Account('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWH', '0'), + { fee: '100', networkPassphrase: STELLAR_NETWORK.networkPassphrase }, + ) + .addOperation(operation) + .setTimeout(30) + .build(); + const simulation = await server.simulateTransaction(tx); + if ('error' in simulation) throw new Error(simulation.error); + if (!simulation.result) throw new Error('Contract returned no result'); + return scValToNative(simulation.result.retval) as T; +} + +async function buildAuctionTransaction(fromAddress: string, operation: xdr.Operation) { + const { rpc } = await import('@stellar/stellar-sdk'); + const server = new rpc.Server(STELLAR_NETWORK.rpcUrl); + const sourceAccount = await getSourceAccount(fromAddress); + const tx = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase: STELLAR_NETWORK.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + const simulation = await server.simulateTransaction(tx); + if ('error' in simulation) throw new Error(simulation.error); + return rpc.assembleTransaction(tx, simulation).build().toXDR(); +} + +function bytesScVal(hex: string) { + const normalized = hex.startsWith('0x') ? hex.slice(2) : hex; + if (!/^[0-9a-f]{64}$/i.test(normalized)) throw new Error('Expected a 32-byte hex value'); + return xdr.ScVal.scvBytes(Buffer.from(normalized, 'hex')); +} + +function normalizeAuction(value: Record | null): NameAuction | null { + if (!value) return null; + return { + name: String(value.name ?? ''), + commitEnd: Number(value.commit_end ?? 0), + revealEnd: Number(value.reveal_end ?? 0), + highestBidder: value.highest_bidder ? String(value.highest_bidder) : null, + highestAmount: BigInt(String(value.highest_amount ?? 0)), + settled: Boolean(value.settled), + }; +} + +export async function getNameAuction(name: string): Promise { + const contract = new Contract(NAMES_CONTRACT_ID); + const value = await simulateRead | null>( + contract.call('get_auction', nativeToScVal(name.trim().toLowerCase())), + ); + return normalizeAuction(value); +} + +export async function getNameAuctionConfig(): Promise { + const contract = new Contract(NAMES_CONTRACT_ID); + const value = await simulateRead | null>(contract.call('auction_config')); + if (!value) return null; + return { + reservePrice: BigInt(String(value.reserve_price ?? 0)), + minBidIncrement: BigInt( + String(value.min_increment ?? value.min_bid_increment ?? MIN_AUCTION_BID_INCREMENT), + ), + commitSecs: Number(value.commit_secs ?? 0), + revealSecs: Number(value.reveal_secs ?? 0), + }; +} + +export async function getActiveNameAuctions(names: string[]): Promise { + const contract = new Contract(NAMES_CONTRACT_ID); + try { + const values = await simulateRead>>( + contract.call('get_active_auctions'), + ); + if (Array.isArray(values)) { + return values + .map((value) => normalizeAuction(value)) + .filter((auction): auction is NameAuction => auction !== null); + } + } catch { + // Current sealed-bid deployments expose lookup-by-name only. Fall back to + // the locally tracked names while remaining compatible with enumerable ABIs. + } + + const uniqueNames = [...new Set(names.map((name) => name.trim().toLowerCase()).filter(Boolean))]; + const auctions = await Promise.all( + uniqueNames.map(async (name) => { + try { + return await getNameAuction(name); + } catch { + return null; + } + }), + ); + return auctions.filter((auction): auction is NameAuction => auction !== null); +} + +export async function computeNameBidCommitment( + bidder: string, + name: string, + amount: bigint, + saltHex: string, +): Promise { + const contract = new Contract(NAMES_CONTRACT_ID); + const commitment = await simulateRead( + contract.call( + 'compute_commitment', + nativeToScVal(name.trim().toLowerCase()), + new Address(bidder).toScVal(), + nativeToScVal(amount, { type: 'i128' }), + bytesScVal(saltHex), + ), + ); + return Buffer.from(commitment).toString('hex'); +} + +export async function buildCommitNameBidTransaction( + bidder: string, + params: CommitBidParams, +): Promise { + const contract = new Contract(NAMES_CONTRACT_ID); + return buildAuctionTransaction( + bidder, + contract.call( + 'commit_bid', + new Address(bidder).toScVal(), + nativeToScVal(params.name.trim().toLowerCase()), + bytesScVal(params.commitmentHex), + nativeToScVal(params.deposit, { type: 'i128' }), + ), + ); +} + +export async function buildRevealNameBidTransaction( + bidder: string, + params: RevealBidParams, +): Promise { + const contract = new Contract(NAMES_CONTRACT_ID); + return buildAuctionTransaction( + bidder, + contract.call( + 'reveal_bid', + new Address(bidder).toScVal(), + nativeToScVal(params.name.trim().toLowerCase()), + nativeToScVal(params.amount, { type: 'i128' }), + bytesScVal(params.saltHex), + ), + ); +} + +export async function buildSettleNameAuctionTransaction(fromAddress: string, name: string) { + const contract = new Contract(NAMES_CONTRACT_ID); + return buildAuctionTransaction( + fromAddress, + contract.call('settle_auction', nativeToScVal(name.trim().toLowerCase())), + ); +} + +export async function buildRefundNameBidTransaction(bidder: string, name: string) { + const contract = new Contract(NAMES_CONTRACT_ID); + return buildAuctionTransaction( + bidder, + contract.call( + 'withdraw_bid', + new Address(bidder).toScVal(), + nativeToScVal(name.trim().toLowerCase()), + ), + ); +} + +export async function buildClaimAuctionNameTransaction( + winner: string, + name: string, + stealthMetaAddress: Uint8Array, +) { + if (stealthMetaAddress.length !== 64) + throw new Error('A 64-byte stealth meta-address is required'); + const contract = new Contract(NAMES_CONTRACT_ID); + return buildAuctionTransaction( + winner, + contract.call( + 'claim_name', + new Address(winner).toScVal(), + nativeToScVal(name.trim().toLowerCase()), + xdr.ScVal.scvBytes(Buffer.from(stealthMetaAddress)), + ), + ); +} + /** * Check if a name is available */ diff --git a/src/pages/Names.tsx b/src/pages/Names.tsx index 62d3d40..3cdb016 100644 --- a/src/pages/Names.tsx +++ b/src/pages/Names.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; +import { Link } from 'react-router-dom'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { stellarTxUrl } from '@/lib/explorer'; import { @@ -13,6 +14,7 @@ import { type NameMetadata, } from '@/lib/stellar/names'; import { CopyButton } from '@/components/CopyButton'; +import { useNameWatchlistStore } from '@/store/nameWatchlistStore'; const DEFAULT_REGISTRATION_DURATION = 365 * 24 * 60 * 60; // 1 year in seconds @@ -32,6 +34,11 @@ export default function Names() { const [error, setError] = useState(''); const [txHash, setTxHash] = useState(null); const [isSuccess, setIsSuccess] = useState(false); + const watchedAuctions = useNameWatchlistStore((state) => state.watchedAuctions); + const now = Math.floor(Date.now() / 1000); + const endingSoonAuctions = watchedAuctions.filter( + (auction) => auction.endsAt > now && auction.endsAt - now < 24 * 60 * 60, + ); // Register form const [registerName, setRegisterName] = useState(''); @@ -267,8 +274,36 @@ export default function Names() {

Register, transfer, and manage your Wraith Names. Set metadata to customize your identity.

+ + Browse name auctions + + {endingSoonAuctions.length > 0 && ( +
+
+
+ + Watched auctions ending soon + +

+ {endingSoonAuctions.map((auction) => `${auction.name}.wraith`).join(', ')}{' '} + {endingSoonAuctions.length === 1 ? 'ends' : 'end'} in under 24 hours. +

+
+ + View + +
+
+ )} + {/* Tabs */}
{(['list', 'register', 'transfer', 'metadata'] as Tab[]).map((tab) => ( diff --git a/src/pages/NamesAuctions.tsx b/src/pages/NamesAuctions.tsx new file mode 100644 index 0000000..6c9e403 --- /dev/null +++ b/src/pages/NamesAuctions.tsx @@ -0,0 +1,879 @@ +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, type AuctionBid } from '@/store/nameWatchlistStore'; +import { stellarTxUrl } from '@/lib/explorer'; +import { + MIN_AUCTION_BID_INCREMENT, + NAMES_CONTRACT_ID, + buildClaimAuctionNameTransaction, + buildCommitNameBidTransaction, + buildRefundNameBidTransaction, + buildRevealNameBidTransaction, + buildSettleNameAuctionTransaction, + computeNameBidCommitment, + getActiveNameAuctions, + getNameAuction, + getNameAuctionConfig, + submitTransaction, + type NameAuction, + type NameAuctionConfig, +} from '@/lib/stellar/names'; + +const STROOPS_PER_XLM = 10_000_000n; + +function parseXlm(value: string): bigint | null { + if (!/^(?:0|[1-9]\d*)(?:\.\d{1,7})?$/.test(value.trim())) return null; + const [whole, fraction = ''] = value.trim().split('.'); + return BigInt(whole) * STROOPS_PER_XLM + BigInt(fraction.padEnd(7, '0')); +} + +function formatXlm(stroops: bigint) { + const whole = stroops / STROOPS_PER_XLM; + const fraction = (stroops % STROOPS_PER_XLM).toString().padStart(7, '0').replace(/0+$/, ''); + return fraction ? `${whole}.${fraction} XLM` : `${whole} XLM`; +} + +function formatEndsAt(timestamp: number) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(timestamp * 1000)); +} + +function phaseFor(auction: NameAuction, now: number) { + if (auction.settled) return 'settled'; + if (now < auction.commitEnd) return 'commit'; + if (now < auction.revealEnd) return 'reveal'; + return 'ended'; +} + +export default function NamesAuctions() { + const { address, isConnected, signTransaction } = useStellarWallet(); + 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 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)); + const [isLoading, setIsLoading] = useState(true); + const [pendingAction, setPendingAction] = useState(''); + const [error, setError] = useState(''); + const [txHash, setTxHash] = useState(''); + const [lookupName, setLookupName] = useState(''); + 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], + ); + const trackedNamesKey = trackedNames.join('|'); + const isUnknownContract = + !isKnownAddress(NAMES_CONTRACT_ID) && !isKnownRecipient(NAMES_CONTRACT_ID); + const unlocked = status === 'unlocked'; + + const refreshAuctions = useCallback(async () => { + setIsLoading(true); + setError(''); + try { + const [nextAuctions, nextConfig] = await Promise.all([ + getActiveNameAuctions(trackedNames), + getNameAuctionConfig(), + ]); + setAuctions(nextAuctions.sort((a, b) => a.revealEnd - b.revealEnd)); + setConfig(nextConfig); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Failed to load auctions'); + } finally { + setIsLoading(false); + } + }, [trackedNamesKey]); + + useEffect(() => { + inspectStorage(); + }, [inspectStorage]); + + useEffect(() => { + void refreshAuctions(); + }, [refreshAuctions]); + + useEffect(() => { + const timer = globalThis.setInterval(() => setNow(Math.floor(Date.now() / 1000)), 30_000); + return () => globalThis.clearInterval(timer); + }, []); + + const trackAuction = async () => { + const name = lookupName + .trim() + .toLowerCase() + .replace(/\.wraith$/, ''); + if (!name) return; + setPendingAction(`track:${name}`); + setError(''); + try { + const auction = await getNameAuction(name); + if (!auction) throw new Error(`No auction exists for ${name}.wraith`); + watchAuction({ name: auction.name, endsAt: auction.revealEnd }); + setLookupName(''); + } catch (trackError) { + setError(trackError instanceof Error ? trackError.message : 'Failed to find auction'); + } finally { + setPendingAction(''); + } + }; + + const submitAuctionTransaction = async (action: string, buildXdr: () => Promise) => { + setPendingAction(action); + setError(''); + setTxHash(''); + try { + const xdr = await buildXdr(); + const signedXdr = await signTransaction(xdr); + const hash = await submitTransaction(signedXdr); + setTxHash(hash); + await refreshAuctions(); + return true; + } catch (transactionError) { + setError(transactionError instanceof Error ? transactionError.message : 'Transaction failed'); + return false; + } finally { + setPendingAction(''); + } + }; + + const minimumBid = selectedAuction + ? [ + config?.reservePrice ?? 0n, + selectedAuction.highestAmount > 0n + ? selectedAuction.highestAmount + (config?.minBidIncrement ?? MIN_AUCTION_BID_INCREMENT) + : 0n, + ].reduce((highest, value) => (value > highest ? value : highest), 0n) + : 0n; + const parsedBid = parseXlm(bidAmount); + const bidError = + bidAmount && parsedBid === null + ? 'Enter an XLM amount with no more than 7 decimal places.' + : parsedBid !== null && parsedBid < minimumBid + ? `Minimum bid is ${formatXlm(minimumBid)}.` + : ''; + + 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(''); + try { + const salt = globalThis.crypto.getRandomValues(new Uint8Array(32)); + const saltHex = Array.from(salt, (byte) => byte.toString(16).padStart(2, '0')).join(''); + const commitmentHex = await computeNameBidCommitment( + address, + selectedAuction.name, + parsedBid, + saltHex, + ); + // Persist and verify the recovery record BEFORE signing or broadcasting the + // on-chain commit. If storage or verification fails, stop here and show an + // error: no transaction is ever submitted without a durable recovery record. + await saveBid({ + name: selectedAuction.name, + amountStroops: parsedBid.toString(), + depositStroops: parsedBid.toString(), + saltHex, + revealed: false, + }); + const submitted = await submitAuctionTransaction(action, () => + buildCommitNameBidTransaction(address, { + name: selectedAuction.name, + commitmentHex, + deposit: parsedBid, + }), + ); + if (submitted) { + watchAuction({ name: selectedAuction.name, endsAt: selectedAuction.revealEnd }); + setSelectedAuction(null); + setBidAmount(''); + } + // If submission fails or its outcome is ambiguous, the persisted recovery + // record is retained so the bid can be retried or revealed after a reload. + } catch (commitmentError) { + setError( + commitmentError instanceof Error ? commitmentError.message : 'Failed to prepare bid', + ); + } finally { + setPendingAction(''); + } + }; + + 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, + amount: BigInt(bid.amountStroops), + saltHex: bid.saltHex, + }), + ); + 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) => { + if (!address) return; + await submitAuctionTransaction(`settle:${auction.name}`, () => + buildSettleNameAuctionTransaction(address, auction.name), + ); + }; + + const claimName = async (auction: NameAuction) => { + if (!address || !stellarMetaAddress) { + setError('Derive your Stellar stealth keys before claiming this name.'); + return; + } + const decoded = decodeStealthMetaAddress(stellarMetaAddress); + const metaAddress = new Uint8Array(64); + metaAddress.set(decoded.spendingPubKey, 0); + metaAddress.set(decoded.viewingPubKey, 32); + const submitted = await submitAuctionTransaction(`claim:${auction.name}`, () => + buildClaimAuctionNameTransaction(address, auction.name, metaAddress), + ); + if (submitted) { + try { + await removeBid(auction.name); + } catch { + // claim submitted; local bid removal is best-effort + } + } + }; + + const refundBid = async (auction: NameAuction) => { + if (!address) return; + const submitted = await submitAuctionTransaction(`refund:${auction.name}`, () => + buildRefundNameBidTransaction(address, 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 ( +
+ + Stellar / Names / Auctions + +

+ Name Auctions +

+

+ Connect your Stellar wallet to bid on premium Wraith Names. +

+
+ ); + } + + return ( +
+
+ + Stellar / Names / Auctions + +

+ Name Auctions +

+

+ Bid privately during the commit phase, reveal your bid, then claim the name or refund your + deposit after the auction. Bid recovery material (salt, amount, deposit) is encrypted with + your passphrase and never written to localStorage in plaintext. +

+
+ + {/* Recovery vault controls */} +
+
+
+ + Recovery vault + + + [{statusLabel}] + +
+ {status === 'unlocked' && ( + + )} +
+ + {vaultError && ( +

+ {vaultError} +

+ )} + + {status === 'empty' || status === 'legacy' || status === 'locked' ? ( +
+
+ + 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 + +

+ )} + +
+ + setLookupName(event.target.value.toLowerCase().replace(/[^a-z0-9.-]/g, '')) + } + onKeyDown={(event) => { + if (event.key === 'Enter') void trackAuction(); + }} + placeholder="Find a premium name auction" + className="h-11 flex-1 border border-outline-variant bg-surface px-3 font-mono text-sm text-primary placeholder:text-outline" + /> + +
+ + {revealPending && ( +

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

+ )} + +
+ {isLoading ? ( +

Loading auctions...

+ ) : auctions.length === 0 ? ( +
+

+ Track a premium name to see its active auction. +

+
+ ) : ( + auctions.map((auction) => { + const phase = phaseFor(auction, now); + const bid = bids[auction.name]; + const isWinner = auction.highestBidder === address; + const isWatched = watchedAuctions.some((item) => item.name === auction.name); + return ( +
+
+
+

+ {auction.name}.wraith +

+ + {phase} + +
+ +
+ +
+
+
+ Top bid +
+
+ {auction.highestAmount ? formatXlm(auction.highestAmount) : 'Not revealed'} +
+
+
+
+ Ends at +
+
+ {formatEndsAt(auction.revealEnd)} +
+
+
+
+ Your bid +
+
+ {bid ? formatXlm(BigInt(bid.amountStroops)) : '—'} +
+
+
+ +
+ {phase === 'commit' && !bid && ( + + )} + {phase === 'commit' && bid && ( + + Bid committed — return to reveal it + + )} + {phase === 'reveal' && bid && !bid.revealed && ( + + )} + {phase === 'ended' && ( + + )} + {phase === 'settled' && isWinner && ( + + )} + {(phase === 'ended' || phase === 'settled') && bid && !isWinner && ( + + )} +
+
+ ); + }) + )} +
+ + {selectedAuction && ( +
+
+
+
+

+ Place bid +

+

{selectedAuction.name}.wraith

+
+ +
+ + {unlocked ? null : ( +
+

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

+
+ )} + + + setBidAmount(event.target.value)} + placeholder="0.0" + 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" + /> +

+ {bidError || `Minimum bid: ${formatXlm(minimumBid)}`} +

+ + {isUnknownContract && ( +
+ +
+

+ You haven't paid this recipient before +

+

+ This bid sends funds to the Wraith Names contract. Verify the contract and + network before approving the transaction. +

+
+
+ )} + +

+ Your amount and recovery secret stay encrypted in this browser until reveal. Clearing + site data before revealing can make the bid unrecoverable. Export a backup after + setting your passphrase. +

+ + {error && ( +

+ {error} +

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