@three-ws/names
v0.1.1
Published
ENS + SNS name resolution, *.threews.sol subdomain minting, and pay-by-name in one import. The three.ws agent-identity SDK.
Maintainers
Readme
@three-ws/namesis the official client for the three.ws naming layer — the identity plumbing behind every agent on the platform. It resolves human-readable names to on-chain addresses across ENS (Ethereum) and SNS (Solana), mints<label>.threews.solsubdomains under the platform-owned parent, and routes USDC payments to a recipient identified by name instead of a 44-character base58 key. It wraps the public/api/sns,/api/sns-subdomain,/api/threews/subdomain, and/api/x402/pay-by-nameendpoints. If your agent has a wallet, this gives it a name people can read, type, and pay.
Why
A wallet address is unusable as an identity. Nobody types
FeMbDoX7R1Psc4GEcvJdsbNbZA3bfztcyDCatJVJpump from memory, and nobody should
have to. The naming layer turns identity into a string:
- Resolve in one call.
resolve('vitalik.eth')andresolve('bonfida.sol')hit the right registry automatically — ENS via an Ethereum RPC with failover, SNS via Bonfida — and a bare label is tried against both. - Mint a name your agent owns.
mintSubdomain('alice')registersalice.threews.solon-chain, writes a Brave-resolvable URL record, and transfers ownership to the agent's wallet — all in one signed transaction. The platform absorbs the gas; the agent's wallet never signs. - Pay by name.
payByName('alice.threews.sol', '5')resolves the recipient and builds (or sends) a USDC transfer — handles,.soldomains, and raw addresses all route through the same call, with a recipient-poisoning guard.
Hand-rolling this means three different on-chain SDKs (Bonfida SNS, ethers, SPL token), RPC failover, label validation, an availability denylist, and a preview-then-send flow that survives a name being re-pointed mid-flight. This package is that, done once.
This is the SDK twin of the ens_sns_resolve MCP tool —
the same resolution engine, exposed as plain functions instead of an MCP tool.
Install
npm install @three-ws/namesZero runtime dependencies. Works in Node 18+ and the browser (uses fetch).
Minting and mode: 'send' payments require a signed-in three.ws session or a
bearer token. The browser prep lane needs no auth — it returns an unsigned
transaction for a wallet to sign.
Quick start
Resolution needs no key:
import { resolve } from '@three-ws/names';
const eth = await resolve('vitalik.eth');
console.log(eth.address); // → 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
const sol = await resolve('bonfida.sol');
console.log(sol.address); // → owner base58 wallet
console.log(sol.allDomains); // → other .sol domains the owner holdsMint a subdomain for an agent and pay it by name:
import { mintSubdomain, payByName } from '@three-ws/names';
// Registers alice.threews.sol on-chain, transfers it to the agent's wallet.
const { full_name, signature } = await mintSubdomain({
agentId: 'agt_7Yq…',
label: 'alice', // optional — defaults to the agent's slugified name
token: process.env.THREEWS_TOKEN,
});
console.log(full_name); // → alice.threews.sol
// Build an unsigned 5-USDC transfer to that name for a browser wallet to sign.
const { tx_base64, recipient } = await payByName('alice.threews.sol', '5', {
payerWallet: myWallet.publicKey.toBase58(),
});
console.log(recipient.address, recipient.source); // resolved wallet + 'sns'API
resolve(name, options?) → Promise<ResolveResult>
Resolve a name to an address. .eth goes to ENS, .sol to SNS, and a bare
label (vitalik) is tried against the .sol registry. Wraps
GET /api/sns?name=<name> for .sol and the ENS resolver for .eth.
Returns ResolveResult
| Field | Type | Notes |
|---|---|---|
| name | string | The resolved name, e.g. alice.sol or vitalik.eth. |
| address | string \| null | Owner wallet. null when resolved is false. |
| network | 'solana' \| 'ethereum' | Which registry answered. |
| resolved | boolean | false is a routine "no such name", not an error. |
| allDomains | string[] | (SNS) other .sol domains the owner holds. |
| favoriteDomain | string \| null | (SNS) the owner's primary .sol, if set. |
A .sol miss returns 200 with resolved: false — the reverse-lookup that
runs on every page load makes "no domain" an expected answer, so it is never a
404. Malformed input is a 400.
reverseLookup(address) → Promise<ResolveResult>
Find the primary .sol for a wallet. Wraps GET /api/sns?address=<base58>.
Returns the same envelope with name populated (or null if the wallet has no
favorite domain).
checkSubdomain(label) → Promise<Availability>
Check whether <label>.threews.sol is free. Wraps
GET /api/sns-subdomain?label=<label> (no auth).
| Field | Type | Notes |
|---|---|---|
| label | string | Normalized label. |
| parent | string | threews.sol. |
| full_name | string | <label>.threews.sol. |
| available | boolean | true if no on-chain owner. |
| owner | string \| null | Current on-chain owner, if any. |
mintSubdomain(input) → Promise<MintResult>
Mint <label>.threews.sol, write its Brave URL record, and transfer ownership
to a wallet — atomically, in one platform-signed transaction. Wraps
POST /api/sns-subdomain. Requires auth.
Input
| Field | Type | Notes |
|---|---|---|
| agentId | string | Required. The agent the subdomain attaches to. |
| label | string | Optional. Defaults to the agent's slugified name. 1–63 chars [a-z0-9-]. |
| ownerAddress | string | Optional base58 wallet to receive ownership. Must be linked to your account. Defaults to the agent's own Solana wallet. |
| space | number | Optional, 1000–10000. Registry bytes reserved. Default 2000. |
| token | string | Bearer token (or rely on a session cookie). |
Returns MintResult: { ok, agent_id, full_name, parent, owner, signature, explorer, url_record, agent_url }. The new subdomain's URL record points at
https://three.ws/a/<agentId>, and the agent's meta.sns_domain is set so x402
manifests can show recipient_name without an extra round-trip.
For a user/username-claim subdomain (showcased at
/u/<label>, where the label must equal your username), useclaimSubdomaininstead.
claimSubdomain(input) → Promise<ClaimResult>
Claim <username>.threews.sol for the signed-in user, with its URL record set
to https://three.ws/u/<username>. Wraps POST /api/threews/subdomain. The
label must equal your account username — divergent labels would let users
impersonate other handles. Pass ownerWallet (linked to your account) or fall
back to your default agent's Solana wallet. releaseSubdomain(label) wraps the
DELETE route to drop the local claim (on-chain ownership is unchanged).
payByName(name, amountUsdc, options?) → Promise<PayResult>
Pay a recipient by name in USDC. Wraps POST /api/x402/pay-by-name. The name
resolves across three namespaces in order: @username → that user's default
agent wallet; .sol domain (including foo.threews.sol) → on-chain owner;
raw base58 → pass-through.
Options
| Option | Type | Default | Notes |
|---|---|---|---|
| mode | 'prep' \| 'send' | 'prep' | prep returns an unsigned tx; send has the agent sign and broadcast. |
| payerWallet | string | — | Required for prep. Base58 fee-payer + source. |
| agentId | string | — | Required for send. The agent that signs (must be yours). |
| expectedAddress | string | — | (send) the address you previewed. If the name now resolves elsewhere, the call rejects with recipient_changed before signing. |
| message | string | — | Optional memo. |
| token | string | — | Bearer token for send. |
Returns (prep) { recipient, amount_usdc, tx_base64, blockhash, last_valid_block_height, mint } — decode tx_base64 into a VersionedTransaction, sign with the payer wallet, and submit. Returns (send) { recipient, payer, amount_usdc, signature, mode }. amount_usdc accepts a string or number, must be > 0 and ≤ 10000.
resolvePayee(name) → Promise<Payee>
Resolve-only, no payment. Wraps GET /api/x402/pay-by-name?name=<name>. Returns
{ name, address, source, resolved, claim? } where source is one of
address, sns, or username. A 404 means the name resolved nowhere.
How it works
Three namespaces, one ergonomic surface. Each call routes by the shape of the string you pass:
resolve / payByName
│
┌───────────┼─────────────────────────┐
*.eth│ *.sol│ base58 / @handle
▼ ▼ ▼
ENS resolver Bonfida SNS users.username
(eth RPC, resolve() + reverse → default agent
failover, domains + fav-domain Solana wallet
3s timeout) (.threews.sol also
│ │ surfaces a DB claim)
▼ ▼ ▼
0x… address owner base58 recipient address
│
mintSubdomain ──▶ createSubdomain → URL record → transferSubdomain
(one VersionedTransaction, signed by the platform parent-owner keypair)- ENS resolves on Ethereum mainnet through a failover provider, bounded by a 3-second timeout. A reverse lookup of the owner's primary name is best-effort.
- SNS resolves through Bonfida — owner wallet, the owner's other
.soldomains, and their favorite domain. - Subdomain minting is a single on-chain transaction:
createSubdomain(parent owner becomes owner) →createRecordV2Instructionwrites the URL record while the platform still owns it (so<label>.threews.solresolves in Brave) →transferSubdomainhands it to the agent's wallet. The platform's parent-owner keypair (THREEWS_SOL_PARENT_SECRET_BASE58) is the only signer and fee payer — gas is under 0.01 SOL and absorbed, so the agent's wallet never has to sign. - Pay-by-name uses the USDC mint (
EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v, 6 decimals), creating the recipient's associated token account idempotently.mode: 'send'enforces the agent's per-transaction spend limit and rejects off-curve recipients.
Environment
THREEWS_SOL_PARENT_SECRET_BASE58 — the base58-encoded 64-byte ed25519 secret
for the wallet that owns the parent .sol (default threews.sol, overridable
with THREEWS_SOL_PARENT_DOMAIN). This is server-side only; it gates all
subdomain minting. When it is absent the mint endpoints answer 503
config_missing rather than failing mid-transaction — the SDK surfaces that as
a clean error, never a fake success.
Pricing / payment
| Action | Cost |
|---|---|
| resolve, reverseLookup, checkSubdomain, resolvePayee | Free. Public, rate-limited per IP. |
| mintSubdomain, claimSubdomain | Free to the caller — the platform absorbs the sub-0.01-SOL gas. Requires auth. |
| payByName | The transferred USDC amount, plus Solana network fees. |
| ens_sns_resolve MCP tool | $0.0005 USDC over x402 — the metered equivalent of resolve. |
.sol reads are cached server-side (5 min positive, 60 s negative), and ENS
resolutions are cached 5 min in-memory, so repeated UX previews don't hammer the
RPC pools.
Errors & edge cases
Every state is designed. Resolution failures return data, not exceptions; only real faults reject.
| State | HTTP | Meaning | Recovery |
|---|---|---|---|
| resolved: false | 200 | Name has no owner. Not an error. | Show "unclaimed"; offer to mint. |
| validation_error | 400 | Malformed name, label, or amount. | Fix the input. Labels are 1–63 chars [a-z0-9-]. |
| unauthorized | 401 | Mint or mode: 'send' without auth. | Sign in or pass a bearer token. |
| forbidden | 403 | ownerAddress not linked to your account. | Link the wallet, or omit it. |
| per_tx_exceeded | 403 | Send over the agent's per-transaction limit. | Lower the amount or raise the agent's limit. |
| not_found | 404 | Agent missing, or payee resolved nowhere. | Check the agent ID / name. |
| agent_missing_wallet | 412 | Agent has no Solana wallet to attach to. | Provision one via POST /api/agents/:id/solana. |
| conflict | 409 | <label>.threews.sol already registered. | Pick another label. |
| recipient_changed | 409 | Name re-pointed since you previewed it. | Re-preview, then re-send. |
| no_username / username_mismatch | 409 | Claim label ≠ your username. | Set/match your username first. |
| config_missing | 503 | Platform parent-owner key not configured. | Server-side THREEWS_SOL_PARENT_SECRET_BASE58 must be set. |
| ens_timeout | 503 | ENS RPC exceeded 3 s. | Retry; the failover provider rotates endpoints. |
Examples
Under the hood — resolve .sol with raw fetch (what resolve wraps):
const r = await fetch('https://three.ws/api/sns?name=bonfida.sol');
const { data } = await r.json();
// → { name: 'bonfida.sol', address: '…', network: 'solana', resolved: true }Agent identity, end to end — give an agent a name, then receive a payment:
import { mintSubdomain, resolvePayee } from '@three-ws/names';
const { full_name } = await mintSubdomain({ agentId, label: 'oracle', token });
// → oracle.threews.sol, owned by the agent's wallet, URL record → /a/<agentId>
const payee = await resolvePayee(full_name);
// → { name: 'oracle.threews.sol', address: '…', source: 'sns', claim: … }Browser pay-by-name — build, sign in the wallet, submit:
import { payByName } from '@three-ws/names';
import { VersionedTransaction } from '@solana/web3.js';
const { tx_base64 } = await payByName('alice.threews.sol', '2.5', {
payerWallet: wallet.publicKey.toBase58(),
});
const tx = VersionedTransaction.deserialize(Buffer.from(tx_base64, 'base64'));
const signed = await wallet.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize());Agent-signed send — the agent's custodial wallet pays, with a poisoning guard:
const preview = await resolvePayee('alice.threews.sol');
const { signature } = await payByName('alice.threews.sol', '5', {
mode: 'send',
agentId,
expectedAddress: preview.address, // rejects if the name re-points before signing
token,
});Related
@three-ws/x402-fetch— auto-pay the$0.0005ens_sns_resolveMCP tool and other x402 lanes.@three-ws/forge— generate the agent's 3D avatar to pair with its name.@three-ws/avatar— render the named agent's avatar in the browser.
