Build Apps
Names for Developers
@usernames from code · @asentum/sdk 0.3.1 · Estimated read time: 10 minutes
Updated 2026-11-07: names are live since block 1,200,000; the SDK has write methods for every registry call; marketplace section added.
How names work
A native name such as @alice points at an address. Names are stored in the NameRegistry, a JavaScript system contract that is part of the chain at ase1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqty736ey. It has been live since block 1,200,000 (7 November 2026) on the testnet, chain 1423.
- Every write (register, transfer, primary, records) is an ordinary signed contract call to the registry.
- Every read is a free view call, or one of two node lookups.
- The registry checks ownership with
msg.sender, so the owner of a name can be a wallet or a contract. - There is no approve or operator method. Only the current owner can move a name. Marketplaces hold names in escrow instead: see Building a names marketplace.
For the user side (what a name is, the reserved list, registering in the explorer) see Native Usernames.
Rules and fees
- 3 to 24 characters:
a-z,0-9and the hyphen. No hyphen at the start or end, no double hyphen, ASCII only, and nothing that starts with0xorase1. - Input is case-insensitive and may carry one leading
@.normalizeNamegives the canonical form. - An address can register at most 5 names per 43,200 blocks.
capOf(address)shows how many are used. - The first name an address gets becomes its primary name.
- Some names are reserved. Only the registrar can release them.
isReserved(name)tells you.
| Length | Fee |
|---|---|
| 3 characters | 500 ASE |
| 4 characters | 100 ASE |
| 5 characters | 50 ASE |
| 6 or more | 10 ASE |
The value of register must be exactly the fee, or the call reverts. Half is burned and half goes to the treasury. Transfers, primary changes and records cost only gas, and every method except register rejects a non-zero value.
Setup
The reads below need @asentum/sdk 0.3.0. The wallet write methods (registerName, transferName, setPrimaryName, clearPrimaryName, setNameRecord) arrived in 0.3.1. With an older SDK, send the same calls with wallet.sendCall.
npm install @asentum/sdk@^0.3.1
import { AsentumClient, AsentumWallet } from '@asentum/sdk';
const client = new AsentumClient('https://testnet.asentum.com');
const wallet = AsentumWallet.fromSecretKey(client, SECRET_KEY_HEX, PUBLIC_KEY_HEX);Every write method takes the usual send options (gasLimit, nonce, signal) and returns a SentTx. Call .wait() for the receipt and check receipt.success: a revert such as "name is taken" shows up there, not as a thrown error. An invalid name throws InvalidNameError before anything is signed. See the SDK reference for the full list.
Reading names
import { NAME_REGISTRY_CONTRACT, formatName, toAse1 } from '@asentum/sdk';
// false before block 1,200,000 and on nodes older than 0.9.0
if (await client.namesActive()) {
const owner = await client.resolveName('@alice'); // ase1 owner, or null when free
const primary = await client.lookupAddress(wallet.address); // "alice" or null
if (primary) console.log(formatName(primary)); // "@alice"
// A "to" field: "@name" is resolved, an address passes through.
const { address, name } = await client.resolveRecipient('@alice');
// The full record (owner, registeredAt, primary flag, records) is a view call.
const rec = await client.view(NAME_REGISTRY_CONTRACT, 'get', ['alice']);
if (rec) console.log(toAse1(rec.owner), rec.records.x); // owner comes back as hex
// Free to register? reason is "taken", "reserved", a broken rule, or null.
const info = await client.view(NAME_REGISTRY_CONTRACT, 'isAvailable', ['alice']);
}| Call | Returns |
|---|---|
| client.namesActive() | true once the node serves names. False before the flag day and on nodes older than 0.9.0. |
| client.resolveName(name) | The ase1 owner, or null when the name is free. |
| client.getNameLookup(name) | The full GET /name/<name> body: { active, name, address, ase1, blockNumber }. |
| client.lookupAddress(address) | The primary name of an address without the @, or null. |
| client.resolveRecipient(input) | { address, name } for a "to" field. Throws NameNotFoundError for a free name. |
| view get(name) | { name, owner, registeredAt, primary, records } or null. owner is lowercase hex. |
| view isAvailable(name) | { name, available, reason, fee }. fee is in wei. |
| view primaryOf(address), namesOfCount(address), capOf(address), isReserved(name), params(), stats() | See the contract methods on the Native Usernames page. |
Primary names can change in any block. Cache them for display for a minute at most, and resolve again right before you send.
Registering
import { normalizeName, nameFeeWei, formatAse } from '@asentum/sdk';
const name = normalizeName('@MyName'); // "myname", or null if invalid
console.log('fee', formatAse(nameFeeWei(name)), 'ASE'); // 10 ASE for 6+ characters
const tx = await wallet.registerName(name); // sends exactly nameFeeWei(name)
const receipt = await tx.wait();
if (!receipt.success) throw new Error(receipt.error); // e.g. "name is taken"registerNamesends exactlynameFeeWei(name), so you never compute the value yourself.- Check
isAvailablefirst to show the user why a name is not free (taken, reserved, or which rule it breaks). - A sixth registration inside 43,200 blocks reverts. Use
capOf(address)to show the limit before the user pays gas.
Transferring
transferName(name, to) calls transfer(name, to) on the registry. Only the owner can call it and there is no fee. The recipient can be an ase1 address (the SDK converts it) or a contract.
const tx = await wallet.transferName('myname', 'ase1wtc569gfwk87hplaawr46uj4suwsx2wndgf8ue');
const receipt = await tx.wait();
if (!receipt.success) throw new Error(receipt.error); // e.g. "you do not own this name"- If the name was the sender's primary name, the sender is left with no primary name.
- The records on the name (avatar, url, x, description) are cleared. The new owner starts clean.
- The name does not become the recipient's primary name. They call
setPrimaryNamethemselves. - Transferring to yourself reverts.
- A transfer is final. There is no undo, so check the recipient before you send. Sending to a contract that has no way to send the name back locks it there for good.
Primary names
The primary name is the one apps show for an address. An address has at most one.
await (await wallet.setPrimaryName('myname')).wait(); // must be a name you own
await (await wallet.clearPrimaryName()).wait(); // show no name; you keep the nameRecords
The owner can attach text records to a name. Keys are fixed:
| Key | Value |
|---|---|
| avatar | An image link starting with https://, http:// or ipfs:// (up to 256 characters) |
| url | A link starting with https://, http:// or ipfs:// (up to 256 characters) |
| x | An X handle (up to 15 characters; a leading @ is dropped) |
| description | A short bio (up to 160 characters) |
await (await wallet.setNameRecord('myname', 'avatar', 'ipfs://bafy...')).wait();
await (await wallet.setNameRecord('myname', 'url', 'https://example.com')).wait();
await (await wallet.setNameRecord('myname', 'x', '@asentum')).wait();
await (await wallet.setNameRecord('myname', 'description', 'Builder on Asentum')).wait();
await (await wallet.setNameRecord('myname', 'x', '')).wait(); // '' clears the recordRead them from get(name).records. Records are text the owner wrote: render them as plain text and treat links as untrusted.
From a dapp (@asentum/connect)
In a browser dapp the user's wallet signs, so use @asentum/connect 0.2.0. It has registerName, setPrimaryName, nameAvailability, resolveRecipient and primaryName on the client, and usePrimaryName and useNamesActive hooks. For transfers and records, call the registry through useContract.
import { useAsentum, useContract, useWallet, usePrimaryName,
NAME_REGISTRY_CONTRACT } from '@asentum/connect';
function Names() {
const client = useAsentum();
const { name } = useWallet(); // the connected account's primary name, or null
const registry = useContract(NAME_REGISTRY_CONTRACT);
async function register(input) {
const a = await client.nameAvailability(input); // { available, reason, feeWei }
if (!a.available) throw new Error(a.reason);
const txHash = await client.registerName(input); // the wallet asks the user; sends the exact fee
return client.waitReceipt(txHash);
}
// The kit has no transferName or setNameRecord helper: call the registry directly.
const transfer = (n, to) => registry.call('transfer', [n, to], '0', { gasLimit: 300000 });
const setRecord = (n, k, v) => registry.call('setRecord', [n, k, v], '0', { gasLimit: 300000 });
}Node lookups
Without an SDK, any node from release 0.9.0 answers two GET routes. Both accept ?blockTag=finalized.
| Route | Returns |
|---|---|
| GET /name/<name> | The owner of a name. address and ase1 are null when the name is free. |
| GET /name-of/<address> | The primary name of an address (ase1 or 0x). name is null when it has none. |
GET https://testnet.asentum.com/name/alice
{ "active": true, "name": "alice", "address": "0xa1a1...", "ase1": "ase15xs6...yggla5", "blockNumber": "1200345" }
GET https://testnet.asentum.com/name-of/ase15xs6rgdp5xs6rgdp5xs6rgdp5xs6rgdpyggla5
{ "active": true, "address": "0xa1a1...", "ase1": "ase15xs6...yggla5", "name": "alice", "blockNumber": "1200345" }Before activation the body says active: false; an older node answers 404. The JSON-RPC methods ase_resolveName and ase_lookupAddress return the same bodies. Details are in the Node API reference.
Names inside contracts
A contract calls the registry like any other contract, with E(address).method(args). E() takes a hex address, so the registry appears in contract code as 0x000000000000000000000000000000000000000b, its hex form. msg.sender and the owner field are both lowercase hex, so they compare directly. See Writing Contracts for the rest of the contract API.
// E() takes hex addresses, so a contract refers to the registry by its hex form.
const REGISTRY = '0x000000000000000000000000000000000000000b';
({
// Only lets a caller in if they own the given name.
greet(name) {
const rec = E(REGISTRY).get(name); // { name, owner, registeredAt, primary, records } or null
assert(rec && rec.owner === msg.sender, 'you do not own @' + name);
emit('Greeted', { name: rec.name, owner: msg.sender });
},
// The primary name of the caller, or null.
whoAmI() {
return E(REGISTRY).primaryOf(msg.sender);
},
});When your contract calls E(REGISTRY).register(name) or E(REGISTRY).transfer(name, to), the registry sees your contract as msg.sender. That is how a contract owns names and moves them. To register from a contract, attach the fee: E(REGISTRY, { value: fee }).register(name).
Building a names marketplace
Since nobody but the owner can move a name, a marketplace cannot sell a name the seller still holds. It works as an escrow instead: the seller hands the name to the marketplace contract, and the marketplace hands it to the buyer when they pay.
The flow
- Prepare. The seller calls
marketplace.prepare(name, price). The marketplace checksE(registry).get(name).owner === msg.senderand records the seller. - Deposit. The seller calls
wallet.transferName(name, marketplaceAddress). - Activate. The seller calls
marketplace.activate(name), which checks that the marketplace now owns the name and opens the listing. - Buy. A buyer calls
marketplace.buy(name)with the price as the value. The marketplace pays the seller and callsE(registry).transfer(name, buyer). - Cancel. The seller calls
marketplace.cancel(name)and gets the name back through the same transfer.
Prepare must come before the deposit. The marketplace learns who the seller is while the seller still owns the name, so nobody else can claim a name that arrives later. Records are cleared on every transfer, so the buyer starts with a clean profile.
Example contract
// A names marketplace. The registry has no approve, so the marketplace holds
// each listed name in escrow and moves it with E(REGISTRY).transfer(...).
const REGISTRY = '0x000000000000000000000000000000000000000b';
const PREPARE_WINDOW = 300n; // blocks between prepare and activate
function load(name) {
const raw = storage.get('l:' + name);
return raw ? JSON.parse(raw) : null;
}
function save(name, listing) {
storage.set('l:' + name, JSON.stringify(listing));
}
function lookup(name) {
const rec = E(REGISTRY).get(name); // also normalises the name ("@Alice" -> "alice")
assert(rec, 'name is not registered');
return rec;
}
({
// 1. The seller, still the owner, announces the sale.
prepare(name, price) {
assert(BigInt(msg.value) === 0n, 'this method does not accept ASE');
const rec = lookup(name);
assert(rec.owner === msg.sender, 'you do not own this name');
const wei = BigInt(price);
assert(wei > 0n, 'price must be above zero');
save(rec.name, { seller: msg.sender, price: wei.toString(), at: chain.blockNumber.toString(), active: false });
emit('Prepared', { name: rec.name, seller: msg.sender, price: wei.toString() });
return rec.name;
},
// 2. (outside) The seller calls transfer(name, marketplace) on the registry.
// 3. Open the listing once the marketplace owns the name.
activate(name) {
assert(BigInt(msg.value) === 0n, 'this method does not accept ASE');
const rec = lookup(name);
const l = load(rec.name);
assert(l && !l.active, 'call prepare first');
assert(msg.sender === l.seller, 'only the seller can activate');
assert(chain.blockNumber - BigInt(l.at) <= PREPARE_WINDOW, 'prepare expired: cancel and prepare again');
assert(rec.owner === msg.contractAddress, 'transfer the name to the marketplace first');
l.active = true;
save(rec.name, l);
emit('Listed', { name: rec.name, seller: l.seller, price: l.price });
},
// 4. Pay exactly the price and receive the name.
buy(name) {
const rec = lookup(name);
const l = load(rec.name);
assert(l && l.active, 'not for sale');
assert(BigInt(msg.value) === BigInt(l.price), 'send exactly the price');
assert(msg.sender !== l.seller, 'you are the seller');
storage.delete('l:' + rec.name);
E(REGISTRY).transfer(rec.name, msg.sender); // records are cleared for the buyer
transfer(l.seller, BigInt(l.price)); // pay the seller
emit('Sold', { name: rec.name, seller: l.seller, buyer: msg.sender, price: l.price });
},
// 5. The seller takes the name back (or drops a prepare).
cancel(name) {
assert(BigInt(msg.value) === 0n, 'this method does not accept ASE');
const rec = lookup(name);
const l = load(rec.name);
assert(l, 'no listing');
assert(msg.sender === l.seller, 'only the seller can cancel');
storage.delete('l:' + rec.name);
if (rec.owner === msg.contractAddress) E(REGISTRY).transfer(rec.name, l.seller);
emit('Cancelled', { name: rec.name, seller: l.seller });
},
listing(name) {
const rec = E(REGISTRY).get(name);
return rec ? load(rec.name) : null;
},
});- The prepare window.
activateonly works within 300 blocks ofprepareand only for the seller who prepared. A seller who sells the name elsewhere after preparing cannot later activate a listing on a name somebody else deposited. If the window runs out,cancelstill returns a deposited name. - A name sent without prepare is stuck. The contract has no listing for it and so no seller to return it to. Tell users in your UI to always go through the three steps, or add an owner-controlled rescue method.
- Exact payment.
buytakes exactly the price, so no change needs refunding.transfer(seller, amount)is the built-in ASE send, settled after the call returns. - Everything reverts together. If the registry transfer fails, the payment and the storage changes in
buyare rolled back with it. - Add a marketplace fee by keeping part of
msg.valuebefore paying the seller.
Calling it from the SDK
const MARKET = 'ase1...your-marketplace';
const price = 25n * 10n ** 18n; // 25 ASE
// Seller: three transactions, in this order.
await (await seller.sendCall({ to: MARKET, method: 'prepare', args: ['myname', price.toString()] })).wait();
await (await seller.transferName('myname', MARKET)).wait();
await (await seller.sendCall({ to: MARKET, method: 'activate', args: ['myname'] })).wait();
// Buyer: one transaction with the price as the value.
const tx = await buyer.sendCall({ to: MARKET, method: 'buy', args: ['myname'], valueWei: price });
const receipt = await tx.wait();
if (!receipt.success) throw new Error(receipt.error);The SDK turns ase1 addresses in arguments into hex before signing, so pass ase1 everywhere on the client side. Show users the listing with a free view call: client.view(MARKET, 'listing', ['myname']).
Errors and gotchas
| Error | When |
|---|---|
| NamesNotActiveError | Before block 1,200,000, or the node is older than 0.9.0. Hide name features and show addresses. |
| NameNotFoundError | resolveRecipient got a valid name that nobody owns. |
| InvalidNameError | The input breaks a naming rule. Thrown before any network call or signature. |
- Reverts are in the receipt. "name is taken", "name is reserved", "you do not own this name" and the fee message come back as
receipt.errorwithsuccess: false. - Contract code uses hex.
E()and the registry'stransferrefuse ase1 strings inside a contract. Off chain, the SDK and connect convert ase1 for you. - Show both. On a confirm screen show the
@nameand the ase1 address it resolved to. - Testnet names do not carry over to mainnet.
Read next