FortiBlox LogoFortiBlox Docs
x1id

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 IDNetworkRPC endpoint
@handle registry8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1PX1 testnethttps://rpc.testnet.x1.xyz
X1NS (SPL Name Service)nameQyUhZQQgnirGbbJRR9ECWSdq1W7mMaNUZZTBvtqX1 mainnethttps://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:

  1. the literal bytes "handle"
  2. 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:

RuleDetail
Strip a leading @Not part of the canonical form.
LowercaseASCII only — reject anything outside a-z 0-9 - after lowercasing.
No UnicodeThis 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.
Length1–32 characters.
HyphensNo leading or trailing hyphen, no two in a row.
Not all-digitsA handle that normalizes to all digits is rejected.

Account layout

Fetch the account (getAccountInfo) and read it as raw bytes:

BytesFieldNotes
0..8discriminatorIdentifies the account type; not needed for a read.
8..40nameFixed 32-byte slot for the handle text.
40..41name_lenActual length of the name within the 32-byte slot.
41..73ownerThe 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)
  • label is the part before the dot (alice in alice.x1) — lowercase ASCII, digits, and hyphens only.
  • class is always 32 zero bytes for a top-level domain.
  • tld_root is the TLD's root authority pubkey (table below), as raw bytes — this is what scopes the derivation to one specific TLD, so alice.x1 and alice.xnt derive 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.

TLDRoot authority (base58)
.x14NG35LXbtyamuoyjarMf5f78esyWxWhSDHxqbAU5yZTk
.xnt6sHoWK6ht73Pb4y6yA7Sw8iP7DxS45gGp1fH3zWYn56V
.xen3SUwpSz33AsyJwf6B48cKZuDTswuUEdUhcXszZrFWPqo

Account layout

The first 96 bytes of the account are the SPL Name Service header:

BytesFieldNotes
0..32parent_nameMust equal the TLD's root authority (table above). Verify this before trusting anything else in the account.
32..64ownerThe address this domain resolves to.
64..96class32 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

  1. Decide the namespace from the input's shape: leading @ is a handle; label.tld where tld is x1/xnt/xen is a domain. If it matches both shapes (@jack.x1), refuse rather than guess.
  2. Normalize the label per the rules above.
  3. Derive the account address (handle PDA, or SPL Name Service domain account).
  4. Fetch the account via getAccountInfo.
  5. If it doesn't exist, the name is unregistered.
  6. For a domain, verify parent_name against the TLD root before trusting owner.
  7. Read owner and 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.