SKILL DETAIL
seeker-domains
solana-mobile/solana-mobile-skills/seeker-domains
The seeker-domains skill enables resolving and displaying .skr domain names in Solana mobile apps. .skr domains are AllDomains names on Solana mainnet, and Seeker users get one by default, making them a good substitute for truncated addresses in a UI. The skill supports both forward resolution (domain to address) and reverse resolution (address to domain), and resolution always targets mainnet regardless of the cluster the rest of the app uses. Resolution logic is framework-agnostic, with the default implementation using @solana/kit, and an alternative using @onsol/tldparser. The skill provides API design suggestions (e.g., POST /api/resolve-domain and POST /api/resolve-address) and emphasizes input validation and distinguishing between 'no domain registered' and 'RPC failed' errors. Client integration recommends caching results with React Query and falling back to truncated addresses when resolution fails.
Installation
npx skills add https://github.com/solana-mobile/solana-mobile-skills --skill seeker-domains
技能檔案
SKILL.md
最近同步 · 2026年8月29日
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.
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://10.0.2.2:3000';
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.
const rpc = createSolanaRpc(process.env.EXPO_PUBLIC_SOLANA_MAINNET_RPC_URL!);
export function useResolveAddress(walletAddress?: string) {
return useQuery({
queryKey: ['skr-name', walletAddress],
enabled: !!walletAddress,
staleTime: 1000 * 60 * 60, // names change rarely
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:
- The RPC URL ships inside the APK. `EXPO_PUBLIC_*` is readable by anyone who unzips it, so this
only works with an endpoint you do not mind exposing.
- Reverse lookup calls `getProgramAccounts`, which many providers restrict. A 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 below.
## 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 [displayName, setDisplayName] = useState<string>('');
useEffect(() => {
if (!account) return;
// 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();
resolveAddress(address).then((result) => {
setDisplayName(result.domain ?? ellipsify(address));
});
}, [account]);
return (
<View>
{loading ? (
<Text>Loading...</Text>
) : (
<Text>Welcome, {displayName || 'Guest'}!</Text>
)}
</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 [displayName, setDisplayName] = useState(ellipsify(address));
useEffect(() => {
// Try to fetch .skr domain
resolveAddress(address).then((result) => {
if (result.domain) {
setDisplayName(result.domain);
}
});
}, [address]);
return (
<View>
<Text>{displayName}</Text>
</View>
);
}
```
## 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.
2. **Caching**: Cache resolved domains. `@tanstack/react-query` with a long `staleTime` is enough — names change rarely, and a list view otherwise re-resolves the same addresses on every render pass.
3. **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.
4. **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.
5. **Validation**: The backend validates input; validating on the client too avoids a round trip for obviously malformed input.
6. **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
```
`@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.
## 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 costs one extra read per name.
## 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';
import { sha256 } from '@noble/hashes/sha2';
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;
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));
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',
dataSlice: { offset: 0, length: 0 },
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 names = await Promise.all(
accounts.map(async ({ pubkey }) => {
const data = await fetchAccountData(rpc, await deriveReverseAccount(pubkey, tldHouse));
if (!data || data.length <= HEADER_SIZE) return null;
const label = utf8Decoder.decode(data.subarray(HEADER_SIZE)).replace(/\0.*$/, '');
return label ? `${label}${TLD}` : null;
}),
);
return names.filter((name): name is string => name !== null).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 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.
**Expiry.** `expiresAt` of `0` means non-expiring, which is what Seeker-issued `.skr` names
carry today. The check above treats 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
```typescript
// backend/src/index.ts
import express, { Request, Response } from 'express';
import { address, createSolanaRpc, type Address } from '@solana/kit';
import cors from 'cors';
import { normalizeSkrName, resolveSkrDomain, resolveSkrNames } from './skr';
const app = express();
const PORT = Number(process.env.PORT ?? 3000);
// Public endpoints are rate-limited and will not survive resolving a list view. Point this at
// a dedicated provider in production, and keep the key server-side. The reverse route needs
// getProgramAccounts, which some providers disable — check before deploying.
const RPC_ENDPOINT = process.env.SOLANA_MAINNET_RPC_URL ?? 'https://api.mainnet-beta.solana.com';
// Build the RPC client once at module scope, not per request.
const rpc = createSolanaRpc(RPC_ENDPOINT);
app.use(cors());
app.use(express.json());
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok' });
});
// Resolve .skr domain to wallet address
app.post('/api/resolve-domain', async (req: Request, res: Response) => {
const { domain } = req.body;
if (!domain || typeof domain !== 'string') {
return res.status(400).json({ error: 'Domain name is required' });
}
// Reject malformed input before spending RPC quota on it.
if (!normalizeSkrName(domain)) {
return res.status(400).json({ error: 'Not a valid .skr domain' });
}
try {
const owner = await resolveSkrDomain(rpc, domain);
// null is a genuine "not registered". A throw means the RPC failed — see below.
if (!owner) {
return res.status(404).json({ error: 'Domain not found' });
}
res.json({ address: owner });
} catch (error) {
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 { address: input } = req.body;
if (!input || typeof input !== 'string') {
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' });
}
try {
const domains = await resolveSkrNames(rpc, owner);
if (domains.length === 0) {
return res.status(404).json({ error: 'No .skr domain found for this address' });
}
// Already sorted, so this is stable across calls for multi-domain owners.
res.json({ domain: domains[0] });
} catch (error) {
console.error('RPC failure resolving address:', error);
res.status(503).json({ error: 'Resolution temporarily unavailable' });
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
});
```
## 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": "^4.21.0"
},
"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"
}
}
```
## 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**: the public mainnet endpoint is fine for development. It will not survive a
list view that resolves many addresses — 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. A short-TTL in-memory cache, keyed on the address or name,
removes most repeated RPC calls — this is the main reason to proxy rather than resolve from
the client.
6. **CORS**: open CORS is for local development. Restrict `origin` to known callers before
deploying.
## Other frameworks
Only the routing changes; the resolver calls are identical.
| Framework | Where the routes go |
| --- | --- |
| Fastify | `fastify.post('/api/resolve-domain', handler)` |
| NestJS | A controller plus an injectable service holding the RPC client |
| Hono | `app.post('/api/resolve-domain', handler)` |
| Koa | Router middleware |
| 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
```
```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.
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.
Full Express implementation, plus notes for Fastify, NestJS, Hono, 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.
Cache results — `@tanstack/react-query` with a long `staleTime` is enough, since names change
rarely.
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`.
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).
## 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