Skip to content
Open
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: 70 additions & 3 deletions frontend/components/SendPaymentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import {
fetchNetworkFeeStats,
isValidFederationAddress,
isValidStellarAddress,
isStellarName,
resolveFederationAddress,
resolveStellarName,
server,
STELLAR_BASE_FEE_XLM,
STELLAR_MEMO_TEXT_MAX_BYTES,
Expand Down Expand Up @@ -138,6 +140,11 @@ function SendPaymentForm({
const [isResolvingDestination, setIsResolvingDestination] = useState(false);
const [destinationResolutionError, setDestinationResolutionError] = useState<string | null>(null);
const [resolvedPaymentDestination, setResolvedPaymentDestination] = 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 @@ -422,8 +429,10 @@ function SendPaymentForm({
const isMemoValid = memoBytes <= 28;

const canSubmit =
(isValidDest || isFederationDestination || isUsernameDestination) &&
(isValidDest || isFederationDestination || isUsernameDestination || (isStellarName(trimmedDestination) && !!snsResolved)) &&
!isResolvingDestination &&
!snsResolving &&
!snsError &&
!destinationResolutionError &&
isValidAmt &&
status === "idle" &&
Expand Down Expand Up @@ -460,6 +469,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 @@ -535,6 +553,9 @@ function SendPaymentForm({
setAmount("");
setMemo("");
setResolvedPaymentDestination(null);
setSnsResolved(null);
setSnsError(null);
setSnsResolving(false);
}
setStatus("idle");
};
Expand Down Expand Up @@ -827,14 +848,44 @@ function SendPaymentForm({
type="text"
value={destination}
onChange={(e) => {
setDestination(e.target.value);
const val = e.target.value;
setDestination(val);
setDestinationResolutionError(null);
setResolvedPaymentDestination(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="G..., alice*domain.com, or @username"
placeholder="G... address or alice.xlm"
className={clsx(
"input-field font-mono text-sm",
destination &&
Expand All @@ -846,6 +897,22 @@ function SendPaymentForm({
disabled={status !== "idle" || destinationReadOnly}
/>

{/* 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
94 changes: 63 additions & 31 deletions frontend/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1797,50 +1797,82 @@ export async function fetchNetworkStats(): Promise<NetworkStats> {

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

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

/**
* Resolves a Stellar name (e.g. alice.xlm) to a Stellar address.
* Uses Stellar Federation protocol under the hood.
* Caches results for 10 minutes.
* Module-level cache for resolved Stellar names.
* Entries expire after {@link SNS_CACHE_TTL_MS}.
* Survives across renders; resets on page reload.
*/
export const resolvedNameCache = new Map<string, { address: string; expiry: number }>();

/**
* Resolves a human-readable Stellar name to a public key (G... address).
*
* - 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 - 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()

// Return as-is if already a valid Stellar address
if (isValidStellarAddress(trimmed)) return trimmed

// Check cache
const cached = snsCache.get(trimmed)
if (cached && cached.expiresAt > Date.now()) return cached.address

// Must contain a * for federation (e.g. alice*stellar.org) or end in .xlm
let federationAddress = trimmed
if (trimmed.endsWith('.xlm')) {
// Convert alice.xlm -> alice*stellarnames.org
const parts = trimmed.split('.')
federationAddress = `${parts[0]}*stellarnames.org`
} else if (!trimmed.includes('*')) {
throw new Error(`Invalid Stellar name: ${trimmed}`)
const trimmed = name.trim();

if (!trimmed) {
throw new Error("Name cannot be empty.");
}

// 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')
snsCache.set(trimmed, { address: record.account_id, expiresAt: Date.now() + SNS_CACHE_TTL_MS })
return record.account_id
} catch (err: any) {
throw new Error(`Could not resolve "${trimmed}": ${err.message ?? 'Unknown error'}`)
const record = await Federation.Server.resolve(federationAddress);
if (!record.account_id) {
throw new Error("Name resolved but no Stellar address was returned.");
}
// 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}": ${message}`);
}
}

/**
* Returns true if the input looks like a Stellar name (not a raw address)
* 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.endsWith('.xlm') || v.includes('*')
const v = value.trim();
return v.endsWith(".xlm") || v.includes("*");
}

// ─── Escrow (issue #213) ──────────────────────────────────────────────────────
Expand Down
56 changes: 34 additions & 22 deletions frontend/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,39 @@ export default function SettingsPage({
</div>
</div>
)}

{/* ── Your Stellar Name (SNS) ── */}
<div className="bg-white dark:bg-cosmos-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-1 flex items-center gap-2">
<svg className="w-5 h-5 text-stellar-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9" />
</svg>
Your Stellar Name
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
Register a human-readable name (e.g.{" "}
<span className="font-mono text-stellar-400">alice.xlm</span>) that others can use
to send you payments instead of your full address.
</p>

<a
href="https://xlm.money"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-stellar-500 hover:bg-stellar-600 text-white font-medium rounded-lg transition-colors text-sm"
>
Register your name on xlm.money
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>

<p className="mt-3 text-xs text-slate-500 dark:text-slate-500">
Already registered? Share your name (e.g.{" "}
<span className="font-mono">yourname.xlm</span>) and it will resolve to your Stellar
address automatically.
</p>
</div>
</div>
</main>
</div>
Expand Down Expand Up @@ -707,28 +740,7 @@ export default function SettingsPage({
</button>
</div>
</div>

{/* ── Stellar Name Service ── */}
<div className="card">
<h2 className="text-lg font-semibold mb-2">Your Stellar Name</h2>
<p className="text-sm text-gray-500 mb-4">
Register a human-readable name (e.g. <strong>alice.xlm</strong>) that others can use to send you payments instead of your full address.
</p>
{publicKey && (
<p className="text-xs text-gray-400 mb-4 break-all">
Your address: <span className="font-mono">{publicKey}</span>
</p>
)}
<a
href="https://stellarnames.org"
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
Register your name on StellarNames →
</a>
</div>
</div>
</div>
)}
</>
);
Expand Down
Loading