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
73 changes: 66 additions & 7 deletions frontend/components/SendPaymentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,11 @@ function SendPaymentForm({
const [isResolvingDestination, setIsResolvingDestination] = useState(false);
const [destinationResolutionError, setDestinationResolutionError] = useState<string | null>(null);
const [resolvedPaymentDestination, setResolvedPaymentDestination] = useState<string | null>(null);
// SNS inline resolution state: tracks the resolved address shown below the
// destination field when a .xlm name or federation address is entered.
const [snsResolvedAddress, setSnsResolvedAddress] = useState<string | null>(null);
// SNS-specific state: live resolution preview as the user types
const [snsResolving, setSnsResolving] = useState(false);
const [snsResolved, setSnsResolved] = useState<string | null>(null);
const [snsError, setSnsError] = useState<string | null>(null);
const snsDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [customAsset, setCustomAsset] = useState<CustomAsset>({ code: "", issuer: "" });
const [showCustomAssetForm, setShowCustomAssetForm] = useState(false);
const [selectedMemoTemplate, setSelectedMemoTemplate] = useState<string | null>(null);
Expand Down Expand Up @@ -501,9 +502,10 @@ function SendPaymentForm({
const isMemoValid = memoBytes <= 28;

const canSubmit =
(isValidDest || isFederationDestination || isUsernameDestination || (isStellarName(trimmedDestination) && !!snsResolvedAddress)) &&
(isValidDest || isFederationDestination || isUsernameDestination || (isStellarName(trimmedDestination) && !!snsResolved)) &&
!isResolvingDestination &&
!snsResolving &&
!snsError &&
!destinationResolutionError &&
isValidAmt &&
status === "idle" &&
Expand Down Expand Up @@ -546,6 +548,15 @@ function SendPaymentForm({

setIsResolvingDestination(true);
try {
// If we already resolved the SNS name in the preview, reuse it
if (isStellarName(trimmedDestination) && snsResolved) {
return snsResolved;
}

if (isStellarName(trimmedDestination)) {
return await resolveStellarName(trimmedDestination);
}

if (isFederationDestination) {
return await resolveFederationAddress(trimmedDestination);
}
Expand Down Expand Up @@ -626,7 +637,9 @@ function SendPaymentForm({
setAmount("");
setMemo("");
setResolvedPaymentDestination(null);
setSnsResolvedAddress(null);
setSnsResolved(null);
setSnsError(null);
setSnsResolving(false);
}
setStatus("idle");
};
Expand Down Expand Up @@ -928,15 +941,45 @@ function SendPaymentForm({
type="text"
value={destination}
onChange={(e) => {
setDestination(e.target.value);
const val = e.target.value;
setDestination(val);
setDestinationResolutionError(null);
setResolvedPaymentDestination(null);
setSnsResolvedAddress(null);
setDestAccountWarning(null);
setIsContactsDropdownOpen(true);

// SNS live resolution: trigger for federation/SNS patterns
const trimmed = val.trim();
const looksLikeRawAddress = trimmed.startsWith("G") && trimmed.length === 56;
if (isStellarName(trimmed) && !looksLikeRawAddress) {
// Clear previous SNS state
setSnsResolved(null);
setSnsError(null);
if (snsDebounceRef.current) clearTimeout(snsDebounceRef.current);
setSnsResolving(true);
snsDebounceRef.current = setTimeout(() => {
resolveStellarName(trimmed)
.then((address) => {
setSnsResolved(address);
setSnsError(null);
})
.catch((err: unknown) => {
setSnsResolved(null);
setSnsError(err instanceof Error ? err.message : "Name not found or invalid");
})
.finally(() => setSnsResolving(false));
}, 600);
} else {
// Not an SNS name — clear SNS state
if (snsDebounceRef.current) clearTimeout(snsDebounceRef.current);
setSnsResolving(false);
setSnsResolved(null);
setSnsError(null);
}
}}
onFocus={() => setIsContactsDropdownOpen(true)}
placeholder={t("dest_placeholder")}
placeholder="G... address or alice.xlm"
className={clsx(
"input-field font-mono text-sm",
destination &&
Expand All @@ -949,6 +992,22 @@ function SendPaymentForm({
onBlur={runImmediateDestinationValidation}
/>

{/* SNS resolution feedback */}
{snsResolving && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-slate-400">
<div className="w-3 h-3 border border-stellar-400 border-t-transparent rounded-full animate-spin" />
Resolving…
</div>
)}
{!snsResolving && snsResolved && (
<p className="mt-1.5 text-xs text-slate-400">
Resolves to: <span className="font-mono text-stellar-300">{snsResolved}</span> ✓
</p>
)}
{!snsResolving && snsError && (
<p className="mt-1.5 text-xs text-red-400">{snsError}</p>
)}

{destinationResolutionError && (
<p className="mt-2 text-xs text-red-400">{destinationResolutionError}</p>
)}
Expand Down
149 changes: 47 additions & 102 deletions frontend/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1793,137 +1793,82 @@ export async function fetchNetworkStats(): Promise<NetworkStats> {

// ── Stellar Name Service ──────────────────────────────────────────────────

/**
* Cached resolution entry for a Stellar name.
*
* @property name - The original name string as entered by the user.
* @property address - The resolved Stellar public key (G...).
* @property resolvedAt - Unix epoch milliseconds when the resolution occurred (used for TTL).
*/
export interface ResolvedName {
name: string;
address: string;
resolvedAt: number;
}

const snsCache = new Map<string, ResolvedName>();
const SNS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
/** TTL for SNS resolution cache: 10 minutes */
const SNS_CACHE_TTL_MS = 600_000;

/**
* Clear the in-memory SNS resolution cache.
*
* Primarily useful in tests to avoid cross-test pollution when mocking timers
* or resolution results.
* Module-level cache for resolved Stellar names.
* Entries expire after {@link SNS_CACHE_TTL_MS}.
* Survives across renders; resets on page reload.
*/
export function clearNameCache(): void {
snsCache.clear();
}
export const resolvedNameCache = new Map<string, { address: string; expiry: number }>();

/**
* Resolve a human-readable Stellar name to a public key.
*
* Supports two input formats:
* - **Federation addresses** — the native `name*domain.com` SEP-0002 format
* supported by any wallet that publishes a `stellar.toml`. Passed directly
* to `Federation.Server.resolve`.
* - **`.xlm` shorthand** — a convenience alias (e.g. `alice.xlm`) that is
* translated to `alice*stellarnames.org` before resolution. StellarNames
* (stellarnames.org) is a community-run federation server for the `.xlm`
* namespace. This mapping is documented here so it is easy to swap for
* another provider if needed.
*
* Raw `G...` public keys bypass resolution entirely and are returned as-is,
* so callers can pass any user input without pre-checking the format.
* Resolves a human-readable Stellar name to a public key (G... address).
*
* Results are cached for {@link SNS_CACHE_TTL_MS} (10 minutes) to avoid
* redundant network lookups on every keystroke. Use {@link clearNameCache}
* to invalidate the cache in tests.
* - Accepts federation addresses: `alice*domain.com`
* - Accepts `.xlm` shorthand: `alice.xlm` → resolved via `alice*xlm.money`
* - Results are cached for 10 minutes in {@link resolvedNameCache}
*
* @param name - A `.xlm` name, `name*domain.com` federation address, or raw
* Stellar public key.
* @returns A promise resolving to the Stellar public key (G...).
* @throws {Error} If the name cannot be resolved to a valid public key.
*
* @example
* ```ts
* const address = await resolveStellarName("alice.xlm");
* // → "GABC...XYZ"
*
* const same = await resolveStellarName("alice*stellarnames.org");
* // → "GABC...XYZ"
*
* // Raw addresses bypass resolution
* const raw = await resolveStellarName("GABC...XYZ");
* // → "GABC...XYZ"
* ```
* @param name - Federation address or `.xlm` shorthand
* @returns The resolved Stellar public key (account_id)
* @throws {Error} If the name is invalid or cannot be resolved
*/
export async function resolveStellarName(name: string): Promise<string> {
const trimmed = name.trim();

// Raw Stellar public keys bypass resolution entirely
if (isValidStellarAddress(trimmed)) return trimmed;

const key = trimmed.toLowerCase();

// Cache hit within TTL
const cached = snsCache.get(key);
if (cached && Date.now() - cached.resolvedAt < SNS_CACHE_TTL_MS) {
return cached.address;
if (!trimmed) {
throw new Error("Name cannot be empty.");
}

// Determine the federation address to look up
let federationAddress = trimmed;
if (trimmed.toLowerCase().endsWith(".xlm")) {
// alice.xlm → alice*stellarnames.org
// StellarNames (https://stellarnames.org) is a community federation server
// for the .xlm namespace. Update this mapping to switch providers.
const localPart = trimmed.slice(0, trimmed.lastIndexOf("."));
federationAddress = `${localPart}*stellarnames.org`;
} else if (!trimmed.includes("*")) {
throw new Error(`Could not resolve "${trimmed}" to a Stellar address`);
// Return as-is if already a valid raw Stellar address
if (isValidStellarAddress(trimmed)) return trimmed;

// Check cache first
const cached = resolvedNameCache.get(trimmed);
if (cached && cached.expiry > Date.now()) return cached.address;

// Determine canonical federation address
let federationAddress: string;
if (trimmed.endsWith(".xlm")) {
// alice.xlm → alice*xlm.money (xlm.money is the public SNS resolver for .xlm handles)
const localPart = trimmed.slice(0, trimmed.length - 4); // strip ".xlm"
if (!localPart) throw new Error(`Invalid .xlm name: "${trimmed}"`);
federationAddress = `${localPart}*xlm.money`;
} else if (trimmed.includes("*")) {
// Standard federation address: alice*domain.com
const parts = trimmed.split("*");
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new Error(`Invalid federation address format: "${trimmed}". Expected "user*domain.com".`);
}
federationAddress = trimmed;
} else {
throw new Error(
`Invalid Stellar name: "${trimmed}". Use a federation address (alice*domain.com) or .xlm name (alice.xlm).`
);
}

try {
const record = await Federation.Server.resolve(federationAddress);
if (!record.account_id) {
throw new Error("Name resolved but no address found");
throw new Error("Name resolved but no Stellar address was returned.");
}
snsCache.set(key, {
name: key,
address: record.account_id,
resolvedAt: Date.now(),
});
// Store in cache
resolvedNameCache.set(trimmed, { address: record.account_id, expiry: Date.now() + SNS_CACHE_TTL_MS });
return record.account_id;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
throw new Error(`Could not resolve "${trimmed}" to a Stellar address`);
throw new Error(`Could not resolve "${trimmed}": ${message}`);
}
}

/**
* Returns `true` when the input looks like a Stellar name rather than a raw
* public key.
*
* Detects:
* - `.xlm` suffix shorthand (e.g. `alice.xlm`)
* - Native federation format (e.g. `alice*domain.com`)
*
* Raw `G...` public keys and plain usernames (no `*` or `.xlm`) return `false`.
*
* @param value - User-supplied destination string.
* @returns `true` if the value should be resolved via {@link resolveStellarName}.
*
* @example
* ```ts
* isStellarName("alice.xlm") // true
* isStellarName("alice*domain.com") // true
* isStellarName("GABC...XYZ") // false
* isStellarName("@username") // false
* ```
* Returns true if the input looks like a Stellar name (not a raw G... address).
* Matches federation addresses (contains `*`) and .xlm shorthand (ends with `.xlm`).
*/
export function isStellarName(value: string): boolean {
const v = value.trim();
return v.toLowerCase().endsWith(".xlm") || v.includes("*");
return v.endsWith(".xlm") || v.includes("*");
}

// ─── Escrow (issue #213) ──────────────────────────────────────────────────────
Expand Down
Loading
Loading