references/client.md
# Frontend Implementation Reference
React Native implementation for .skr domain resolution with Mobile Wallet Adapter integration.
## Framework Note
This reference shows React Native implementation. **For other frontends**, the same pattern applies—you're simply making two API calls to the backend:
- `POST /api/resolve-domain` with `{ domain: "alice.skr" }` → returns `{ address: "..." }`
- `POST /api/resolve-address` with `{ address: "5FHw..." }` → returns `{ domain: "alice.skr" }`
Adapt this to your frontend framework:
- **React (web)**: Use `fetch` or `axios` in a custom hook
- **Vue**: Use composables with `fetch`
- **Svelte**: Use stores or `fetch` in `onMount`
- **Angular**: Use HttpClient in a service
- **Plain JS**: Use `fetch` directly
The core logic is identical—just HTTP POST requests to your backend.
## Domain Resolution Hook
Create a custom hook to handle API calls to the backend:
```typescript
// hooks/use-domain-lookup.ts
import { useState } from 'react';
// 10.0.2.2 is the Android emulator's alias for the host machine's localhost. Set
// EXPO_PUBLIC_API_URL per environment rather than committing either value.
//
// The fallback is gated on __DEV__ deliberately. EXPO_PUBLIC_* is inlined at build time, so an
// ungated default compiles the emulator's cleartext URL straight into the release APK, where it
// resolves to nothing and Android's default network security config blocks cleartext anyway.
// Better to fail loudly at startup than to ship an app whose lookups silently never work.
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? (__DEV__ ? 'http://10.0.2.2:3000' : '');
if (!API_BASE_URL) {
throw new Error('EXPO_PUBLIC_API_URL must be set for release builds');
}
interface DomainLookupResult {
address?: string;
domain?: string;
error?: string;
}
export function useDomainLookup() {
const [loading, setLoading] = useState(false);
/**
* Resolve .skr domain to wallet address
* @param domain - Domain name (with or without .skr extension)
* @returns Wallet address or error
*/
const resolveDomain = async (domain: string): Promise<DomainLookupResult> => {
setLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/api/resolve-domain`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain }),
});
if (!response.ok) {
const error = await response.json();
return { error: error.error || 'Failed to resolve domain' };
}
const data = await response.json();
return { address: data.address };
} catch (error) {
console.error('Error resolving domain:', error);
return { error: 'Network request failed' };
} finally {
setLoading(false);
}
};
/**
* Reverse lookup: resolve wallet address to .skr domain
* @param address - Solana wallet address (base58)
* @returns .skr domain name or error
*/
const resolveAddress = async (address: string): Promise<DomainLookupResult> => {
setLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/api/resolve-address`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address }),
});
if (!response.ok) {
const error = await response.json();
return { error: error.error || 'Failed to resolve address' };
}
const data = await response.json();
return { domain: data.domain };
} catch (error) {
console.error('Error resolving address:', error);
return { error: 'Network request failed' };
} finally {
setLoading(false);
}
};
return {
resolveDomain,
resolveAddress,
loading,
};
}
```
## Resolving directly, without a backend
For a prototype, the app can resolve on its own. The Kit resolver in
[kit-resolver.md](kit-resolver.md) uses only Kit's codecs, `@noble/hashes`, and `DataView` — no
`Buffer`, `TextEncoder`, or Node built-ins — so it needs no polyfills of its own beyond whatever
`@solana/kit` already requires in your app.
```typescript
// hooks/use-resolve-address.ts
import { useQuery } from '@tanstack/react-query';
import { address, createSolanaRpc } from '@solana/kit';
import { resolveSkrNames } from '../utils/skr';
// .skr lives on mainnet regardless of the cluster the rest of the app targets.
//
// The public endpoint, hardcoded, and not EXPO_PUBLIC_SOLANA_MAINNET_RPC_URL: every
// EXPO_PUBLIC_* value is inlined into the bundle, so putting a keyed provider URL here hands
// the key to anyone who unzips the APK — the thing this skill tells you to proxy in order to
// avoid. A prototype lives with the public endpoint's rate limits; the moment you need a paid
// provider, that is the moment you need the proxy.
const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com');
export function useResolveAddress(walletAddress?: string) {
return useQuery({
queryKey: ['skr-name', walletAddress],
enabled: !!walletAddress,
// Reverse direction, so a long staleTime is fine — see "Caching by direction" below.
staleTime: 1000 * 60 * 60,
queryFn: async () => {
const names = await resolveSkrNames(rpc, address(walletAddress!));
return names[0] ?? null; // already sorted, so this is stable
},
});
}
```
Two caveats before shipping this rather than the proxy:
- **A key cannot live here.** `EXPO_PUBLIC_*` is inlined at build time and readable by anyone who
unzips the APK, so client-side resolution only works against an endpoint you do not mind
exposing. That is why the sample hardcodes the public one instead of reading an env var that
invites a paid URL.
- Reverse lookup calls `getProgramAccounts`, which many providers restrict. The public endpoint
will also rate-limit a list view that resolves dozens of addresses.
Both point the same way for production: proxy it, and use the hook above.
## Caching by direction
Names change rarely, so caching is worth having. But the two directions carry different
consequences when the cache is wrong, and they need different TTLs.
| Direction | What it feeds | `staleTime` | Cost of a stale hit |
| --- | --- | --- | --- |
| Reverse (address → name) | Display labels | Long — an hour is fine | A wrong label. Cosmetic. |
| Forward (name → address) | Payment destinations | None. Re-resolve at send time | Funds sent to the name's previous owner |
`.skr` names are transferable and re-registrable. Resolve `alice.skr` for a "send to" field,
cache it for an hour, and a transfer built from that cached entry pays whoever held the name an
hour ago — not the person the user thinks they are paying. Nothing about the UI looks wrong.
So for anything that becomes a transaction:
```typescript
// hooks/use-resolve-domain.ts — the forward direction, for payees.
import { useQuery } from '@tanstack/react-query';
export function useResolveDomain(domain?: string) {
return useQuery({
queryKey: ['skr-address', domain],
enabled: !!domain,
// No caching in the forward direction. The address is only good at the moment it is read.
staleTime: 0,
gcTime: 0,
queryFn: () => resolveDomainViaApi(domain!),
});
}
```
And re-resolve immediately before signing rather than trusting whatever the field last showed:
```typescript
const onSend = async () => {
// Resolve again at send time; the value the user typed is a name, not a destination.
const { address: destination } = await resolveDomain(recipientName);
if (!destination) return showError('That .skr name is no longer registered');
// Show the address that will actually be paid, not just the name that was typed.
const confirmed = await confirmTransfer({ name: recipientName, address: destination, amount });
if (!confirmed) return;
await signAndSend(destination, amount);
};
```
The confirmation step is the part that is easy to skip and most worth keeping. A user who typed
`alice.skr` cannot tell a re-registration from a correct resolution, but they can recognise an
address they have paid before — so put the resolved address in front of them before they sign.
Two different things are called confirmation here: `confirmTransfer` is the user assenting to a
destination, not the network confirming a transaction — `signAndSend` still has to wait for
the transaction to land before the payment counts as made.
## Usage in Components
### Example 1: Display User's .skr Domain
```typescript
// app/index.tsx - Main screen showing personalized welcome
import { useEffect, useState } from 'react';
import { View, Text } from 'react-native';
import { useMobileWallet } from '@wallet-ui/react-native-kit';
import { useDomainLookup } from '../hooks/use-domain-lookup';
import { ellipsify } from '../utils/ellipsify';
export default function HomeScreen() {
const { account } = useMobileWallet();
const { resolveAddress, loading } = useDomainLookup();
const [domain, setDomain] = useState<string | null>(null);
// On the kit stack `address` is already a string. On the web3.js stack it is a
// PublicKey, so call .toString() there.
const address = account?.address.toString();
useEffect(() => {
// Clear first, including on disconnect. Without this the previous account's name stays on
// screen while the next one resolves, or after the wallet goes away entirely.
setDomain(null);
if (!address) return;
// Switching accounts starts a second lookup while the first is still in flight, and they
// can land out of order. Without this flag, account A's name can overwrite account B's.
let current = true;
resolveAddress(address).then((result) => {
if (current) setDomain(result.domain ?? null);
});
return () => {
current = false;
};
}, [address]);
if (!address) return <Text>Welcome, Guest!</Text>;
return (
<View>
<Text>Welcome, {domain ?? ellipsify(address)}!</Text>
{/* The address stays visible even for the signed-in user: anyone can transfer a .skr
name to any wallet, so the label is not self-asserted. */}
{domain ? <Text>{ellipsify(address)}</Text> : null}
{loading ? <Text>Loading...</Text> : null}
</View>
);
}
```
### Example 2: Domain Search Component
```typescript
// components/domain-search.tsx - Search for domains or addresses
import { useState } from 'react';
import { View, TextInput, Button, Text } from 'react-native';
import { useDomainLookup } from '../hooks/use-domain-lookup';
export function DomainSearch() {
const [query, setQuery] = useState('');
const [result, setResult] = useState<string>('');
const { resolveDomain, resolveAddress, loading } = useDomainLookup();
const handleSearch = async () => {
if (!query.trim()) return;
// Check if input looks like a domain (.skr) or address
if (query.includes('.skr')) {
// Domain to address lookup
const res = await resolveDomain(query);
if (res.address) {
setResult(`Address: ${res.address}`);
} else {
setResult(`Error: ${res.error}`);
}
} else {
// Address to domain lookup
const res = await resolveAddress(query);
if (res.domain) {
setResult(`Domain: ${res.domain}`);
} else {
setResult(`Error: ${res.error}`);
}
}
};
return (
<View>
<TextInput
placeholder="Enter .skr domain or wallet address"
value={query}
onChangeText={setQuery}
/>
<Button title="Search" onPress={handleSearch} disabled={loading} />
{result && <Text>{result}</Text>}
</View>
);
}
```
### Example 3: Display .skr Instead of Address in Lists
```typescript
// components/wallet-list-item.tsx - Show .skr domain in user lists
import { useEffect, useState } from 'react';
import { View, Text } from 'react-native';
import { useDomainLookup } from '../hooks/use-domain-lookup';
import { ellipsify } from '../utils/ellipsify';
interface WalletListItemProps {
address: string;
}
export function WalletListItem({ address }: WalletListItemProps) {
const { resolveAddress } = useDomainLookup();
const [domain, setDomain] = useState<string | null>(null);
useEffect(() => {
// Clear on change, and ignore a response that arrives after the address moved on — a
// recycled row in a list would otherwise show the previous wallet's name.
setDomain(null);
let current = true;
resolveAddress(address).then((result) => {
if (current) setDomain(result.domain ?? null);
});
return () => {
current = false;
};
}, [address]);
// The name goes beside the address, never in place of it. A reverse-resolved .skr name is
// whatever sorts first among the names that address holds, and anyone can transfer a name to
// any wallet without the owner agreeing — so a stranger can choose the label here.
return (
<View>
<Text>{domain ?? ellipsify(address)}</Text>
{domain ? <Text>{ellipsify(address)}</Text> : null}
</View>
);
}
```
The truncated address is not decoration. It is the part the user can check.
## Utility: Address Truncation
```typescript
// utils/ellipsify.ts
export function ellipsify(str: string, len = 4): string {
if (str.length <= len * 2) return str;
return `${str.slice(0, len)}...${str.slice(-len)}`;
}
```
## Key Implementation Notes
1. **API URL**: `http://10.0.2.2:3000` reaches the host machine from an Android emulator. Physical devices need the host's LAN IP. Read it from `EXPO_PUBLIC_API_URL` instead of committing either value, and gate the emulator fallback on `__DEV__` — `EXPO_PUBLIC_*` is inlined at build time, so an ungated default ships a dead cleartext URL in the release APK, which Android's default network security config blocks regardless.
2. **No RPC keys in the app**: every `EXPO_PUBLIC_*` value is readable by anyone who unzips the build. Client-side resolution is limited to endpoints you do not mind publishing; a paid provider belongs behind the proxy.
3. **Caching**: cache by direction, not uniformly. Long `staleTime` for reverse lookups (display labels); no cache for forward lookups, re-resolved at send time, because a stale name-to-address entry pays the name's previous owner. See [Caching by direction](#caching-by-direction).
4. **Display names beside addresses, not instead of them**: a reverse-resolved `.skr` name is the first-sorting name an address happens to hold, and names are transferable without the recipient's consent, so the label can be picked by a third party. Keep the truncated address visible next to it, and put the resolved address in any confirmation step that precedes a signature.
5. **Resolution is async, so guard against stale responses**: switching accounts or recycling a list row starts a second lookup while the first is in flight, and they can land out of order. Clear the name when the address changes — including on disconnect — and drop any response that arrives after it did, or one wallet ends up labelled with another's name.
6. **Error Handling**: Always fall back to a truncated address. A failed lookup should degrade to something readable, never to a blank or a permanent spinner.
7. **Loading States**: Show a loading indicator, but render the truncated address underneath rather than an empty string, so the UI never shows a nameless user.
8. **Validation**: The backend validates input; validating on the client too avoids a round trip for obviously malformed input.
9. **Wallet hook**: The hook is `useMobileWallet()`, from `@wallet-ui/react-native-kit` or `@wallet-ui/react-native-web3js` depending on the stack. There is no `useMobileWalletAdapter` export. On the kit stack `account.address` is a string; on web3.js it is a `PublicKey` needing `.toString()`. See the `solana-mobile-wallet` skill.
references/kit-resolver.md
# Kit resolver reference
A self-contained `.skr` resolver built on `@solana/kit`. This is the default — prefer it over
`@onsol/tldparser` unless you need the parts of AllDomains it does not cover (see
[Limits](#limits)).
Resolving a `.skr` name is a PDA derivation plus one account read. That is small enough to own
outright, which buys a not-found path that returns `null` instead of throwing, input handling
that accepts what users actually type, and no dependency on an SDK whose ESM build is broken.
```bash
npm install @solana/kit "@noble/hashes@^1"
```
`@noble/hashes` is a separate install: `@solana/kit` does not depend on it, so it is only
present by accident if something else in the tree pulls it in. It is pure JavaScript and works
under Hermes.
**Pin the major, and import `sha2.js` with the extension.** A bare `npm install @noble/hashes`
now resolves to 2.x, whose `exports` map lists `./sha2.js` and nothing else — the extensionless
`@noble/hashes/sha2` that 1.x also accepted fails there under strict exports resolution. The
`./sha2.js` specifier used below is valid on both 1.x and 2.x, so it survives an accidental
upgrade; the `^1` pin is what keeps the rest of the 2.x API changes out.
## How `.skr` names are stored
`.skr` is a TLD in AllDomains' Alt Name Service (ANS). Every name is an account owned by the
ANS program, found by a chain of PDAs — each seeded with `sha256("ALT Name Service" + name)`:
| Account | Seeds |
| --- | --- |
| ANS root | `hash("ANS")`, 32 zero bytes, 32 zero bytes |
| `.skr` parent | `hash(".skr")`, 32 zero bytes, ANS root |
| `alice.skr` | `hash("alice")`, 32 zero bytes, `.skr` parent |
The root is a constant, `3mX9b4AZaQehNoQGfckVcmgmA6bkBoFcbLj9RMmMyNcU`. Deriving it and
comparing against that value is a cheap self-check that the hashing and seed order are right —
worth keeping in a test, because every wrong derivation fails the same silent way: a PDA for an
account that does not exist, indistinguishable from an unregistered name.
The account is a 200-byte header. Three fields matter here:
| Offset | Field |
| --- | --- |
| 8 | `parentName` (32 bytes) — the memcmp filter for reverse lookup |
| 40 | `owner` (32 bytes) |
| 104 | `expiresAt` (u64 LE, seconds; `0` means non-expiring) |
The forward record carries nothing after byte 200. The human-readable label lives in a separate
reverse-lookup account, seeded with the name account's own base58 string — which is why reverse
lookup has to read a second account for every name. Those reads are batched 100 at a time
through `getMultipleAccounts`, so the cost is one request per 100 names rather than one each.
## Implementation
Compiles clean under `tsc --strict`. It touches no `Buffer`, `TextEncoder`, or `TextDecoder`,
using Kit's codecs instead, so the same file runs on a server and in React Native.
```typescript
// src/skr.ts
import {
address,
getAddressDecoder,
getAddressEncoder,
getBase64Encoder,
getProgramDerivedAddress,
getUtf8Decoder,
getUtf8Encoder,
type Address,
type Base58EncodedBytes,
type createSolanaRpc,
} from '@solana/kit';
// Extensioned subpath: valid on @noble/hashes 1.x and 2.x. Bare 'sha2' breaks on 2.x.
import { sha256 } from '@noble/hashes/sha2.js';
type Rpc = ReturnType<typeof createSolanaRpc>;
const ANS_PROGRAM = address('ALTNSZ46uaAUU7XUV6awvdorLGqAsPwa9shm7h4uP2FK');
const TLD_HOUSE_PROGRAM = address('TLDHkysf5pCnKsVA4gXpNvmy7psXLPEu4LAdDJthT9S');
const NAME_HOUSE_PROGRAM = address('NH3uX6FtVE2fNREAioP7hm5RaozotZxeL6khU1EHx51');
const ROOT_ANS = address('3mX9b4AZaQehNoQGfckVcmgmA6bkBoFcbLj9RMmMyNcU');
const HASH_PREFIX = 'ALT Name Service';
const TLD = '.skr';
const HEADER_SIZE = 200;
const OWNER_OFFSET = 40;
const EXPIRES_AT_OFFSET = 104;
// getMultipleAccounts takes at most 100 addresses per call.
const REVERSE_BATCH_SIZE = 100;
const addressDecoder = getAddressDecoder();
const utf8Decoder = getUtf8Decoder();
const base64Encoder = getBase64Encoder();
const utf8 = (value: string) => new Uint8Array(getUtf8Encoder().encode(value));
const addressBytes = (value: Address) => new Uint8Array(getAddressEncoder().encode(value));
const ZERO_32 = new Uint8Array(32);
const hashName = (name: string) => sha256(utf8(HASH_PREFIX + name));
async function pda(programAddress: Address, seeds: Uint8Array[]): Promise<Address> {
const [derived] = await getProgramDerivedAddress({ programAddress, seeds });
return derived;
}
const deriveNameAccount = (name: string, parent?: Address) =>
pda(ANS_PROGRAM, [hashName(name), ZERO_32, parent ? addressBytes(parent) : ZERO_32]);
const deriveTldHouse = () => pda(TLD_HOUSE_PROGRAM, [utf8('tld_house'), utf8(TLD)]);
const deriveReverseAccount = (nameAccount: Address, tldHouse: Address) =>
pda(ANS_PROGRAM, [hashName(nameAccount), addressBytes(tldHouse), ZERO_32]);
async function deriveNftRecord(nameAccount: Address, tldHouse: Address) {
const nameHouse = await pda(NAME_HOUSE_PROGRAM, [utf8('name_house'), addressBytes(tldHouse)]);
return pda(NAME_HOUSE_PROGRAM, [
utf8('nft_record'),
addressBytes(nameHouse),
addressBytes(nameAccount),
]);
}
async function fetchAccountData(rpc: Rpc, account: Address): Promise<Uint8Array | null> {
const { value } = await rpc.getAccountInfo(account, { encoding: 'base64' }).send();
return value ? new Uint8Array(base64Encoder.encode(value.data[0])) : null;
}
/** Normalise user input to a bare label, or null if it cannot name a .skr domain. */
export function normalizeSkrName(input: string): string | null {
const label = input.trim().toLowerCase().replace(/\.skr$/, '');
return /^[a-z0-9-]{1,63}$/.test(label) ? label : null;
}
/** Forward lookup. Accepts "alice.skr" or "alice". Returns null when unregistered. */
export async function resolveSkrDomain(rpc: Rpc, domain: string): Promise<Address | null> {
const label = normalizeSkrName(domain);
if (!label) return null;
const parent = await deriveNameAccount(TLD, ROOT_ANS);
const nameAccount = await deriveNameAccount(label, parent);
const data = await fetchAccountData(rpc, nameAccount);
if (!data || data.length < HEADER_SIZE) return null;
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const expiresAt = Number(view.getBigUint64(EXPIRES_AT_OFFSET, true));
if (expiresAt !== 0 && expiresAt * 1000 < Date.now()) return null;
const owner = addressDecoder.decode(data.subarray(OWNER_OFFSET, OWNER_OFFSET + 32));
// A tokenized domain records the nft_record PDA as owner; the real owner holds the NFT.
const nftRecord = await deriveNftRecord(nameAccount, await deriveTldHouse());
return owner === nftRecord ? resolveTokenizedOwner(rpc, nftRecord) : owner;
}
async function resolveTokenizedOwner(rpc: Rpc, nftRecord: Address): Promise<Address | null> {
const data = await fetchAccountData(rpc, nftRecord);
if (!data || data[8] !== 1) return null; // tag !== ActiveRecord
const mint = addressDecoder.decode(data.subarray(74, 106));
// Check the mint, not the holder's balance. getTokenLargestAccounts returns the 20 largest
// holders, so a mint with a supply of 2 split between two accounts passes a balance check and
// resolves to an arbitrary one of them. Requiring a supply of exactly one indivisible unit is
// what makes "the largest holder" and "the owner" the same thing.
const { value: supply } = await rpc.getTokenSupply(mint).send();
if (supply.decimals !== 0 || BigInt(supply.amount) !== 1n) return null;
const { value: largest } = await rpc.getTokenLargestAccounts(mint).send();
if (!largest?.length) return null;
const { value: holder } = await rpc
.getAccountInfo(largest[0].address, { encoding: 'jsonParsed' })
.send();
const parsed = holder?.data as { parsed?: { info?: { owner?: string } } } | undefined;
const ownerString = parsed?.parsed?.info?.owner;
return ownerString ? address(ownerString) : null;
}
/** Reverse lookup. Returns every .skr name the address owns, sorted. */
export async function resolveSkrNames(rpc: Rpc, owner: Address): Promise<string[]> {
const parent = await deriveNameAccount(TLD, ROOT_ANS);
const tldHouse = await deriveTldHouse();
const accounts = await rpc
.getProgramAccounts(ANS_PROGRAM, {
encoding: 'base64',
// Just the expiry field. Enough to drop expired names in this same call, without
// pulling 200-byte headers for every name the address holds.
dataSlice: { offset: EXPIRES_AT_OFFSET, length: 8 },
filters: [
{ memcmp: { offset: 8n, bytes: parent as string as Base58EncodedBytes, encoding: 'base58' } },
{
memcmp: {
offset: BigInt(OWNER_OFFSET),
bytes: owner as string as Base58EncodedBytes,
encoding: 'base58',
},
},
],
})
.send();
const now = Date.now();
// Same expiry rule as the forward path: 0 is non-expiring, a past value is unregistered.
// Skipping it would leave an expired name showing as somebody's display label.
const live = accounts.filter(({ account }) => {
const expiry = new Uint8Array(base64Encoder.encode(account.data[0]));
if (expiry.length < 8) return false;
const expiresAt = Number(
new DataView(expiry.buffer, expiry.byteOffset, 8).getBigUint64(0, true),
);
return expiresAt === 0 || expiresAt * 1000 >= now;
});
// PDA derivation only — no network, so deriving these together is free.
const reverseAccounts = await Promise.all(
live.map(({ pubkey }) => deriveReverseAccount(pubkey, tldHouse)),
);
// One request per 100 names, in sequence, rather than one per name all at once. An address
// can hold thousands of names and that count is chosen by whoever transferred them, so a
// per-name fan-out is a burst an outsider gets to size — against your own RPC quota.
const names: string[] = [];
for (let i = 0; i < reverseAccounts.length; i += REVERSE_BATCH_SIZE) {
const { value: batch } = await rpc
.getMultipleAccounts(reverseAccounts.slice(i, i + REVERSE_BATCH_SIZE), {
encoding: 'base64',
})
.send();
for (const entry of batch) {
if (!entry) continue;
const data = new Uint8Array(base64Encoder.encode(entry.data[0]));
if (data.length <= HEADER_SIZE) continue;
const label = utf8Decoder.decode(data.subarray(HEADER_SIZE)).replace(/\0.*$/, '');
if (label) names.push(`${label}${TLD}`);
}
}
return names.sort();
}
```
## Usage
```typescript
import { address, createSolanaRpc } from '@solana/kit';
import { resolveSkrDomain, resolveSkrNames } from './skr';
// Always mainnet, whatever cluster the rest of the app targets.
const rpc = createSolanaRpc(process.env.SOLANA_MAINNET_RPC_URL!);
await resolveSkrDomain(rpc, 'alice.skr'); // Address, or null
await resolveSkrNames(rpc, address('5FHw...')); // ['alice.skr'], sorted
```
## Notes
**Not-found is `null`, never a throw.** A rejected promise from either function means the RPC
failed, so the two map cleanly onto a 404 and a 503. This is the main practical reason to prefer
this over `@onsol/tldparser`, which throws a `TypeError` for both cases indistinguishably.
**Forward lookup accepts either form.** `normalizeSkrName` strips a trailing `.skr`, trims, and
lowercases, so `"Alice.SKR"` and `"alice"` both work. It rejects anything with an interior dot,
including subdomains like `"a.alice.skr"` — those are a different derivation this resolver does
not implement, and quietly resolving them to the wrong account would be worse than refusing.
**Reverse lookup needs `getProgramAccounts`.** It is filtered down to one owner so the response
is tiny, but plenty of providers disable or rate-limit the method regardless. Confirm your
provider allows it before relying on the reverse direction, and keep it server-side.
**Reverse lookup batches its second read, and does not fan out.** The label for each name lives
in its own account, so a naive version issues one `getAccountInfo` per name in a single
`Promise.all`. How many names an address holds is not something you control — anyone can
transfer names to it — so that shape lets an outsider pick the size of a burst against your RPC
quota, by loading up an address and asking you to resolve it. The batched loop above is capped
at 100 addresses per request, one request at a time.
**Reverse lookup returns an array, sorted.** An address can own several `.skr` names, and the
on-chain order is not a ranking. Sorting is what stops the displayed name changing between
calls; take `[0]` only after sorting.
**A reverse-resolved name is not a claim about who an address is.** `.skr` names are
transferable, and a transfer needs nothing from the recipient — anyone can push a name onto any
wallet. Since `[0]` is just the lexicographically first name the address holds, a stranger can
decide what your UI calls a user by registering something that sorts early. Sorting makes the
label stable, not trustworthy. So:
- Render it **beside** the truncated address, never instead of it.
- Where the label stands in for identity — a payee, a counterparty, a moderation surface —
prefer the owner's `MainDomain`, the name they picked themselves. This resolver does not
implement it; that is `@onsol/tldparser`'s `getMainDomain`, which throws when the user has
never set one (the common case), so treat that throw as "no main domain" and fall back to the
address.
**Expiry.** `expiresAt` of `0` means non-expiring, which is what Seeker-issued `.skr` names
carry today. Both directions treat a past `expiresAt` as unregistered, with no grace period —
`@onsol/tldparser` instead keeps a name resolving for roughly 50 days past expiry. If you need
to match the SDK, or want to show "expires soon", return `expiresAt` rather than dropping it.
## Limits
The resolver covers the name-account path, which is all that Seeker `.skr` names use today. Two
gaps, both in AllDomains features `.skr` does not currently exercise:
- **Reverse lookup skips tokenized domains.** The `memcmp` on `owner` matches name accounts
only, so a domain minted as an NFT and held in a wallet would not appear. As of writing, none
of the ~120k `.skr` name accounts are tokenized, so this is latent rather than a live gap.
Forward lookup does handle the case, via `resolveTokenizedOwner`.
- **No records, avatars, or `MainDomain`.** If you need a user's chosen primary domain or the
ANS record set (avatar, socials), that is `@onsol/tldparser` territory. Note that
`getMainDomain` throws when a user has never set one, which is the common case.
Reach for `@onsol/tldparser` for those, and see the caveats in
[server.md](server.md#onsoltldparser-alternative) before you do.
references/server.md
# Server implementation reference
Express implementation of `.skr` resolution. Adapt the routing to whatever framework the project
already uses — see [Other frameworks](#other-frameworks). The resolution logic itself is
framework-agnostic.
`.skr` names live on Solana **mainnet**, whatever cluster the app targets.
This uses the Kit resolver from [kit-resolver.md](kit-resolver.md), which is the default. For the
`@onsol/tldparser` route, see [the section below](#onsoltldparser-alternative).
## Full Express code
This proxy exists to keep an RPC key off user devices. That only holds if the proxy is not
itself an open relay in front of that key: the reverse route costs a `getProgramAccounts` plus
a batched read of every name the address owns, so an unthrottled, uncached endpoint hands anyone
who finds the URL a free way to burn the quota you were protecting. Origin allowlist, rate
limit, cache, and body limit are all part of the sample for that reason.
```typescript
// backend/src/index.ts
import express, { NextFunction, Request, Response } from 'express';
import { address, createSolanaRpc, type Address } from '@solana/kit';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { LRUCache } from 'lru-cache';
import { normalizeSkrName, resolveSkrDomain, resolveSkrNames } from './skr';
const app = express();
const PORT = Number(process.env.PORT ?? 3000);
const IS_PROD = process.env.NODE_ENV === 'production';
// No public-endpoint fallback. This server's whole job is to hold the key, so an unset env var
// is a broken deploy — fail at startup rather than quietly serving from the public endpoint
// until its rate limits make it look like every user has no name. The reverse route needs
// getProgramAccounts, which some providers disable; check before deploying.
const RPC_ENDPOINT = process.env.SOLANA_MAINNET_RPC_URL;
if (!RPC_ENDPOINT) {
throw new Error('SOLANA_MAINNET_RPC_URL is required');
}
// Build the RPC client once at module scope, not per request.
const rpc = createSolanaRpc(RPC_ENDPOINT);
// Comma-separated, e.g. "https://app.example.com,https://staging.example.com".
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
if (IS_PROD && ALLOWED_ORIGINS.length === 0) {
throw new Error('ALLOWED_ORIGINS is required in production');
}
app.use(
cors({
origin: (origin, callback) =>
// A missing Origin is a native app or a server-side caller, which CORS does not police
// either way — the rate limit is what covers those. A browser Origin must be listed.
!origin || ALLOWED_ORIGINS.includes(origin)
? callback(null, true)
: callback(new Error('Origin not allowed')),
}),
);
// Both routes carry one short string. Express's 100kb default lets a caller push megabytes at
// the JSON parser before any of your code runs.
app.use(express.json({ limit: '1kb' }));
// Per-IP ceiling on the RPC spend. Tune to your provider's quota, not to what feels polite.
// Behind a load balancer this needs `app.set('trust proxy', <hops>)` to see the real client
// IP — see note 8.
app.use(
'/api',
rateLimit({
windowMs: 60_000,
limit: 60,
standardHeaders: 'draft-7',
legacyHeaders: false,
}),
);
// The forward direction caches misses ONLY, and never a resolved address. A forward result is
// a payment destination, and the client is told to re-resolve at send time — which comes back
// through this same route, so serving it a cached address would reintroduce the stale-payee bug
// the client-side rule exists to prevent. Caching misses still blunts the cheap abuse, which is
// walking through unregistered names to miss the cache on every request.
//
// Reverse results are display labels, so they cache normally, boxed because LRUCache's value
// type is `V extends {}` and rejects null, and because a miss and a cached "no name" have to
// stay distinguishable. See notes 5 and 6.
const forwardMissCache = new LRUCache<string, true>({ max: 10_000, ttl: 60_000 });
const reverseCache = new LRUCache<string, { domain: string | null }>({
max: 10_000,
ttl: 3_600_000,
});
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok' });
});
// express.json() leaves req.body undefined unless the Content-Type is JSON, so destructuring it
// straight away turns an ordinary bad request into a 500 — and on Express 4, into an unhandled
// rejection. Read fields defensively instead.
function readStringField(body: unknown, field: string): string | null {
if (typeof body !== 'object' || body === null) return null;
const value = (body as Record<string, unknown>)[field];
return typeof value === 'string' ? value : null;
}
// Resolve .skr domain to wallet address
app.post('/api/resolve-domain', async (req: Request, res: Response) => {
const domain = readStringField(req.body, 'domain');
if (!domain) {
return res.status(400).json({ error: 'Domain name is required' });
}
// Reject malformed input before spending RPC quota on it. The normalised label doubles as the
// cache key, so "Alice.SKR" and "alice" are one entry rather than two.
const label = normalizeSkrName(domain);
if (!label) {
return res.status(400).json({ error: 'Not a valid .skr domain' });
}
if (forwardMissCache.has(label)) {
return res.status(404).json({ error: 'Domain not found' });
}
try {
const owner = await resolveSkrDomain(rpc, label);
// null is a genuine "not registered". A throw means the RPC failed — see below.
if (!owner) {
forwardMissCache.set(label, true);
return res.status(404).json({ error: 'Domain not found' });
}
// Deliberately not cached — see the cache declarations above.
res.json({ address: owner });
} catch (error) {
// Also not cached: caching an outage turns a blip into a TTL of wrong answers.
console.error('RPC failure resolving domain:', error);
res.status(503).json({ error: 'Resolution temporarily unavailable' });
}
});
// Reverse lookup: resolve wallet address to .skr domain
app.post('/api/resolve-address', async (req: Request, res: Response) => {
const input = readStringField(req.body, 'address');
if (!input) {
return res.status(400).json({ error: 'Wallet address is required' });
}
// address() throws on malformed base58, so validate separately from the RPC call to keep
// bad input a 400 rather than a 503.
let owner: Address;
try {
owner = address(input);
} catch {
return res.status(400).json({ error: 'Invalid wallet address' });
}
const cached = reverseCache.get(owner);
if (cached) {
return cached.domain
? res.json({ domain: cached.domain })
: res.status(404).json({ error: 'No .skr domain found for this address' });
}
try {
const domains = await resolveSkrNames(rpc, owner);
// Already sorted, so this is stable across calls for multi-domain owners.
const domain = domains[0] ?? null;
reverseCache.set(owner, { domain });
if (!domain) {
return res.status(404).json({ error: 'No .skr domain found for this address' });
}
res.json({ domain });
} catch (error) {
console.error('RPC failure resolving address:', error);
res.status(503).json({ error: 'Resolution temporarily unavailable' });
}
});
// Without this, a rejected origin and an oversized body both come back as a 500 with a stack
// trace in the body.
app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) return next(err);
if (err instanceof Error && err.message === 'Origin not allowed') {
return res.status(403).json({ error: 'Origin not allowed' });
}
// express.json() attaches its own status: 413 for too large, 400 for malformed JSON.
const status = (err as { status?: number } | null)?.status ?? 500;
if (status === 500) console.error('Unhandled error:', err);
res.status(status).json({ error: status === 500 ? 'Internal error' : 'Bad request' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
});
```
Required environment: `SOLANA_MAINNET_RPC_URL` always, and `ALLOWED_ORIGINS` whenever
`NODE_ENV=production`. Both throw at startup when missing, so a misconfigured deploy fails
visibly instead of degrading.
## Package Configuration
```json
{
"name": "skr-backend",
"version": "1.0.0",
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@noble/hashes": "^1.8.0",
"@solana/kit": "^8.0.0",
"cors": "^2.8.5",
"express": "^5.2.1",
"express-rate-limit": "^8.7.0",
"lru-cache": "^11.5.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}
```
Express is pinned to `^5`, which is what `npm install express` resolves to — 4.x now sits
behind the `latest-4` tag. The sample runs unchanged on either: every route is a literal path,
so none of Express 5's `path-to-regexp` changes apply, and `express-rate-limit` declares
`express >= 4.11`. `@types/express@^5` is the matching major.
## TypeScript Configuration
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
```
## Key implementation notes
1. **RPC endpoint**: required, with no fallback. The public mainnet endpoint is fine for
development, but set it explicitly — a silent fallback means a deploy that lost its env var
keeps answering, from a rate-limited endpoint, until it looks like every user has no name.
Use a dedicated provider in production and keep the key in server-side environment variables
only.
2. **Forward lookup**: `resolveSkrDomain` accepts `alice.skr` or `alice`, and returns `null` when
the name is unregistered. Validate with `normalizeSkrName` first so malformed input is a 400
rather than a wasted RPC round trip.
3. **Reverse lookup**: `resolveSkrNames` returns **all** `.skr` names owned by an address, sorted.
Take `[0]` for a display name; the sort is what keeps it stable between calls. It relies on
`getProgramAccounts`, which some providers restrict — verify yours supports it.
4. **Error handling**: 400 for invalid input, 404 for a genuine "no domain registered", 503 for
RPC failures. The resolver makes this easy: `null` is not-found, a rejected promise is an
outage. Do not collapse RPC failures into 404 — an outage would then look like every user
having no name.
5. **Cache**: names change rarely, and caching is the main reason to proxy rather than resolve
from the client. Two things the obvious version gets wrong. Cache **negative** results, or a
caller walking through unregistered names reaches the RPC on every request and the cache
buys nothing exactly when you need it. And do **not** cache RPC failures — a rejected promise
is an outage, and storing it turns a momentary blip into a full TTL of confidently wrong
404s.
6. **What you cache depends on the direction, and the forward direction caches nothing
positive.** Reverse results (address → name) are display labels; a stale one is a cosmetic
bug, so an hour is fine. Forward results (name → address) end up as payment destinations, and
`.skr` names are transferable and re-registrable — so a cached one pays whoever owned the
name when it was cached. Shortening that TTL is not enough, because the client is told to
re-resolve at send time and that re-resolution arrives *on this route*: any positive forward
cache silently answers it from the same stale entry the rule exists to avoid. So the sample
caches forward **misses** only and always resolves a real name for real. The client side of
this is in [client.md](client.md#caching-by-direction).
7. **CORS**: the allowlist comes from `ALLOWED_ORIGINS` and is mandatory under
`NODE_ENV=production`. `cors()` with no options reflects any origin, which makes the proxy
usable from any page on the internet — the exact thing that turns a key-protection layer into
a free relay for the key. A request with no `Origin` header (a native app, a server-side
caller, curl) is allowed through, because CORS never restricted those in the first place;
the rate limit is what covers them.
8. **Rate limit and body limit**: `express-rate-limit` caps the per-IP RPC spend, and
`express.json({ limit: '1kb' })` stops a caller pushing megabytes through the JSON parser
before any handler runs. Behind a proxy or load balancer, every request appears to come from
the proxy's IP and one client exhausts the limit for everyone — set
`app.set('trust proxy', <number of hops>)` to the count of proxies you actually control.
Never `app.set('trust proxy', true)`: it takes the client's own `X-Forwarded-For` at face
value, and rotating that header is a one-line bypass.
## Hono
Same five controls, rather less ceremony: `hono/cors` and `hono/body-limit` are built in, and
the whole thing is runtime-agnostic. Reasonable choice for a greenfield proxy. Express stays the
reference above because it is the shape most existing Node backends already have, and because
`express-rate-limit` is considerably more settled than `hono-rate-limiter`, which is still
pre-1.0 — and the rate limit is the control that actually stops the abuse this proxy invites.
**One trap, and it is the reason this sample is longer than you would expect.** `hono/cors` only
*sets response headers*; it does not reject a disallowed origin, because CORS is a browser
mechanism. `curl` and every server-side caller walk straight past it. Express's `cors` package
happens to block, because its origin callback can raise, and that difference is easy to carry
over by accident. If you want the allowlist enforced, write the check yourself.
```typescript
// backend/src/index.ts
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { getConnInfo } from '@hono/node-server/conninfo';
import { bodyLimit } from 'hono/body-limit';
import { cors } from 'hono/cors';
import { rateLimiter } from 'hono-rate-limiter';
import { address, createSolanaRpc, type Address } from '@solana/kit';
import { LRUCache } from 'lru-cache';
import { normalizeSkrName, resolveSkrDomain, resolveSkrNames } from './skr.js';
const PORT = Number(process.env.PORT ?? 3000);
const IS_PROD = process.env.NODE_ENV === 'production';
// Same rule as the Express version: no public-endpoint fallback, because a server whose job is
// to hold the key should fail visibly when it has not been given one.
const RPC_ENDPOINT = process.env.SOLANA_MAINNET_RPC_URL;
if (!RPC_ENDPOINT) {
throw new Error('SOLANA_MAINNET_RPC_URL is required');
}
const rpc = createSolanaRpc(RPC_ENDPOINT);
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
if (IS_PROD && ALLOWED_ORIGINS.length === 0) {
throw new Error('ALLOWED_ORIGINS is required in production');
}
// Forward: misses only, never a resolved address — it is a payment destination, and the client
// re-resolves at send time through this same route. Reverse: display labels, so cached normally.
const forwardMissCache = new LRUCache<string, true>({ max: 10_000, ttl: 60_000 });
const reverseCache = new LRUCache<string, { domain: string | null }>({
max: 10_000,
ttl: 3_600_000,
});
const app = new Hono();
app.get('/health', (c) => c.json({ status: 'ok' }));
// hono/cors sets response headers; it does NOT reject a disallowed origin, because CORS is a
// browser mechanism. Curl and any server-side caller sail straight past it. So the allowlist is
// enforced here, as an explicit 403, and cors() below only produces the browser-facing headers.
app.use('/api/*', async (c, next) => {
const origin = c.req.header('Origin');
if (origin && !ALLOWED_ORIGINS.includes(origin)) {
return c.json({ error: 'Origin not allowed' }, 403);
}
return next();
});
app.use(
'/api/*',
cors({
origin: (origin) => (ALLOWED_ORIGINS.includes(origin) ? origin : null),
}),
);
// Both routes carry one short string.
app.use(
'/api/*',
bodyLimit({
maxSize: 1024,
onError: (c) => c.json({ error: 'Body too large' }, 413),
}),
);
// Per-IP ceiling on the RPC spend. getConnInfo comes from the Node adapter; on another runtime
// use that runtime's adapter, and behind a proxy key on the forwarded header you control.
app.use(
'/api/*',
rateLimiter({
windowMs: 60_000,
limit: 60,
standardHeaders: 'draft-7',
keyGenerator: (c) => getConnInfo(c).remote.address ?? 'unknown',
}),
);
app.post('/api/resolve-domain', async (c) => {
const body = await c.req.json().catch(() => null);
const domain = (body as { domain?: unknown } | null)?.domain;
if (typeof domain !== 'string') {
return c.json({ error: 'Domain name is required' }, 400);
}
const label = normalizeSkrName(domain);
if (!label) {
return c.json({ error: 'Not a valid .skr domain' }, 400);
}
if (forwardMissCache.has(label)) {
return c.json({ error: 'Domain not found' }, 404);
}
try {
const owner = await resolveSkrDomain(rpc, label);
if (!owner) {
forwardMissCache.set(label, true);
return c.json({ error: 'Domain not found' }, 404);
}
return c.json({ address: owner });
} catch (error) {
console.error('RPC failure resolving domain:', error);
return c.json({ error: 'Resolution temporarily unavailable' }, 503);
}
});
app.post('/api/resolve-address', async (c) => {
const body = await c.req.json().catch(() => null);
const input = (body as { address?: unknown } | null)?.address;
if (typeof input !== 'string') {
return c.json({ error: 'Wallet address is required' }, 400);
}
let owner: Address;
try {
owner = address(input);
} catch {
return c.json({ error: 'Invalid wallet address' }, 400);
}
const cached = reverseCache.get(owner);
if (cached) {
return cached.domain
? c.json({ domain: cached.domain })
: c.json({ error: 'No .skr domain found for this address' }, 404);
}
try {
const domains = await resolveSkrNames(rpc, owner);
const domain = domains[0] ?? null;
reverseCache.set(owner, { domain });
return domain
? c.json({ domain })
: c.json({ error: 'No .skr domain found for this address' }, 404);
} catch (error) {
console.error('RPC failure resolving address:', error);
return c.json({ error: 'Resolution temporarily unavailable' }, 503);
}
});
serve({ fetch: app.fetch, port: PORT }, ({ port }) => {
console.log(`🚀 Server running on http://localhost:${port}`);
});
```
```json
{
"name": "skr-backend",
"private": true,
"type": "module",
"dependencies": {
"@hono/node-server": "^2.1.1",
"@noble/hashes": "^1.8.0",
"@solana/kit": "^8.0.0",
"hono": "^4.13.5",
"hono-rate-limiter": "^0.5.3",
"lru-cache": "^11.5.2"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}
```
Notes specific to this version:
- **`getConnInfo` is adapter-specific.** It is imported from `@hono/node-server/conninfo` here.
On another runtime, import it from that runtime's adapter; behind a proxy, key the limiter on
a forwarded header you control rather than the socket address.
- **On Workers or Deno Deploy, revisit the cache.** An in-memory `LRUCache` is per-isolate and
ephemeral there, so the hit rate collapses — and a shared cache is the main reason to run this
proxy at all. Use KV or the Cache API, and Cloudflare's rate-limiting binding in place of
`hono-rate-limiter`. `hono/cache` is Web Cache API based and is not available on Node.
- **`c.req.json()` rejects on a malformed body**, hence the `.catch(() => null)` and the
explicit type check, which is also what keeps bad input a 400 rather than a 500.
- **It also ignores `Content-Type`**, which is where the two samples genuinely diverge. Hono
parses the body whatever the header says, so a valid JSON body sent as `text/plain` is
answered normally; `express.json()` refuses to parse it and the Express sample returns a 400.
Neither leaks a 500, so this is a difference in leniency, not in safety — but do not assume
the two are byte-for-byte interchangeable in their responses.
## Other frameworks
Only the routing changes; the resolver calls are identical.
| Framework | Where the routes go |
| --- | --- |
| Fastify | `fastify.post('/api/resolve-domain', handler)` |
| Hono | [Full sample above](#hono) |
| Koa | Router middleware |
| NestJS | A controller plus an injectable service holding the RPC client |
| Next.js | Route handlers at `app/api/resolve-domain/route.ts` |
Construct the RPC client **once** at module scope, not per request. Building it per request adds
latency and, on some providers, trips connection limits.
## @onsol/tldparser alternative
Use the SDK when you need ANS features the Kit resolver does not implement — records, avatars, or
a user's `MainDomain`. For plain forward and reverse resolution the helper is less trouble.
Pin the current major. The skill previously pinned `^0.6.7`, which still installs but is two
majors behind what `npm install @onsol/tldparser` gives you:
```json
{
"dependencies": {
"@onsol/tldparser": "^1.2.1",
"@solana/web3.js": "^1.98.4"
}
}
```
```typescript
import { TldParser } from '@onsol/tldparser';
import { Connection, PublicKey } from '@solana/web3.js';
const connection = new Connection(RPC_ENDPOINT, 'confirmed');
const parser = new TldParser(connection);
// Forward — pass the FULL domain. A bare 'alice' throws.
try {
const owner = await parser.getOwnerFromDomainTld('alice.skr');
res.json({ address: owner.toBase58() });
} catch {
// Unregistered and malformed are indistinguishable here; both throw the same TypeError.
res.status(404).json({ error: 'Domain not found' });
}
// Reverse — TLD without the leading dot. '.skr' silently returns [].
const domains = await parser.getParsedAllUserDomainsFromTld(publicKey, 'skr');
// domains[n].domain already includes the suffix, e.g. 'alice.skr'.
const sorted = domains.map((d) => d.domain).sort();
```
Behaviour verified against mainnet on 1.2.1, and identical on 0.6.7 — the full-domain
requirement is not a recent API change:
- **`getOwnerFromDomainTld` requires the full domain.** It splits the argument on `.` and treats
the second segment as the TLD, so `'alice'` derives a name account under the TLD `.undefined`.
- **It throws rather than returning null.** `getNameOwner` dereferences `.owner` on a name record
it never checked for `undefined`, so any account it cannot fetch — unregistered name, typo,
bare label — raises `TypeError: Cannot read properties of undefined (reading 'owner')`. There
is no falsy-return path to branch on.
- **To distinguish not-found from bad input**, call `getNameRecordFromDomainTld(domain)`. It
returns `undefined` for a missing account instead of throwing, so you can validate first and
keep genuine RPC errors mapped to a 503.
- **`getMainDomain` throws when the user has never set a main domain**, which is the common case.
It is not a null-returning lookup either.
### The ESM build is broken
`dist/esm/index.js` uses extensionless relative imports (`from './parsers'`), which Node's ESM
resolver rejects:
```
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../dist/esm/parsers'
imported from .../dist/esm/index.js
```
The CJS build is fine, so any ESM project (`"type": "module"`) has to reach for it explicitly:
```typescript
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { TldParser } = require('@onsol/tldparser');
```
A bundler that resolves extensionless paths will paper over this; plain Node will not. The Kit
resolver has no such problem, which is one more reason it is the default.
SKILL.md
---
name: seeker-domains
description: Resolve and display .skr domain names in Solana mobile apps, in both directions between names and wallet addresses. Use when showing .skr names instead of wallet addresses in profiles, friend lists, or transaction history, resolving a .skr domain to an address, reverse-looking-up an address to a domain, or validating .skr input.
---
# .skr domain resolution
`.skr` domains are AllDomains names on Solana mainnet. Seeker users get one by default, which
makes them a good substitute for truncated addresses in a UI.
Two directions:
- **Forward** — `alice.skr` to a wallet address
- **Reverse** — a wallet address to the `.skr` names it owns
Both live on **mainnet**, regardless of which cluster the rest of the app targets. An app on
devnet still resolves names against mainnet.
## Decide where resolution runs
Resolution reads public on-chain data, so a client can do it directly. Proxy it through a
backend when you want:
- **RPC key protection.** A key in `EXPO_PUBLIC_*` is readable by anyone with the APK. If you
use a paid RPC, it has to be server-side.
- **Shared caching.** Names change rarely. One server-side cache beats every client
re-resolving the same addresses.
- **Batch lookups.** Resolving a whole friend list in one request beats N round trips from a
phone.
- **`getProgramAccounts` access.** Reverse lookup needs it, and plenty of providers disable or
heavily rate-limit the method. One server-side endpoint against a provider you have checked
beats discovering the restriction on user devices.
Direct client-side resolution against a public RPC is reasonable for a prototype or a
low-traffic app. Public endpoints are rate-limited, so it will not survive a list view that
resolves dozens of addresses.
Ask which the user wants if it is not obvious from the project. Default to the proxy for
anything heading to production.
## Integrating with an existing backend
**Check what exists before writing a new server.** Adding an Express app beside someone's
NestJS service is a mess to maintain.
1. Look for backend dependencies in every `package.json` — `express`, `fastify`, `hono`,
`@nestjs/core`, `koa`, or a Next.js app with API routes.
2. Look for entry points: `server.ts`, `app.ts`, `main.ts`, `index.ts`.
3. Look for route organisation: `routes/`, `api/`, `controllers/`.
4. Ask if it is still ambiguous — "I see a Fastify server in `apps/api`; should the `.skr`
endpoints go there?"
Add routes to what exists, matching its conventions for routing, validation, and error
handling. Only scaffold a minimal server when there is genuinely no backend.
## Core resolution logic
Resolution is framework-agnostic; only the routing around it changes. Two options, and the
default is the first.
### Kit (default)
`.skr` names are AllDomains (ANS) accounts, and resolving one is a PDA derivation plus a single
account read — small enough to own outright rather than take an SDK for.
```bash
npm install @solana/kit "@noble/hashes@^1"
```
Pin `@noble/hashes` to `^1`. A bare install now gives 2.x, whose `exports` map drops the
extensionless `./sha2` subpath. The resolver imports `@noble/hashes/sha2.js`, which both majors
accept, so it survives the upgrade even where the pin does not hold.
```ts
import { address, createSolanaRpc } from '@solana/kit'
import { resolveSkrDomain, resolveSkrNames } from './skr'
// Always mainnet, whatever cluster the rest of the app targets.
const rpc = createSolanaRpc(process.env.SOLANA_MAINNET_RPC_URL)
const owner = await resolveSkrDomain(rpc, 'alice.skr') // Address, or null
const names = await resolveSkrNames(rpc, address('5FHw...')) // ['alice.skr'], sorted
```
Copy the implementation from [references/kit-resolver.md](references/kit-resolver.md) — about 140
lines, typechecked under `tsc --strict`, and free of `Buffer`/`TextEncoder`, so the same file runs
on a server and in React Native. It accepts `alice.skr` or `alice`, returns `null` for an
unregistered name, and only rejects when the RPC itself fails.
Reach for the SDK instead when you need ANS records, avatars, or a user's `MainDomain`, none of
which the helper implements.
### @onsol/tldparser (alternative)
```bash
npm install @onsol/tldparser @solana/web3.js
```
```ts
import { TldParser } from '@onsol/tldparser'
import { Connection } from '@solana/web3.js'
const connection = new Connection(process.env.SOLANA_MAINNET_RPC_URL, 'confirmed')
const parser = new TldParser(connection)
// Forward: pass the FULL domain, including the .skr suffix.
const owner = await parser.getOwnerFromDomainTld('alice.skr')
// Reverse: TLD without the leading dot. Returns [{ nameAccount, domain: 'alice.skr' }].
const domains = await parser.getParsedAllUserDomainsFromTld(publicKey, 'skr')
```
Four things to get right, all verified against mainnet on 1.2.1:
- **`getOwnerFromDomainTld` needs the full domain.** `'alice.skr'` resolves; `'alice'` throws.
It splits on `.` and uses the second segment as the TLD, so a bare name derives a PDA under
the TLD `.undefined` and finds nothing.
- **It throws instead of returning null, and an unregistered name is indistinguishable from
malformed input** — both surface as `TypeError: Cannot read properties of undefined (reading
'owner')`, because the SDK dereferences a name record it never null-checked. Wrap every call
in `try`/`catch`; never branch on a falsy return. To tell the two apart, call
`getNameRecordFromDomainTld(domain)`, which returns `undefined` cleanly for a missing account.
- **`getParsedAllUserDomainsFromTld` wants the TLD without a dot.** `'skr'` works; `'.skr'`
silently returns `[]`. The `domain` field of each result already includes the suffix.
- **Reverse lookup returns an array.** An address can own several `.skr` names, and the order is
not a ranking. Sort and take the first, or the displayed name will change between calls.
Sorting makes the label stable, not trustworthy — see [Trusting a reverse-resolved
name](#trusting-a-reverse-resolved-name).
Its ESM build is also broken — see
[references/server.md](references/server.md#onsoltldparser-alternative) for that and the
`createRequire` workaround.
## API shape
Two endpoints, adapted to whatever framework is in use:
| Route | Body | Success | Not found |
| --- | --- | --- | --- |
| `POST /api/resolve-domain` | `{ domain: "alice.skr" }` | `{ address }` | 404 |
| `POST /api/resolve-address` | `{ address: "5FHw..." }` | `{ domain }` | 404 |
Validate input before touching RPC: reject a malformed base58 address or a domain that does
not end in `.skr` with a 400, so bad input does not consume RPC quota.
Distinguish "no domain registered" (404) from "RPC failed" (503). Collapsing both into 404
makes an outage look like every user having no name.
A proxy that exists to protect an RPC key has to not be an open relay in front of it. The
reverse route costs a `getProgramAccounts` plus a batched read of every name the address owns,
so the endpoint needs an origin allowlist, a per-IP rate limit, a body limit, and a cache that
also stores negative results. Require the RPC URL and the origin list at startup rather than
falling back to the public endpoint or to open CORS.
Full Express and Hono implementations, plus notes for Fastify, NestJS, Koa, and Next.js
route handlers: [references/server.md](references/server.md).
## Client integration
```ts
const { data: domain } = useResolveAddress(account?.address)
const label = domain ?? ellipsify(account?.address)
```
Always fall back to a truncated address. A name that fails to resolve should degrade to
something usable, never to a blank space or a spinner that never resolves.
For an Android emulator, `localhost` is the emulator itself. Reach the host machine at
`http://10.0.2.2:3000`. On a physical device use the host's LAN IP. Hard-coding either into
source is what breaks the app for the next person — read it from `EXPO_PUBLIC_API_URL`, and
gate any emulator fallback on `__DEV__`: `EXPO_PUBLIC_*` is inlined at build time, so an
ungated default ships a dead cleartext URL in the release APK, which Android blocks by default
anyway. Nothing with an RPC key belongs in an `EXPO_PUBLIC_*` variable at all — that is what
the proxy is for.
The snippet above assumes the proxy. If you resolve directly from the app instead, the Kit
resolver runs unchanged under Hermes — call it from the same hook in place of `fetch`.
Hook, components, and the truncation helper: [references/client.md](references/client.md).
### Trusting a reverse-resolved name
A reverse-resolved name is the first-sorting name an address happens to hold, and a `.skr`
transfer needs nothing from the recipient — anyone can push a name onto any wallet. So a
stranger can decide what your UI calls a user, by registering something that sorts early and
sending it over.
- **Show the name beside the truncated address, not instead of it.** The address is the part
a user can actually check.
- **Where the label stands in for identity** — a payee, a counterparty, a moderation surface —
prefer the owner's `MainDomain`, the name they chose. That is `@onsol/tldparser`'s
`getMainDomain`, which throws when the user never set one; treat the throw as "none" and fall
back to the address.
- **Reverse lookup must respect expiry.** An expired name that still resolves keeps labelling
an address with a name its owner has lost.
### Cache by direction
| Direction | Feeds | Cache |
| --- | --- | --- |
| Forward (name → address) | Payment destinations | None. Re-resolve at send time. |
| Reverse (address → name) | Display labels | Long `staleTime`; an hour is fine. |
Names change rarely, so caching reverse results is nearly free. Forward results are different:
`.skr` names are transferable and re-registrable, so a forward result cached for an hour and
then used to build a transfer pays whoever owned the name an hour ago, with nothing in the UI
looking wrong. Re-resolve immediately before signing, and put the resolved address in the
confirmation step so the user sees where the funds are actually going.
That has to hold on **both** sides. A client that re-resolves at send time gets nothing if the
proxy answers from its own forward cache, so the server caches forward misses only and never a
resolved address.
## Reference material
- [references/kit-resolver.md](references/kit-resolver.md) — the default resolver, how `.skr`
names are stored on chain, and what the helper deliberately leaves out
- [references/server.md](references/server.md) — Express implementation, other frameworks,
validation and error handling
- [references/client.md](references/client.md) — resolution hook, display components,
emulator networking
## Related skills
- `solana-mobile-wallet` — the wallet connection supplying the address to resolve
- `seeker-genesis-token` — verifying Seeker ownership
## Links
- AllDomains developer guide: https://docs.alldomains.id/protocol/developer-guide/ad-sdks/svm-sdks/solana-mainnet-sdk
- `@onsol/tldparser`: https://www.npmjs.com/package/@onsol/tldparser
- `@onsol/tldparser` source, for the account layouts: https://github.com/onsol-labs/tld-parser
- `@solana/kit`: https://www.npmjs.com/package/@solana/kit