X1ID is live on X1 mainnet
X1ID / Address records

Address records

A @handle can carry a payment address for several chains — X1, Solana, Ethereum, Bitcoin. Each lives in its own on-chain Record account. This page covers reading those records (with the staleness rule enforced for you) and verifying a non-SVM address so its record shows as proven.

This page targets @x1id/[email protected]. The registry is on X1 mainnet at 8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P (https://rpc.mainnet.x1.xyz).

Reading records

The simplest read is records() on a resolver — it takes a handle and applies the staleness rule:

const all = await x1id.records('@nike'); // readonly HandleRecord[]

For a lower-level read (no resolver, just an RPC), use fetchRecords. It demands the handle's registeredAt and recordsClearedAt — the staleness rule needs them, and there is no variant without them — plus an optional holderEpoch for tokenized names (see the staleness rule):

import { fetchRecords, liveRecords, valueToAddress } from '@x1id/resolve';

// handleAccount = the ["handle", name] PDA; registeredAt / recordsClearedAt / holderEpoch from the Handle account
const records = await fetchRecords(rpc, programId, handleAccount, registeredAt, recordsClearedAt, holderEpoch);

for (const r of liveRecords(records)) {
  console.log(r.chain, valueToAddress(r.chain, r.value));
}

A HandleRecord carries:

FieldMeaning
accountthe Record account's address (base58)
handlethe Handle account it hangs off (base58)
coinTypeSLIP-44 coin type as stored
chainwhich Chain that maps to ('X1' | 'SOL' | 'ETH' | 'BTC'), or null if unknown
valueraw bytes as stored on-chain
addresshuman-readable address — see valueToAddress
verifiedRecord.verified gated by staleness: forced false whenever the record is stale
updatedAtRecord.updated_at, unix seconds
staletrue when the record belongs to a previous owner — see below

fetchRecords returns every record, stale ones flagged, so an owner surface can show what a previous owner left behind. Anything that resolves, displays to a visitor, or counts must take liveRecords(...) first. valueToAddress renders stored bytes per chain: X1/SOL as base58 pubkeys, ETH as 0x-hex, BTC as the canonical address string; unknown coin types render as hex so a value is never silently hidden.

The staleness rule

A record that predates the current registration belongs to a previous, unrelated owner of the same name. It must never be resolved as this name's address, never counted, and only ever offered to the current owner as something to remove.

A Handle's address is ["handle", name] — a pure function of the name — so release + re-register lands the new registration at the same pubkey, and Record PDAs (["record", handle_pubkey, coin_type]) hang off that pubkey. A record a previous owner created (possibly verified: true for their address) is therefore physically attached to the new owner's name with no action by anyone. The program cannot prevent this; the mandatory read-side rule is enforced structurally here. A record is stale when any of:

  • updated_at < handle.registered_at — predates the current registration; or
  • updated_at < handle.records_cleared_at — the owner ran clear_records, bulk-invalidating records without a transfer; or
  • updated_at < handle.holder_epoch — the name is a capability NFT and changed hands through a native marketplace sale (buy_handle), which bumps holder_epoch instead of registered_at (the registration itself does not move on a tokenized sale). fetchRecords takes holderEpoch for this; pass it for tokenized names.

This applies to payment resolution, not just badges — which is why fetchRecords requires the epoch anchors and there is no code path that returns an unjudged record.

Plain NFT transfers are not covered. holder_epoch is bumped only by a native marketplace sale. If a capability NFT is moved by a plain Metaplex transfer (wallet-to-wallet, or a sale on a generic NFT marketplace), nothing on the Handle moves — not registered_at, not holder_epoch — so the previous holder's records stay live and keep resolving as the new holder's payment address. A new holder should review and clear_records on any name they acquired by plain transfer; the app surfaces this as a prompt. Closing this on-chain (stamping the holder on every transfer) is tracked as a future change.

Verifying a non-SVM address record

To make a record show as verified: true, the address's owner must prove control. On-chain, the program itself recovers the address from the signature — this SDK never derives or checks an address client-side, it just builds the challenge and packages the wallet's signature.

The flow has three steps: create the (unverified) record, have the wallet sign a challenge, then submit the proof.

Ethereum (secp256k1 / EIP-191)

import {
  buildRecordChallenge, buildCreateRecordIx, buildVerifyRecordEthIx, splitEthSignature,
} from '@x1id/resolve';

// 1. create the (unverified) record
const createIx = buildCreateRecordIx({
  programId, payer, owner, handle, record,
  coinType: 60,          // ETH, per SLIP-44
  value: ethAddressBytes, // 20 raw bytes
});

// 2. build the challenge and have the wallet sign it with personal_sign
const challenge = buildRecordChallenge('alice', 60, registeredAt, 'someNonce123');
const sig65 = await wallet.signMessage(challenge); // 65 raw bytes: r || s || v

// 3. verify
const { signature, recoveryId } = splitEthSignature(sig65);
const verifyIx = buildVerifyRecordEthIx({
  programId, owner, handle, record,
  nonce: 'someNonce123', signature, recoveryId,
});

verify_record_eth hashes the challenge with EIP-191's "\x19Ethereum Signed Message:\n" framing — the exact framing every wallet's personal_sign produces. There is no EIP-712 variant on-chain. splitEthSignature normalizes v's two conventions (27/28 or 0/1).

Bitcoin (BIP-137)

import { buildVerifyRecordBtcIx, splitBtcSignature } from '@x1id/resolve';

const sig65 = await wallet.signmessage(challenge); // header(1) || r(32) || s(32)
const { header, signature } = splitBtcSignature(sig65);
const verifyIx = buildVerifyRecordBtcIx({
  programId, owner, handle, record, nonce: 'someNonce123', header, signature,
});

verify_record_btc derives the address from the recovered pubkey (never trusts a client-supplied one) and accepts only compressed P2PKH (header 31–34) and bech32/P2WPKH (39–42), mainnet only — uncompressed P2PKH and P2SH-segwit are refused rather than guessed. splitBtcSignature passes the header byte through unchanged; most current wallets produce one of the two supported ranges.

Another SVM address

No challenge needed — the claimed address simply co-signs the transaction (X1 and Solana share ed25519, so the signature is the proof):

import { buildVerifyRecordSvmIx } from '@x1id/resolve';

const verifyIx = buildVerifyRecordSvmIx({
  programId, owner, handle, record,
  claimed, // the address being claimed — MUST co-sign this transaction
});

About the challenge

buildRecordChallenge(handle, coinType, registeredAt, nonce) builds the exact bytes a wallet must sign — x1-handles:v1:<handle>:<coinType>:<registeredAt>:<nonce>, byte-identical to the program's Rust challenge(). Unlike the off-chain control-proof flow, the nonce does not need to be server-issued — the on-chain transaction landing is the single-use event, so any ASCII-alphanumeric nonce (1–64 chars) you pick is fine. Pass handle already canonical (run it through normalizeHandle first if it might have a leading @ or mixed case).

Next

Browse the docs

Address records