Register, transfer, and manage your Wraith Names. Set metadata to customize your identity.
+
{(['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' },
+ ),
+);