Reading On-Chain Without the SDK
Derive x1id account addresses and read owner data directly from X1, for integrators building outside the SDK's supported languages.
Reading on-chain data without the SDK
x1id resolves names by deriving an account address and reading it over RPC — that is the entire mechanism. The @x1id/resolve SDK is not a convenience layer in front of some other source of truth; it is exactly the logic on this page, packaged for TypeScript. There is no backend a resolution can silently depend on.
This page is for integrators who need to replicate that logic in a language the SDK doesn't cover — Rust, Go, Swift/Kotlin for a native mobile wallet, or any environment where pulling in a JavaScript dependency isn't an option. If you can call a Solana-compatible JSON-RPC endpoint and read raw account bytes, you can resolve x1id names without any of our code.
If your language has a working @x1id/resolve port already, use it — a second hand-rolled implementation of this logic is exactly how divergent derivations produce a wrong address that silently swallows a payment. This page exists for when you have no other option.
Two independent namespaces
@handles and X1NS domains (.x1 / .xnt / .xen) are read from different programs, on different networks, with different account layouts. They are documented separately below. @jack and jack.x1 can have different owners — never merge the two into one lookup that returns a bare address.
Program and network
| Program ID | Network | RPC endpoint | |
|---|---|---|---|
@handle registry | 8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P | X1 testnet | https://rpc.testnet.x1.xyz |
| X1NS (SPL Name Service) | nameQyUhZQQgnirGbbJRR9ECWSdq1W7mMaNUZZTBvtq | X1 mainnet | https://rpc.mainnet.x1.xyz |
Handles are testnet-only today — do not point handle lookups at mainnet. X1NS domains are live on mainnet. If you resolve both namespaces, your integration talks to two different RPC endpoints.
@handles
Deriving the account address
A handle account is a program-derived address (PDA), found the standard Solana way: the first off-curve address reachable by hashing the seeds, a bump byte, the program ID, and the PDA marker, walking the bump down from 255.
Seeds, in order:
- the literal bytes
"handle" - the normalized handle, as UTF-8 bytes (no
@, lowercase, no padding)
use solana_sdk::pubkey::Pubkey;
const PROGRAM: &str = "8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P";
fn handle_pda(program: &Pubkey, canonical: &str) -> Pubkey {
Pubkey::find_program_address(&[b"handle", canonical.as_bytes()], program).0
}
// handle_pda(&program, "nike") derives the account that stores @nike's owner.The seed must be the normalized form, not raw user input — normalizing to the wrong bytes derives a different, likely-empty account instead of erroring. Normalization is:
| Rule | Detail |
|---|---|
Strip a leading @ | Not part of the canonical form. |
| Lowercase | ASCII only — reject anything outside a-z 0-9 - after lowercasing. |
| No Unicode | This is what stops a homograph (e.g. Cyrillic а in @аlice) from resolving as if it were the ASCII handle it visually matches. Reject on any non-ASCII byte, don't try to transliterate. |
| Length | 1–32 characters. |
| Hyphens | No leading or trailing hyphen, no two in a row. |
| Not all-digits | A handle that normalizes to all digits is rejected. |
Account layout
Fetch the account (getAccountInfo) and read it as raw bytes:
| Bytes | Field | Notes |
|---|---|---|
0..8 | discriminator | Identifies the account type; not needed for a read. |
8..40 | name | Fixed 32-byte slot for the handle text. |
40..41 | name_len | Actual length of the name within the 32-byte slot. |
41..73 | owner | The address this handle resolves to. This is the field you want. |
fn read_handle_owner(rpc: &RpcClient, pda: &Pubkey) -> Option<String> {
let acc = rpc.get_account(pda).ok()?;
if acc.data.len() < 73 {
return None; // malformed — treat like "not found", don't guess
}
Some(Pubkey::new_from_array(acc.data[41..73].try_into().ok()?).to_string())
}Or as raw JSON-RPC, language-agnostic:
curl -s https://rpc.testnet.x1.xyz -X POST -H 'content-type: application/json' -d '{
"jsonrpc": "2.0", "id": 1, "method": "getAccountInfo",
"params": ["<derived PDA base58>", { "encoding": "base64" }]
}'Base64-decode result.value.data[0], then read bytes 41..73 as a 32-byte ed25519 public key and base58-encode it — that's the owner.
An account that doesn't exist (result.value is null) means the handle isn't registered. Fewer than 73 bytes of data means something is wrong with the account and you should treat it the same as "not found" rather than reading past the end of the buffer.
X1NS domains
X1NS is built on SPL Name Service, so domain derivation and layout follow that program's conventions rather than a custom one.
Deriving the account address
hashed = sha256("SPL Name Service" || label)
account = find_program_address([hashed, class(32 zero bytes), tld_root], NAME_PROGRAM)labelis the part before the dot (aliceinalice.x1) — lowercase ASCII, digits, and hyphens only.classis always 32 zero bytes for a top-level domain.tld_rootis the TLD's root authority pubkey (table below), as raw bytes — this is what scopes the derivation to one specific TLD, soalice.x1andalice.xntderive to different accounts even though the label is identical.
use sha2::{Digest, Sha256};
fn domain_account(label: &str, tld_root: &[u8; 32], name_program: &Pubkey) -> Pubkey {
let mut h = Sha256::new();
h.update(b"SPL Name Service");
h.update(label.as_bytes());
let hashed: [u8; 32] = h.finalize().into();
let class = [0u8; 32];
Pubkey::find_program_address(&[&hashed, &class, tld_root], name_program).0
}TLD root authorities
Every account this derivation returns must be verified against the root for the TLD you claimed — see Verifying the TLD below.
| TLD | Root authority (base58) |
|---|---|
.x1 | 4NG35LXbtyamuoyjarMf5f78esyWxWhSDHxqbAU5yZTk |
.xnt | 6sHoWK6ht73Pb4y6yA7Sw8iP7DxS45gGp1fH3zWYn56V |
.xen | 3SUwpSz33AsyJwf6B48cKZuDTswuUEdUhcXszZrFWPqo |
Account layout
The first 96 bytes of the account are the SPL Name Service header:
| Bytes | Field | Notes |
|---|---|---|
0..32 | parent_name | Must equal the TLD's root authority (table above). Verify this before trusting anything else in the account. |
32..64 | owner | The address this domain resolves to. |
64..96 | class | 32 zero bytes for a top-level domain. |
struct Header {
parent_name: [u8; 32],
owner: [u8; 32],
class: [u8; 32],
}
fn parse_header(data: &[u8]) -> Option<Header> {
if data.len() < 96 {
return None;
}
Some(Header {
parent_name: data[0..32].try_into().ok()?,
owner: data[32..64].try_into().ok()?,
class: data[64..96].try_into().ok()?,
})
}Verifying the TLD (required)
Skipping this check is a real vulnerability, not a formality. The account address you derive is only trustworthy because the derivation includes the TLD root as a seed. But nothing stops you from being handed a different, attacker-controlled account and reading bytes 32..64 out of it as if it were an "owner" — the bytes will decode to a valid-looking pubkey regardless. The parent_name check is what confirms the account you fetched actually is a domain under the TLD you asked about, rather than an arbitrary account shaped to look like one.
fn verify_parent(header: &Header, tld_root: &[u8; 32]) -> bool {
&header.parent_name == tld_root
}Always derive the account yourself from the label and TLD, fetch it, parse the header, and check parent_name == tld_root for the TLD you asked about before reading owner. If the check fails, treat the domain as unregistered — do not fall back to trusting the data anyway.
Full resolution flow
- Decide the namespace from the input's shape: leading
@is a handle;label.tldwheretldisx1/xnt/xenis a domain. If it matches both shapes (@jack.x1), refuse rather than guess. - Normalize the label per the rules above.
- Derive the account address (handle PDA, or SPL Name Service domain account).
- Fetch the account via
getAccountInfo. - If it doesn't exist, the name is unregistered.
- For a domain, verify
parent_nameagainst the TLD root before trustingowner. - Read
ownerand base58-encode it. Show the namespace alongside it — never a bare address.
This is the same sequence the SDK runs; nothing about it requires a backend, and nothing behind it is a fallback path.