Resolving .xrpl Domains with ZNS
Audience: Developers building wallets, dApps, or tools that need to resolve human-readable .xrpl domain names to Ethereum addresses.
Key fact: The ZNS Registry on XRPL EVM provides on-chain domain resolution, reverse lookup, and caching strategies for optimal performance.
Registry Address:
0xf180136DdC9e4F8c9b5A9FE59e2b1f07265C5D4D(Mainnet)
Why use .xrpl domains?
| Traditional flow | .xrpl domain flow |
|---|---|
| Copy/paste 42-character hex addresses | Type alice.xrpl → resolve |
| Risk of typos and phishing | Human-readable, verifiable names |
| No reverse lookup | Address → primary domain lookup |
.xrpl domains make your dApp more user-friendly and reduce errors when sending transactions.
Overview
The ZNS Registry is a smart contract that maps .xrpl domain names to Ethereum addresses. It provides:
- Forward resolution: domain name → address
- Reverse resolution: address → primary domain name
- Token-based ownership: Each domain is an NFT (ERC-721)
- Expiration tracking: Domains have expiration dates
Key concepts
- Domain names are stored without the
.xrplsuffix in the registry - Each domain has a unique token ID
- Users can set a primary domain for reverse lookups
- Domains can expire and need renewal
Quick-start code (TypeScript + Viem)
Prerequisites
npm install viemBasic setup
import { createPublicClient, http, getAddress } from 'viem';
// Define XRPL EVM chain config
const XRPL_EVM = {
id: 1440000,
name: 'XRPL EVM Sidechain',
nativeCurrency: { name: 'XRP', symbol: 'XRP', decimals: 18 },
rpcUrls: {
default: { http: ['https://rpc.xrplevm.org'] },
},
};
// ZNS Registry address (verified on mainnet)
export const ZNS_REGISTRY = '0xf180136DdC9e4F8c9b5A9FE59e2b1f07265C5D4D';
// Create a public client for read operations
export const publicClient = createPublicClient({
chain: XRPL_EVM,
transport: http(XRPL_EVM.rpcUrls.default.http[0]),
});Forward resolution: domain → address
// ABI for domain lookup (returns token ID)
const ABI_DOMAIN_LOOKUP = [
{
inputs: [{ name: '', type: 'string' }],
name: 'domainLookup',
outputs: [{ name: '', type: 'uint256' }],
stateMutability: 'view',
type: 'function',
},
] as const;
// ABI for registry lookup by token ID
const ABI_REGISTRY_LOOKUP_BY_ID = [
{
inputs: [{ name: 'tokenId', type: 'uint256' }],
name: 'registryLookupById',
outputs: [
{
name: '',
type: 'tuple',
components: [
{ name: 'owner', type: 'address' },
{ name: 'domainName', type: 'string' },
{ name: 'lengthOfDomain', type: 'uint16' },
{ name: 'expirationDate', type: 'uint256' },
],
},
],
stateMutability: 'view',
type: 'function',
},
] as const;
async function resolveXrplDomain(name: string): Promise<string | null> {
if (!name.toLowerCase().endsWith('.xrpl')) return null;
// Strip .xrpl suffix (registry stores names without it)
const nameWithoutTld = name.toLowerCase().replace(/\.xrpl$/, '');
try {
// Step 1: Get token ID for the domain
const tokenId = await publicClient.readContract({
address: ZNS_REGISTRY,
abi: ABI_DOMAIN_LOOKUP,
functionName: 'domainLookup',
args: [nameWithoutTld],
}) as bigint;
if (!tokenId || tokenId === BigInt(0)) return null;
// Step 2: Get registry data including owner address
const registryData = await publicClient.readContract({
address: ZNS_REGISTRY,
abi: ABI_REGISTRY_LOOKUP_BY_ID,
functionName: 'registryLookupById',
args: [tokenId],
}) as {
owner: `0x${string}`;
domainName: string;
lengthOfDomain: number;
expirationDate: bigint;
};
// Return checksummed address
if (registryData?.owner && registryData.owner !== '0x0000000000000000000000000000000000000000') {
return getAddress(registryData.owner);
}
} catch (error) {
console.error('Domain resolution failed:', error);
}
return null;
}
// Usage
const address = await resolveXrplDomain('alice.xrpl');
console.log(address); // 0x1234...Reverse resolution: address → domain
// ABI for user lookup by address
const ABI_USER_LOOKUP = [
{
inputs: [{ name: 'user', type: 'address' }],
name: 'userLookupByAddress',
outputs: [
{
name: '',
type: 'tuple',
components: [
{ name: 'primaryDomain', type: 'uint256' },
{ name: 'allOwnedDomains', type: 'uint256[]' },
{ name: 'numberOfReferrals', type: 'uint256' },
{ name: 'totalEarnings', type: 'uint256' },
],
},
],
stateMutability: 'view',
type: 'function',
},
] as const;
async function resolveAddressToDomain(address: string): Promise<string | null> {
// Validate address format
if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return null;
try {
// Step 1: Get user config (includes primary domain token ID)
const userConfig = await publicClient.readContract({
address: ZNS_REGISTRY,
abi: ABI_USER_LOOKUP,
functionName: 'userLookupByAddress',
args: [address as `0x${string}`],
}) as {
primaryDomain: bigint;
allOwnedDomains: readonly bigint[];
numberOfReferrals: bigint;
totalEarnings: bigint;
};
if (!userConfig.primaryDomain || userConfig.primaryDomain === BigInt(0)) {
return null; // No primary domain set
}
// Step 2: Get domain name from token ID
const registryData = await publicClient.readContract({
address: ZNS_REGISTRY,
abi: ABI_REGISTRY_LOOKUP_BY_ID,
functionName: 'registryLookupById',
args: [userConfig.primaryDomain],
}) as {
owner: `0x${string}`;
domainName: string;
lengthOfDomain: number;
expirationDate: bigint;
};
if (registryData?.domainName) {
// Add .xrpl suffix if not present
return registryData.domainName.toLowerCase().endsWith('.xrpl')
? registryData.domainName
: `${registryData.domainName}.xrpl`;
}
} catch (error) {
console.error('Reverse lookup failed:', error);
}
return null;
}
// Usage
const domain = await resolveAddressToDomain('0x1234...');
console.log(domain); // alice.xrplPerformance optimization with caching
Since blockchain reads can be slow, implement a simple in-memory cache:
// Simple cache with 5-minute TTL
const resolutionCache = new Map<string, {
address: string | null;
timestamp: number
}>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Clear expired entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, value] of resolutionCache.entries()) {
if (now - value.timestamp > CACHE_DURATION) {
resolutionCache.delete(key);
}
}
}, CACHE_DURATION);
async function resolveXrplDomainCached(name: string): Promise<string | null> {
const nameWithoutTld = name.toLowerCase().replace(/\.xrpl$/, '');
// Check cache first
const cached = resolutionCache.get(nameWithoutTld);
if (cached && (Date.now() - cached.timestamp) < CACHE_DURATION) {
return cached.address;
}
// Perform resolution (implementation from section 3)
const address = await resolveXrplDomain(name);
// Cache result (including null to avoid repeated failed lookups)
resolutionCache.set(nameWithoutTld, {
address,
timestamp: Date.now()
});
return address;
}Tip: For production apps, consider using Redis or another distributed cache for better scalability.
Alternative resolution method
The ZNS Registry also provides registryLookupByName for direct name-to-owner lookup:
const ABI_REGISTRY_LOOKUP_BY_NAME = [
{
inputs: [{ name: 'domainName', type: 'string' }],
name: 'registryLookupByName',
outputs: [
{
name: '',
type: 'tuple',
components: [
{ name: 'owner', type: 'address' },
{ name: 'domainName', type: 'string' },
{ name: 'lengthOfDomain', type: 'uint16' },
{ name: 'expirationDate', type: 'uint256' },
],
},
],
stateMutability: 'view',
type: 'function',
},
] as const;
async function resolveDirectly(name: string): Promise<string | null> {
const nameWithoutTld = name.toLowerCase().replace(/\.xrpl$/, '');
try {
const info = await publicClient.readContract({
address: ZNS_REGISTRY,
abi: ABI_REGISTRY_LOOKUP_BY_NAME,
functionName: 'registryLookupByName',
args: [nameWithoutTld],
}) as {
owner: `0x${string}`;
domainName: string;
lengthOfDomain: number;
expirationDate: bigint;
};
if (info?.owner && info.owner !== '0x0000000000000000000000000000000000000000') {
return getAddress(info.owner);
}
} catch (error) {
console.error('Direct lookup failed:', error);
}
return null;
}Note: The
domainLookup→registryLookupByIdapproach is typically faster as it uses indexed token IDs.
Complete implementation with fallbacks
Here's a production-ready implementation combining all strategies:
import { createPublicClient, http, getAddress } from 'viem';
export const ZNS_REGISTRY = '0xf180136DdC9e4F8c9b5A9FE59e2b1f07265C5D4D';
// In-memory cache
const resolutionCache = new Map<string, {
address: `0x${string}` | null;
timestamp: number
}>();
const CACHE_DURATION = 5 * 60 * 1000;
// ABIs (from sections above)
const ABI_DOMAIN_LOOKUP = [/* ... */];
const ABI_REGISTRY_LOOKUP_BY_ID = [/* ... */];
const ABI_REGISTRY_LOOKUP_BY_NAME = [/* ... */];
export const publicClient = createPublicClient({
chain: {
id: 1440000,
name: 'XRPL EVM Sidechain',
nativeCurrency: { name: 'XRP', symbol: 'XRP', decimals: 18 },
rpcUrls: {
default: { http: ['https://rpc.xrplevm.org'] },
},
},
transport: http('https://rpc.xrplevm.org'),
});
export async function resolveXrplNameToAddress(
name: string
): Promise<`0x${string}` | null> {
if (!name.toLowerCase().endsWith('.xrpl')) return null;
const nameWithoutTld = name.toLowerCase().replace(/\.xrpl$/, '');
if (!nameWithoutTld.trim()) return null;
// Check cache
const cached = resolutionCache.get(nameWithoutTld);
if (cached && (Date.now() - cached.timestamp) < CACHE_DURATION) {
return cached.address;
}
let resolvedAddress: `0x${string}` | null = null;
// Method 1: domainLookup + registryLookupById (faster)
try {
const tokenId = await publicClient.readContract({
address: ZNS_REGISTRY,
abi: ABI_DOMAIN_LOOKUP,
functionName: 'domainLookup',
args: [nameWithoutTld],
}) as bigint;
if (tokenId && tokenId > BigInt(0)) {
const registryData = await publicClient.readContract({
address: ZNS_REGISTRY,
abi: ABI_REGISTRY_LOOKUP_BY_ID,
functionName: 'registryLookupById',
args: [tokenId],
}) as any;
if (registryData?.owner &&
registryData.owner !== '0x0000000000000000000000000000000000000000') {
resolvedAddress = getAddress(registryData.owner);
}
}
} catch (error) {
console.debug('Primary resolution failed, trying fallback:', error);
}
// Method 2: registryLookupByName (fallback)
if (!resolvedAddress) {
try {
const info = await publicClient.readContract({
address: ZNS_REGISTRY,
abi: ABI_REGISTRY_LOOKUP_BY_NAME,
functionName: 'registryLookupByName',
args: [nameWithoutTld],
}) as any;
if (info?.owner &&
info.owner !== '0x0000000000000000000000000000000000000000') {
resolvedAddress = getAddress(info.owner);
}
} catch (error) {
console.debug('Fallback resolution failed:', error);
}
}
// Cache result
resolutionCache.set(nameWithoutTld, {
address: resolvedAddress,
timestamp: Date.now()
});
return resolvedAddress;
}
export function isValidAddress(address: string): boolean {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
export async function resolveAddressToXrplName(
address: string
): Promise<string | null> {
if (!isValidAddress(address)) return null;
try {
const userConfig = await publicClient.readContract({
address: ZNS_REGISTRY,
abi: ABI_USER_LOOKUP,
functionName: 'userLookupByAddress',
args: [address as `0x${string}`],
}) as any;
if (!userConfig.primaryDomain || userConfig.primaryDomain === BigInt(0)) {
return null;
}
const registryData = await publicClient.readContract({
address: ZNS_REGISTRY,
abi: ABI_REGISTRY_LOOKUP_BY_ID,
functionName: 'registryLookupById',
args: [userConfig.primaryDomain],
}) as any;
if (registryData?.domainName) {
return registryData.domainName.toLowerCase().endsWith('.xrpl')
? registryData.domainName
: `${registryData.domainName}.xrpl`;
}
} catch (error) {
console.debug('Reverse lookup failed:', error);
}
return null;
}Integration checklist
- Install
viemdependency - Configure XRPL EVM chain in your app
- Implement forward resolution for wallet/dApp inputs
- Add reverse resolution for displaying user identities
- Implement caching strategy (in-memory or distributed)
- Handle edge cases (expired domains, unset primary domains)
- Test on testnet before mainnet deployment
- Add error handling for network failures
Common patterns
Wallet integration
// Accept both addresses and .xrpl domains in send forms
async function validateRecipient(input: string): Promise<string | null> {
// Check if it's already a valid address
if (isValidAddress(input)) {
return input;
}
// Try resolving as .xrpl domain
if (input.toLowerCase().endsWith('.xrpl')) {
return await resolveXrplNameToAddress(input);
}
return null;
}Display user-friendly names
// Show domain instead of address when available
async function formatAddress(address: string): Promise<string> {
const domain = await resolveAddressToXrplName(address);
return domain || `${address.slice(0, 6)}...${address.slice(-4)}`;
}Batch resolution
// Resolve multiple domains in parallel
async function resolveBatch(names: string[]): Promise<Map<string, string | null>> {
const results = await Promise.all(
names.map(async (name) => ({
name,
address: await resolveXrplNameToAddress(name),
}))
);
return new Map(results.map(r => [r.name, r.address]));
}FAQ
Q: Do I need to deploy my own registry contract? A: No, use the existing ZNS Registry at 0xf180136DdC9e4F8c9b5A9FE59e2b1f07265C5D4D.
Q: What if a domain has expired? A: The registry will still return the owner, but you should check the expirationDate field if freshness matters.
Q: Can I use this with ethers.js instead of viem? A: Yes! The contract addresses and ABIs are the same—just adapt the client initialization and contract call syntax.
Q: How do I register a .xrpl domain? A: Visit the official ZNS platform or use their registration contract (not covered in this guide).
Q: Does the registry support subdomains (e.g., wallet.alice.xrpl)? A: Check the ZNS documentation for subdomain support—this guide covers primary domain resolution.
Need help?
Discord: https://discord.gg/xrplevm
ZNS Registry Contract: 0xf180136DdC9e4F8c9b5A9FE59e2b1f07265C5D4D
ZNS Buy Domain https://zns.bio/
Transfer to .xrpl domain ens.xrplevm.org
Happy building! 🚀