Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -37,6 +38,7 @@ export function App() {
<Route path="/stellar/split" element={<StellarSplit />} />
<Route path="/pay" element={<Send />} />
<Route path="/names" element={<Names />} />
<Route path="/names/auctions" element={<NamesAuctions />} />
<Route path="/activity" element={<Activity />} />
<Route path="/history" element={<Activity />} />
<Route path="/debug" element={<Debug />} />
Expand Down
239 changes: 236 additions & 3 deletions src/lib/stellar/names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<T>(operation: xdr.Operation): Promise<T> {
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<string, unknown> | 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<NameAuction | null> {
const contract = new Contract(NAMES_CONTRACT_ID);
const value = await simulateRead<Record<string, unknown> | null>(
contract.call('get_auction', nativeToScVal(name.trim().toLowerCase())),
);
return normalizeAuction(value);
}

export async function getNameAuctionConfig(): Promise<NameAuctionConfig | null> {
const contract = new Contract(NAMES_CONTRACT_ID);
const value = await simulateRead<Record<string, unknown> | 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<NameAuction[]> {
const contract = new Contract(NAMES_CONTRACT_ID);
try {
const values = await simulateRead<Array<Record<string, unknown>>>(
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<string> {
const contract = new Contract(NAMES_CONTRACT_ID);
const commitment = await simulateRead<Uint8Array>(
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<string> {
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<string> {
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
*/
Expand Down
35 changes: 35 additions & 0 deletions src/pages/Names.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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

Expand All @@ -32,6 +34,11 @@ export default function Names() {
const [error, setError] = useState('');
const [txHash, setTxHash] = useState<string | null>(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('');
Expand Down Expand Up @@ -267,8 +274,36 @@ export default function Names() {
<p className="font-body text-sm leading-relaxed text-on-surface-variant">
Register, transfer, and manage your Wraith Names. Set metadata to customize your identity.
</p>
<Link
to="/names/auctions"
className="mt-2 w-fit font-heading text-[10px] font-semibold uppercase tracking-widest text-primary underline"
>
Browse name auctions
</Link>
</div>

{endingSoonAuctions.length > 0 && (
<div className="border border-tertiary bg-surface-container p-4">
<div className="flex items-center justify-between gap-4">
<div>
<span className="font-heading text-xs font-semibold uppercase tracking-widest text-on-surface">
Watched auctions ending soon
</span>
<p className="mt-2 font-body text-sm text-on-surface-variant">
{endingSoonAuctions.map((auction) => `${auction.name}.wraith`).join(', ')}{' '}
{endingSoonAuctions.length === 1 ? 'ends' : 'end'} in under 24 hours.
</p>
</div>
<Link
to="/names/auctions"
className="shrink-0 font-heading text-[10px] uppercase tracking-widest text-primary underline"
>
View
</Link>
</div>
</div>
)}

{/* Tabs */}
<div className="flex gap-0 border-b border-outline-variant">
{(['list', 'register', 'transfer', 'metadata'] as Tab[]).map((tab) => (
Expand Down
Loading
Loading