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 addressesType alice.xrpl → resolve
Risk of typos and phishingHuman-readable, verifiable names
No reverse lookupAddress → 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 .xrpl suffix 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 viem

Basic 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.xrpl

Performance 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 domainLookupregistryLookupById approach 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 viem dependency
  • 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! 🚀