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/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..75bc0e4 --- /dev/null +++ b/src/pages/NamesAuctions.tsx @@ -0,0 +1,555 @@ +import { useCallback, useEffect, useMemo, 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 { 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 [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(''); + + 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 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(() => { + 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 (!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, + ); + 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, + }); + watchAuction({ name: selectedAuction.name, endsAt: selectedAuction.revealEnd }); + setSelectedAuction(null); + setBidAmount(''); + } + } catch (commitmentError) { + setError( + commitmentError instanceof Error ? commitmentError.message : 'Failed to prepare bid', + ); + } finally { + setPendingAction(''); + } + }; + + const revealBid = async (auction: NameAuction) => { + if (!address) return; + const bid = bids[auction.name]; + if (!bid) return; + const submitted = await submitAuctionTransaction(`reveal:${auction.name}`, () => + buildRevealNameBidTransaction(address, { + name: auction.name, + amount: BigInt(bid.amountStroops), + saltHex: bid.saltHex, + }), + ); + if (submitted) markBidRevealed(auction.name); + }; + + 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) removeBid(auction.name); + }; + + const refundBid = async (auction: NameAuction) => { + if (!address) return; + const submitted = await submitAuctionTransaction(`refund:${auction.name}`, () => + buildRefundNameBidTransaction(address, auction.name), + ); + if (submitted) removeBid(auction.name); + }; + + 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. +

+
+ +
+ + 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" + /> + +
+ + {error && ( +

+ {error} +

+ )} + {txHash && ( +

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

+ )} + +
+ {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

+
+ +
+ + + 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 in this browser until reveal. Clearing site data + before revealing can make the bid unrecoverable. +

+ + {error && ( +

+ {error} +

+ )} + + +
+
+ )} +
+ ); +} diff --git a/src/store/nameWatchlistStore.test.ts b/src/store/nameWatchlistStore.test.ts new file mode 100644 index 0000000..4cf3f9d --- /dev/null +++ b/src/store/nameWatchlistStore.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +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); + } +} + +describe('name auction watchlist', () => { + beforeEach(() => { + vi.resetModules(); + const storage = new MemoryStorage(); + vi.stubGlobal('localStorage', storage); + vi.stubGlobal('window', { localStorage: storage }); + }); + + it('restores watched auctions and sealed bid recovery data', async () => { + const { useNameWatchlistStore } = await import('./nameWatchlistStore'); + 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, + }); + + 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, + }); + }); +}); diff --git a/src/store/nameWatchlistStore.tsx b/src/store/nameWatchlistStore.tsx new file mode 100644 index 0000000..764c2ce --- /dev/null +++ b/src/store/nameWatchlistStore.tsx @@ -0,0 +1,71 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface WatchedNameAuction { + name: string; + endsAt: number; +} + +export interface LocalAuctionBid { + name: string; + amountStroops: string; + depositStroops: string; + saltHex: string; + revealed: boolean; +} + +interface NameWatchlistState { + watchedAuctions: WatchedNameAuction[]; + bids: Record; + watchAuction: (auction: WatchedNameAuction) => void; + unwatchAuction: (name: string) => void; + saveBid: (bid: LocalAuctionBid) => void; + markBidRevealed: (name: string) => void; + removeBid: (name: string) => void; +} + +const normalizeName = (name: string) => name.trim().toLowerCase(); + +export const useNameWatchlistStore = create()( + persist( + (set) => ({ + watchedAuctions: [], + bids: {}, + watchAuction: (auction) => + set((state) => { + const name = normalizeName(auction.name); + return { + watchedAuctions: [ + ...state.watchedAuctions.filter((item) => item.name !== name), + { ...auction, name }, + ], + }; + }), + unwatchAuction: (name) => + set((state) => ({ + watchedAuctions: state.watchedAuctions.filter( + (item) => item.name !== normalizeName(name), + ), + })), + saveBid: (bid) => + set((state) => { + const name = normalizeName(bid.name); + return { bids: { ...state.bids, [name]: { ...bid, name } } }; + }), + markBidRevealed: (name) => + set((state) => { + const key = normalizeName(name); + const bid = state.bids[key]; + if (!bid) return state; + return { bids: { ...state.bids, [key]: { ...bid, revealed: true } } }; + }), + removeBid: (name) => + set((state) => { + const bids = { ...state.bids }; + delete bids[normalizeName(name)]; + return { bids }; + }), + }), + { name: 'wraith-name-auction-watchlist' }, + ), +);