solana-mobile/solana-mobile-skills包含需要注意的行为
SKILL DETAIL
seeker-domains
solana-mobile/solana-mobile-skills/seeker-domains
seeker-domains 技能用于在 Solana 移动应用中解析和显示 .skr 域名。.skr 域名是 Solana 主网上的 AllDomains 名称,Seeker 用户默认拥有一个,可作为 UI 中截断地址的良好替代。该技能支持正向解析(从域名到地址)和反向解析(从地址到域名),且无论应用目标集群如何,解析均针对主网进行。 解析逻辑与框架无关,默认使用 @solana/kit 实现,也可选择 @onsol/tldparser。技能提供了 API 设计建议(如 POST /api/resolve-domain 和 /api/resolve-address),并强调输入验证、区分“未注册”与“RPC 故障”等错误处理。客户端集成建议使用 React Query 缓存结果,并在解析失败时回退到截断地址。
安装量 · 950查看来源
Installation
npx skills add https://github.com/solana-mobile/solana-mobile-skills --skill seeker-domains
技能文件
SKILL.md
最近同步 · 2026年10月21日
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 with
[`@solana-mobile/skr-domain`](https://www.npmjs.com/package/@solana-mobile/skr-domain). It hashes
names with WebCrypto (`crypto.subtle.digest`), which Hermes lacks: install
`react-native-quick-crypto` and call its `install()` at startup, before the package is imported,
the way the Solana Mobile Expo templates do in their `polyfill.js`. It is a native module, so Expo
Go cannot load it: run a development build (`npx expo run:android`) that includes it, or use the
backend path above.
```typescript
// hooks/use-resolve-address.ts
import { useQuery } from '@tanstack/react-query';
import { address, createSolanaRpc } from '@solana/kit';
import { getPrimarySkrDomain, getSkrDomains } from '@solana-mobile/skr-domain';
// .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 wallet = address(walletAddress!);
// The name the wallet chose, else the first it owns (sorted, so stable), else null.
return (await getPrimarySkrDomain(rpc, wallet)) ?? (await getSkrDomains(rpc, wallet))[0] ?? null;
},
});
}
```
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.
- The fallback to `getSkrDomains` 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. Without a primary name, the label 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**: when a wallet has no primary name, the reverse-resolved `.skr` name is the first-sorting name it 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/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.
Resolution comes from [`@solana-mobile/skr-domain`](https://www.npmjs.com/package/@solana-mobile/skr-domain).
Its functions return `null` (or `[]`) for not-found and only reject when the RPC fails, which is
what the 404/503 split below relies on.
## 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 when the wallet has no primary name, 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 { getPrimarySkrDomain, getSkrDomains, normalizeSkrName, resolveSkrDomain } from '@solana-mobile/skr-domain';
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.
let label: string;
try {
label = normalizeSkrName(domain);
} catch {
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 resolved = await resolveSkrDomain(rpc, label);
// null is a genuine "not registered". A throw means the RPC failed — see below.
if (!resolved) {
forwardMissCache.set(label, true);
return res.status(404).json({ error: 'Domain not found' });
}
// Deliberately not cached — see the cache declarations above.
res.json({ address: resolved.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 {
// The name the wallet chose, else the first name it owns. getSkrDomains is sorted, so the
// fallback is stable across calls for multi-domain owners.
const domain =
(await getPrimarySkrDomain(rpc, owner)) ?? (await getSkrDomains(rpc, owner))[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",
"type": "module",
"engines": {
"node": ">=20.18.0"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@solana-mobile/skr-domain": "^1.1.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",
"tsx": "^4.19.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.
The project is ESM (`"type": "module"`, `"module": "nodenext"`) because `@solana-mobile/skr-domain`
ships ESM only, and on Node 20.18 or later because that is the package's declared minimum.
## TypeScript Configuration
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "nodenext",
"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
`{ owner, nameAccount, expiresAt }`, or `null` when the name is unregistered or expired.
Validate with `normalizeSkrName` first — it throws on malformed input — so bad input is a 400
rather than a wasted RPC round trip.
3. **Reverse lookup**: `getPrimarySkrDomain` returns the name the wallet chose, or `null`. Most
wallets never choose one, so fall back to `getSkrDomains`, which returns **all** live `.skr`
names owned by an address, sorted; take `[0]`. `getSkrDomains` 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 package 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 { getPrimarySkrDomain, getSkrDomains, normalizeSkrName, resolveSkrDomain } from '@solana-mobile/skr-domain';
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);
}
let label: string;
try {
label = normalizeSkrName(domain);
} catch {
return c.json({ error: 'Not a valid .skr domain' }, 400);
}
if (forwardMissCache.has(label)) {
return c.json({ error: 'Domain not found' }, 404);
}
try {
const resolved = await resolveSkrDomain(rpc, label);
if (!resolved) {
forwardMissCache.set(label, true);
return c.json({ error: 'Domain not found' }, 404);
}
return c.json({ address: resolved.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 domain =
(await getPrimarySkrDomain(rpc, owner)) ?? (await getSkrDomains(rpc, owner))[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",
"@solana-mobile/skr-domain": "^1.1.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 package 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.
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 a `.skr` name: its primary name, or else one 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
Use [`@solana-mobile/skr-domain`](https://www.npmjs.com/package/@solana-mobile/skr-domain). Do
not copy a resolver into the project or reach for another SDK: the package is maintained by
Solana Mobile, built on `@solana/kit`, and covers both directions. Resolution is
framework-agnostic; only the routing around it changes.
```bash
npm install @solana-mobile/skr-domain @solana/kit
```
```ts
import { getPrimarySkrDomain, getSkrDomains, normalizeSkrName, resolveSkrDomain } from '@solana-mobile/skr-domain'
import { address, createSolanaRpc } from '@solana/kit'
// Always mainnet, whatever cluster the rest of the app targets.
const rpc = createSolanaRpc(process.env.SOLANA_MAINNET_RPC_URL!)
// Forward: { owner, nameAccount, expiresAt }, or null when unregistered or expired.
const domain = await resolveSkrDomain(rpc, 'alice.skr')
// Reverse: the name the wallet chose, else the first name it owns (sorted), else null.
const wallet = address('5FHw...')
const name = (await getPrimarySkrDomain(rpc, wallet)) ?? (await getSkrDomains(rpc, wallet))[0] ?? null
```
What the package does, so you do not re-implement it:
- **Not-found is `null` (or `[]`), never a throw.** A rejection means the RPC failed, so the two
map cleanly onto a 404 and a 503.
- **Invalid input throws a plain `Error`.** `normalizeSkrName` accepts `alice`, `Alice.skr` and
`' alice '`, and throws for subdomains, other TLDs, or an empty label. Call it inside a
`try`/`catch` to turn bad input into a 400 before spending RPC quota.
- **Expired names resolve as unregistered**, in both directions, with no grace period.
- **`getPrimarySkrDomain` re-checks ownership.** A `MainDomain` account is not updated on
transfer, so the package resolves the name forward and only returns it if the wallet still owns
it.
- **`getSkrDomains` uses `getProgramAccounts`**, which many providers disable or rate-limit. It
reads labels in batches of 100, one request at a time, so a wallet stuffed with names cannot
set the size of a burst against your quota.
- **NFT-wrapped names are out of scope.** None exist under `.skr` today; `resolveSkrDomain`
throws if one appears.
**React Native needs a WebCrypto polyfill.** Deriving a name address calls
`crypto.subtle.digest`, which Hermes lacks. Install `react-native-quick-crypto` and call its
`install()` at startup, before the package is imported. Node 20.18+, Bun and browsers need nothing.
**The package is ESM-only.** A CommonJS server needs `"type": "module"` (or Node 22.12+, which can
`require()` ES modules).
For ANS records or avatars, which the package does not cover, use the AllDomains SDK — see the
links below.
## 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 name that
`normalizeSkrName` rejects 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 up to two `getAccountInfo` calls, a `getProgramAccounts`, and 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 package
runs under Hermes once the WebCrypto polyfill is installed — 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
The primary name is one the wallet chose. A name from `getSkrDomains` is not: a `.skr` transfer
needs nothing from the recipient, so anyone can push a name onto any wallet, and a stranger can
decide what your UI calls a user without a primary name by sending one that sorts early.
- **Prefer `getPrimarySkrDomain`**, and fall back to `getSkrDomains` only for display.
- **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 —
use the primary name only, and fall back to the address.
### 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/server.md](references/server.md) — Express and Hono implementations, 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
- `@solana-mobile/skr-domain`: https://www.npmjs.com/package/@solana-mobile/skr-domain
- `@solana-mobile/skr-domain` source and README: https://github.com/solana-mobile/skr-js/tree/main/packages/skr-domain
- AllDomains developer guide: https://docs.alldomains.id/protocol/developer-guide/ad-sdks/svm-sdks/solana-mainnet-sdk
- `@solana/kit`: https://www.npmjs.com/package/@solana/kit