> For the complete documentation index, see [llms.txt](https://parad0xlabs.gitbook.io/parad0xlabs-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://parad0xlabs.gitbook.io/parad0xlabs-docs/technical-reference/null_domains.md).

# .null Domain Spec

`.null` domains are Solana PDAs. A name like `foo.null` maps to a Program Derived Account owned by the `null_registrar` Solana program. The account holds the content address, payment endpoint, identity proof hash, and metadata for that name.

**Live on Solana mainnet:**

|                          |                                                                                  |
| ------------------------ | -------------------------------------------------------------------------------- |
| `null_registrar` program | `H4wbFJucY9shJt95N8Bra532Z4nnkKhGEfqWvLcYfuDm`                                   |
| `null-auction` program   | `7uxLhqLzkEzPpkvdmTwqgL3g66yq2aMBS5QgcjaZZEaw`                                   |
| Account schema           | NullDomain v1, **314 bytes**, native (not Anchor)                                |
| Name rules               | lowercase `a-z 0-9 -`, **4–32 chars** (1–3 char names are premium, auction-only) |

This is the deployed v1 schema — you can read any registered name's account directly with `getAccountInfo` and decode the layout below.

***

## Solana PDA Layout

### Seeds and Derivation

The PDA is derived from the **name alone** — no owner in the seed. Because Solana caps each seed at 32 bytes and a name can be up to 64 bytes once null-padded, the name is hashed with SHA-256 first:

```
seeds: [b"null-domain", sha256(name_padded_to_64_bytes)]
```

Name is lowercase `a-z 0-9 -`, 4–32 characters, `.null` suffix stripped, then null-padded to 64 bytes before hashing. Deriving from the name (not the owner) is what lets anyone resolve `foo.null` without knowing who owns it, and lets a transfer be a simple owner-field update instead of a close-and-recreate.

```ts
import { createHash } from "crypto";
import { PublicKey } from "@solana/web3.js";

const NULL_REGISTRAR = new PublicKey("H4wbFJucY9shJt95N8Bra532Z4nnkKhGEfqWvLcYfuDm");

function deriveDomainPda(name: string): PublicKey {
  const padded = Buffer.alloc(64, 0);
  Buffer.from(name.toLowerCase(), "utf8").copy(padded, 0);
  const seedHash = createHash("sha256").update(padded).digest();
  const [pda] = PublicKey.findProgramAddressSync(
    [Buffer.from("null-domain"), seedHash],
    NULL_REGISTRAR
  );
  return pda;
}
```

### Account Layout (NullDomain v1 — 314 bytes, native)

`null_registrar` is a native Solana program, not Anchor. The account begins with a 1-byte discriminant (`0x4E`, ASCII `N`) rather than an 8-byte Anchor discriminator. Fixed offsets, little-endian integers:

```
offset  size  field            notes
------  ----  ---------------  ---------------------------------------------
  0       1   disc             0x4E ('N')
  1      64   name             UTF-8, null-padded, no ".null" suffix
 65      32   owner            current owner pubkey
 97      32   arweave_txid     raw 32 bytes = a 43-char base64url Arweave tx id
129     128   x402_endpoint    UTF-8 URL, null-padded (all-zero = none)
257      32   passport_hash    Dark Passport ZK commitment (all-zero = none)
289       8   registered_at    i64 unix seconds
297       8   expires_at       i64 unix seconds (0 = perpetual)
305       8   null_paid        u64 — NULL paid at registration
313       1   bump             PDA bump
------  ----  ---------------  ---------------------------------------------
total = 314 bytes
```

Note: `arweave_txid` is stored as the raw 32 bytes of the Arweave transaction id. To build the URL, base64url-encode those 32 bytes into the 43-char id and prefix `https://arweave.net/`.

### Instructions

The program exposes nine instructions, dispatched by a leading 1-byte tag:

| Tag    | Name            | Who       | Effect                                                                            |
| ------ | --------------- | --------- | --------------------------------------------------------------------------------- |
| `0x01` | InitRegistry    | authority | one-time registry bootstrap                                                       |
| `0x02` | Register        | anyone    | claim an available name (owner = signer); pays rent + the configured SOL/NULL fee |
| `0x03` | UpdateContent   | owner     | set/replace `arweave_txid`                                                        |
| `0x04` | Transfer        | owner     | set `owner` to a new pubkey (no close/recreate)                                   |
| `0x05` | Resolve         | anyone    | read-only; returns the record via logs/CPI                                        |
| `0x06` | UpdateEndpoint  | owner     | set/replace `x402_endpoint`                                                       |
| `0x07` | UpdatePassport  | owner     | set/replace `passport_hash`                                                       |
| `0x08` | SetConfig       | authority | flip the SOL/NULL registration fees + treasury (live config, no redeploy)         |
| `0x09` | MigrateConfigV2 | authority | one-time `RegistryConfig` realloc 114→122 B (already run on mainnet)              |

Because the PDA seed is the name (not the owner), **Transfer is just an owner-field update** — the account stays at the same address, no close-and-recreate. Every owner-gated instruction checks the signer against the stored `owner` field.

### Registration fees (config-driven, flippable)

A `.null` name is a **one-time purchase, priced in SOL** — so it never chases a token's USD value. Two parts:

* **Solana rent (\~0.003 SOL)** — funds the name's own on-chain account. This is *your* deposit, locked in *your* account; it is **not** protocol revenue.
* **Protocol fee** — the protocol's margin, on top of rent, paid to the treasury. Paid in **SOL** (or optionally NULL — pick the currency with a byte in the `Register` data: `1` = SOL, `3` = NULL via Token-2022).

Both fee amounts live in the `RegistryConfig` PDA and are changed by a single authority-signed `SetConfig` (`0x08`) call — **no redeploy, ever**.

* **During the pilot, the fee is 0** — registration costs only the \~0.003 SOL rent. This replaces the old hardcoded `IS_MAINNET_READY` gate: "pilot vs live" is now simply whether the configured fee is zero, flippable in one transaction.
* **At go-live**, the authority sets a small fixed **SOL** fee (target **\~0.007 SOL**, so a standard name is **\~0.01 SOL all-in including rent** — about $0.65 at today's SOL). Priced in SOL on purpose: no USD peg to chase, and the protocol's margin stays positive at any SOL price. **NULL is accepted as an alternative fee token at \~20% less** than the SOL price.
* **1–3 char premium names** are not first-come — they're released through `null-auction` as **open-ascending (English) auctions**, and any name can be resold the same way (see [Wrapped NULL](/parad0xlabs-docs/technical-reference/wrapped_null.md)).

> `RegistryConfig` was migrated in place on mainnet (114 → 122 bytes via `MigrateConfigV2`, `0x09`) with every already-registered name preserved. Fees are live config, not code.

***

## Arweave Content Pointer

The `arweave_txid` field (32 raw bytes, shown as a 43-char base64url id) points to the content root. For a single-file site that's the page itself; for a multi-file site it can point to an ANS-104 path manifest that maps URL paths to individual Arweave transaction IDs:

```json
{
  "manifest": "arweave/paths",
  "version": "0.2.0",
  "index": { "path": "index.html" },
  "paths": {
    "index.html": { "id": "<43-char-tx-id>" },
    "assets/app.js": { "id": "<43-char-tx-id>" },
    "assets/style.css": { "id": "<43-char-tx-id>" }
  }
}
```

Updating content means uploading a new path manifest (plus any changed files), getting the new manifest tx ID back, and calling `update_content` on the Solana program. Unchanged files that are already on Arweave are referenced by their existing tx IDs — only changed files need re-uploading.

The upload flow (using `@ardrive/turbo-sdk` v1.41.3):

```ts
import { TurboFactory } from "@ardrive/turbo-sdk";

const turbo = TurboFactory.authenticated({ signer: arweaveSigner });
const result = await turbo.uploadFolder({
  folderPath: "./dist",
  manifestFile: "path-manifest.json",
  dataItemOpts: { tags: [{ name: "Content-Type", value: "text/html" }] }
});
// result.manifestResponse.id is the 43-char tx ID
```

TTL / caching: There is no TTL in the PDA. Resolvers should cache the `arweave_txid` for a configurable duration (recommend 60–300 seconds), and re-read the account to pick up changes. Updating content is a single `UpdateContent` (0x03) call that overwrites `arweave_txid` in place — the previous Arweave upload stays permanently available at its own id, so updates are non-destructive.

***

## Resolution Flow

```
1. User types "foo.null" in Chrome address bar
         |
         | (Chrome has no DNS record for .null, converts to search)
         v
2. Chrome navigates to "https://www.google.com/search?q=foo.null"
         |
         | MV3 extension onBeforeRequest listener fires
         | (matches *://www.google.com/search*)
         v
3. Extension extracts domain from q param: "foo.null"
   Calls: chrome.tabs.update(tab.id, { url: "chrome-extension://.../resolver.html?domain=foo.null" })
         |
         v
4. resolver.html loads in tab
   resolver.js runs:
     a. strips ".null" suffix -> name = "foo"
     b. derives PDA: ["null-domain", sha256(name padded to 64)]  (no owner needed)
     c. calls Solana RPC: getAccountInfo(pdaAddress)
     d. decodes the 314-byte NullDomain account
     e. reads arweave_txid (32 bytes) -> base64url -> 43-char id
     f. navigates: window.location.replace("https://arweave.net/" + txid)
         |
         v
5. Arweave gateway serves the content
   Browser fetches the page and assets normally
```

Because the PDA is derived from the name alone, resolution is a single deterministic step: name → SHA-256 → PDA → `getAccountInfo`. No owner lookup, no separate index account, no registry scan. Anyone can resolve any name knowing only the name.

(An earlier design considered a separate name→owner index account; the deployed v1 removes it by hashing the name directly into the PDA seed.)

***

## Chrome Extension Implementation

### Manifest V3 Permissions

```json
{
  "manifest_version": 3,
  "permissions": ["webRequest", "storage"],
  "host_permissions": [
    "*://*.null/*",
    "*://www.google.com/search*",
    "https://duckduckgo.com/*",
    "*://www.bing.com/search*"
  ],
  "background": { "service_worker": "background.js" }
}
```

### Background Service Worker

```js
// Case 1: user typed "http://foo.null" or browser somehow resolved the URL
chrome.webRequest.onBeforeRequest.addListener(
  (details) => {
    const url = new URL(details.url);
    if (url.hostname.endsWith(".null")) {
      chrome.tabs.update(details.tabId, {
        url: chrome.runtime.getURL(`resolver.html?domain=${encodeURIComponent(url.hostname)}`)
      });
    }
  },
  { urls: ["*://*.null/*"] },
  []
);

// Case 2: Chrome converted "foo.null" into a search query
chrome.webRequest.onBeforeRequest.addListener(
  (details) => {
    const q = new URL(details.url).searchParams.get("q") || "";
    const match = q.match(/^([\w-]+\.null)(\/.*)?$/);
    if (match) {
      chrome.tabs.update(details.tabId, {
        url: chrome.runtime.getURL(`resolver.html?domain=${encodeURIComponent(match[1])}`)
      });
    }
  },
  { urls: ["*://www.google.com/search*", "https://duckduckgo.com/*", "*://www.bing.com/search*"] },
  []
);

// Keepalive: MV3 service workers idle after ~30s (confirmed SNS Resolver bug)
chrome.alarms.create("keepalive", { periodInMinutes: 0.33 }); // every 20s
chrome.alarms.onAlarm.addListener(() => {}); // noop, just keeps worker alive
```

### resolver.js (runs in extension page)

```js
const params = new URLSearchParams(location.search);
const domain = params.get("domain"); // "foo.null"
const name = domain.replace(/\.null$/, ""); // "foo"

async function resolve() {
  // 1. Derive the NullDomain PDA from the name alone
  const domainPda = deriveDomainPda(name); // ["null-domain", sha256(name padded 64)]
  const domainAccount = await solanaGetAccountInfo(domainPda);

  // 2. Decode the 314-byte account, read arweave_txid (32 raw bytes @ offset 97)
  const { arweaveTxid } = parseNullDomain(domainAccount); // base64url of 32 bytes

  // 3. Navigate to content
  window.location.replace(`https://arweave.net/${arweaveTxid}`);
}

resolve().catch(() => {
  // Show a simple "not found" UI — do not redirect to a search engine
  document.body.textContent = `${domain}: name not registered`;
});
```

***

## Comparison to Other Approaches

### Handshake / Namebase Extension

Handshake (HNS) resolves custom TLDs via a PAC script injected through `chrome.proxy.settings`. The extension intercepts `.badass`, `.home`, etc. by rebuilding a Proxy Auto-Config script every time a new domain resolves. PAC scripts run synchronously in Chrome's network stack and cannot call async RPCs — so the first request triggers an async lookup and gets cancelled, the second request routes through the updated PAC.

The Web0 approach avoids PAC entirely. MV3 `webRequest` (non-blocking) + `chrome.tabs.update()` redirect to an internal page is simpler, has no PAC race condition, and allows full async Solana RPC calls in the resolver page. The Namebase extension is deprecated (last commit 2021, README says "use NextDNS.io instead").

### Unstoppable Domains

Unstoppable resolves `.crypto`, `.nft`, `.x`, etc. by either:

* Partnering with browsers (Brave, Opera) to add native resolution, or
* Providing a browser extension that catches omnibox input and resolves via their centralized resolver API.

The centralized resolver API is the problem — Unstoppable controls the API and controls the namespace. Their extension is also MV2-only in practice. No on-chain lookup happens in the client.

Web0: no centralized resolver. The Chrome extension calls Solana RPC directly and reads the PDA. The program on-chain enforces ownership. No entity controls the resolution path except the Solana validator set.

### SNS Resolver (.sol)

SNS Resolver (github.com/zhvng/sns-resolver) is the closest architectural template. It uses the same MV3 + `onBeforeRequest` + internal redirect page pattern we use, targeting `.sol` domains via Bonfida's name registry on Solana.

Key differences:

* `.sol` is occasionally treated as a valid TLD by some DNS resolvers (the South Ossetia ccTLD), which creates ambiguity. `.null` has no ICANN registration and no DNS path — cleaner.
* SNS Resolver resolves to IPFS content hashes or web2 URLs via Bonfida records. Web0 resolves to Arweave transaction IDs (permanent, not IPFS pinned).
* SNS Resolver has the keepalive bug documented but not fixed. We fix it with `chrome.alarms`.

### B-DNS (.null support — defunct)

B-DNS (github.com/B-DNS/Chrome) explicitly listed `.null` in its supported TLDs, but resolved it via the OpenNIC public DNS network — a centralized resolver. The project is MV2 only, last updated \~2019, dead. There is no code or network infrastructure to inherit from it.

Web0 `.null` shares only the TLD name with B-DNS. The resolution mechanism is entirely different.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://parad0xlabs.gitbook.io/parad0xlabs-docs/technical-reference/null_domains.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
