> 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/null-resolver-.null-domains/how-it-works.md).

# How It Works

***

## The .null Domain Record (PDA)

Every `.null` name maps to a single Solana account — a Program Derived Account (PDA) owned by the `null_registrar` program (`H4wbFJucY9shJt95N8Bra532Z4nnkKhGEfqWvLcYfuDm`). PDAs have no private key; only the program that owns them can write to them. The account holds a fixed 314-byte struct called `NullDomain`.

### NullDomain Layout (314 bytes)

| Offset | Size (bytes) | Field           | Type        | Notes                                                                                      |
| ------ | ------------ | --------------- | ----------- | ------------------------------------------------------------------------------------------ |
| 0      | 1            | `disc`          | `u8`        | Always `0x4E` ('N'). Not an 8-byte Anchor discriminator — this program is native.          |
| 1      | 64           | `name`          | `[u8; 64]`  | UTF-8, null-padded to fill 64 bytes.                                                       |
| 65     | 32           | `owner`         | `Pubkey`    | Current owner. Updated by `Transfer` (0x04).                                               |
| 97     | 32           | `arweave_txid`  | `[u8; 32]`  | Raw 32 bytes of a 43-char base64url Arweave tx ID. All-zero = no content set.              |
| 129    | 128          | `x402_endpoint` | `[u8; 128]` | UTF-8 URL, null-padded. Empty until `UpdateEndpoint` (0x06) is called.                     |
| 257    | 32           | `passport_hash` | `[u8; 32]`  | Groth16 BN254 public commitment. All-zero = no passport linked.                            |
| 289    | 8            | `registered_at` | `i64`       | Unix seconds.                                                                              |
| 297    | 8            | `expires_at`    | `i64`       | Unix seconds. `0` = perpetual.                                                             |
| 305    | 8            | `null_paid`     | `u64`       | NULL tokens paid at registration. Currently always `0` — fee gate is off during the pilot. |
| 313    | 1            | `bump`          | `u8`        | PDA canonical bump.                                                                        |

The discriminator at offset 0 lets `getAccountInfo` callers reject accounts that are not `NullDomain` without parsing the rest of the struct. The `null_registrar` program checks `disc == 0x4E` before processing any instruction that touches an existing record.

***

## PDA Derivation

Solana PDA seeds are each limited to 32 bytes. A `.null` name can be up to 32 UTF-8 characters, but the on-chain name field is 64 bytes (null-padded). Passing the raw padded name as a seed is impossible — it exceeds the per-seed limit. The solution: hash the null-padded name with SHA-256 first, producing a 32-byte digest used as the second seed.

```typescript
import { PublicKey } from "@solana/web3.js";
import { createHash } from "crypto"; // Node.js; browser: crypto.subtle.digest

const NULL_REGISTRAR = new PublicKey(
  "H4wbFJucY9shJt95N8Bra532Z4nnkKhGEfqWvLcYfuDm"
);

/**
 * Derive the NullDomain PDA for a given name.
 *
 * Seeds: ["null-domain", sha256(name null-padded to 64 bytes)]
 *
 * The name is NOT the signer seed — two wallets registering "foo"
 * would collide. Registration is first-come, first-served; the program
 * enforces uniqueness by requiring the PDA to be uninitialized.
 */
export function deriveNullDomain(name: string): [PublicKey, number] {
  // Step 1: encode name as UTF-8, pad/truncate to exactly 64 bytes
  const nameBytes = Buffer.alloc(64, 0);
  const encoded = Buffer.from(name, "utf8");
  encoded.copy(nameBytes, 0, 0, Math.min(encoded.length, 64));

  // Step 2: SHA-256 the padded buffer → 32-byte digest
  const nameHash = createHash("sha256").update(nameBytes).digest();

  // Step 3: findProgramAddressSync with two seeds
  return PublicKey.findProgramAddressSync(
    [Buffer.from("null-domain"), nameHash],
    NULL_REGISTRAR
  );
}
```

**Why SHA-256 instead of just the raw name?** Solana's `create_program_address` syscall enforces a 32-byte maximum per seed. The 64-byte padded name field cannot be a seed directly. Hashing collapses it to 32 bytes deterministically. The same hash is computed identically in the on-chain program (Rust `sha2` crate) and off-chain (JS or any language), so derivation is trustless — no lookup table, no server.

***

## Resolution Flow

```mermaid
sequenceDiagram
    actor User
    participant Omnibox as Chrome Omnibox
    participant BG as background.js (MV3 Service Worker)
    participant Resolver as null-resolver-page.html
    participant Codec as codec.js
    participant RPC as Solana RPC
    participant GW as Arweave Gateway

    User->>Omnibox: types foo.null and presses Enter
    Omnibox->>Omnibox: no DNS match → treats as search query
    Omnibox->>BG: chrome.webNavigation.onBeforeNavigate fires<br/>(url: https://www.google.com/search?q=foo.null)
    BG->>BG: targetFor() matches q param → bare name.null pattern
    BG->>BG: maybeRedirect() — not own resolver page, redirect OK
    BG->>Resolver: chrome.tabs.update → null-resolver-page.html?domain=foo.null

    Resolver->>Codec: deriveNullDomain("foo")
    Codec->>Codec: UTF-8 encode + null-pad to 64 bytes → SHA-256 → 32-byte seed
    Codec->>Codec: findProgramAddressSync(["null-domain", hash], NULL_REGISTRAR)
    Codec-->>Resolver: PDA pubkey

    Resolver->>RPC: getAccountInfo(PDA, {encoding: "base64"})
    Note over RPC: RPC fallback chain:<br/>1. solana-rpc.publicnode.com<br/>2. solana.publicnode.com<br/>3. solana.api.onfinality.io/public<br/>(api.mainnet-beta.solana.com → 403, banned)

    RPC-->>Resolver: base64-encoded 314-byte account blob

    Resolver->>Codec: decodeArweaveTxid(accountData)
    Codec->>Codec: base64-decode blob<br/>slice bytes [97..129]<br/>check all-zero
    Codec-->>Resolver: 32 raw bytes

    Resolver->>Codec: base64urlEncode(32 bytes)
    Codec-->>Resolver: 43-char Arweave tx ID

    Resolver->>GW: HEAD https://gateway.irys.xyz/{txid}
    alt 2xx or redirect
        GW-->>Resolver: 200 OK
        Resolver->>GW: location.replace(gateway.irys.xyz/{txid})
    else fail
        Resolver->>GW: HEAD https://arweave.net/{txid}
        alt 2xx or redirect
            GW-->>Resolver: 200 OK
            Resolver->>GW: location.replace(arweave.net/{txid})
        else fail
            Resolver->>GW: location.replace(ar-io.net/{txid}) [fallback]
        end
    end
    GW-->>User: serves Arweave content
```

***

## The Two Listener Cases

The MV3 service worker runs two listeners in parallel inside `background.js`. Both funnel into the same `maybeRedirect` function.

### Case 1 — Direct URL (`*://*.null/*`)

The user types `http://parad0x.null` directly. `webNavigation.onBeforeNavigate` fires earliest in the navigation lifecycle, before the tab renders anything.

```javascript
// background.js — Case 1: direct .null navigation
chrome.webNavigation.onBeforeNavigate.addListener(
  (details) => {
    // Only intercept top-level frames; ignore iframes
    if (details.frameId !== 0) return;
    maybeRedirect(details.tabId, details.url);
  },
  { url: [{ hostSuffix: ".null" }] }
);

function maybeRedirect(tabId, rawUrl) {
  // Prevent redirect loops — skip if we're already on the resolver page
  if (rawUrl.includes(chrome.runtime.getURL("null-resolver-page.html"))) return;

  const target = targetFor(rawUrl);
  if (!target) return;

  chrome.tabs.update(tabId, {
    url: chrome.runtime.getURL(`null-resolver-page.html?domain=${target}`),
  });
}
```

### Case 2 — Search-Engine Redirect

The omnibox sends `parad0x.null` to Google/Bing as a query. `onBeforeNavigate` sees the search URL, not a `.null` host. `tabs.onUpdated` catches the resulting page load and inspects query parameters.

```javascript
// background.js — Case 2: search-engine proxy
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.status !== "loading") return;
  if (!tab.url) return;
  maybeRedirect(tabId, tab.url);
});

// targetFor: returns "name" if the URL is a search-engine proxy for name.null
function targetFor(rawUrl) {
  let u;
  try { u = new URL(rawUrl); } catch { return null; }

  // Direct .null host
  if (u.hostname.endsWith(".null")) {
    return u.hostname.replace(/\.null$/, "");
  }

  // Search engines: check all common query params
  const SEARCH_PARAMS = ["q", "p", "query", "text", "wd", "search"];
  const SEARCH_HOSTS = [
    "www.google.com", "www.bing.com",
    "search.yahoo.com", "duckduckgo.com",
    "search.brave.com",
  ];
  if (!SEARCH_HOSTS.includes(u.hostname)) return null;

  for (const param of SEARCH_PARAMS) {
    const val = u.searchParams.get(param);
    // Match bare "name.null" — no spaces, no extra tokens
    if (val && /^[a-z0-9-]+\.null$/i.test(val.trim())) {
      return val.trim().replace(/\.null$/i, "");
    }
  }
  return null;
}
```

### Keepalive Fix (MV3 Service Workers Die After 30s Idle)

MV3 service workers are terminated after \~30 seconds with no activity. A tab sitting on a `.null` page would lose its listener. The fix: a `chrome.alarms` heartbeat every 20 seconds forces the service worker to wake and re-register if it was killed.

```javascript
// background.js — keepalive via chrome.alarms
chrome.alarms.create("null-keepalive", { periodInMinutes: 1 / 3 }); // every 20s

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === "null-keepalive") {
    // Waking up is sufficient — listeners re-register on service worker boot
    // Optionally sweep any pending resolution state here
  }
});
```

`chrome.alarms` is the only MV3-sanctioned mechanism for keeping a service worker alive without a persistent connection. `setInterval` does not survive termination; `alarms` does because Chrome wakes the worker to fire them.

***

## The Seven Instructions

The `null_registrar` program (`H4wbFJucY9shJt95N8Bra532Z4nnkKhGEfqWvLcYfuDm`) uses a single-byte instruction tag at offset 0 of the instruction data — not the 8-byte Anchor discriminator pattern.

| Tag    | Name             | Signer Required            | Effect                                                                                                                                                                                                                            |
| ------ | ---------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0x01` | `InitRegistry`   | authority (deploy keypair) | One-time bootstrap. Creates the registry config account. Must be called once before any `Register`.                                                                                                                               |
| `0x02` | `Register`       | any wallet                 | Claims a name. Creates the PDA, sets `owner = signer`, writes `disc = 0x4E`, records `registered_at`. Fee is currently off during the pilot; costs only rent (\~0.003 SOL). Go-live fee is a config-set SOL amount (\~0.007 SOL). |
| `0x03` | `UpdateContent`  | current `owner`            | Overwrites `arweave_txid` at offset 97 with 32 new bytes. Old content on Arweave is permanent — the PDA pointer changes, the old data does not disappear.                                                                         |
| `0x04` | `Transfer`       | current `owner`            | Updates the `owner` field to a new pubkey. No account close or recreate — the PDA address is name-derived, not owner-derived.                                                                                                     |
| `0x05` | `Resolve`        | none                       | Read-only. Returns the full record via program logs or CPI return data. Used by on-chain integrations; off-chain callers use `getAccountInfo` directly.                                                                           |
| `0x06` | `UpdateEndpoint` | current `owner`            | Overwrites `x402_endpoint` at offset 129 with up to 128 UTF-8 bytes, null-padded.                                                                                                                                                 |
| `0x07` | `UpdatePassport` | current `owner`            | Overwrites `passport_hash` at offset 257 with a 32-byte Groth16 public commitment from a Dark Passport proof.                                                                                                                     |

**Registration fee:** priced in SOL — **\~0.01 SOL all-in** (the \~0.003 SOL rent + a \~0.007 SOL protocol fee) for 4–32 character names, payable in SOL or in **NULL for \~20% less**. Set in config and currently **off during the pilot** (rent only). 1–3 character premium names are released by auction, and any name can be resold through open-ascending (English) auctions, via `null-auction` (`7uxLhqLzkEzPpkvdmTwqgL3g66yq2aMBS5QgcjaZZEaw`).

```mermaid
flowchart TD
    A[Instruction data byte 0] --> B{Tag?}
    B -->|0x01| C[InitRegistry\nauthority only]
    B -->|0x02| D[Register\nopen — anyone\n≤3 free names per wallet]
    B -->|0x03| E[UpdateContent\nowner only]
    B -->|0x04| F[Transfer\nowner only]
    B -->|0x05| G[Resolve\nread-only / no signer]
    B -->|0x06| H[UpdateEndpoint\nowner only]
    B -->|0x07| I[UpdatePassport\nowner only]
    B -->|0x0B| O[MintPremium\nCPI from null-auction\nmints a 1–3 char name\nto the auction winner — cap-exempt]
    D --> J[(PDA created\n314 bytes)]
    O --> J
    E --> K[(arweave_txid\noffset 97 overwritten)]
    F --> L[(owner field\noffset 65 overwritten)]
    H --> M[(x402_endpoint\noffset 129 overwritten)]
    I --> N[(passport_hash\noffset 257 overwritten)]
```

***

## What Gets Stored On Arweave

### ANS-104 Path Manifest

Raw files uploaded to Arweave get individual transaction IDs, but a `.null` name points to exactly one 32-byte tx ID. An ANS-104 path manifest solves this: it is a single Arweave transaction whose data is a JSON index mapping URL paths to individual data item IDs.

```json
{
  "manifest": "arweave/paths",
  "version": "0.1.0",
  "index": { "path": "index.html" },
  "paths": {
    "index.html": { "id": "<43-char tx ID of the HTML file>" },
    "style.css":  { "id": "<43-char tx ID of the CSS file>" },
    "logo.png":   { "id": "<43-char tx ID of the image>" }
  }
}
```

The manifest's own tx ID is the 32 bytes stored in the PDA at offset 97. When the resolver navigates to `arweave.net/<manifest-txid>`, the Arweave gateway reads the manifest and serves `index.html` by default. Paths like `arweave.net/<manifest-txid>/style.css` resolve to the correct data item.

### Upload Toolchain

Manifests are assembled and uploaded via `@ardrive/turbo-sdk` v1.41.3 or `@irys/sdk` v0.2.11. Neither requires AR tokens — payment is in Turbo Credits purchased with credit card, USDC, or ETH. The upload SDK returns the manifest tx ID, which is then passed to `UpdateContent` (0x03).

```typescript
// Simplified upload + register flow
import { TurboFactory } from "@ardrive/turbo-sdk";

const turbo = TurboFactory.authenticated({ privateKey: walletJwk });
const { id: manifestTxId } = await turbo.uploadFolder({
  folderPath: "./site-build",
  indexFile: "index.html",
  manifestOptions: { disableManifest: false },
});

// manifestTxId is a 43-char base64url string
// Decode to 32 raw bytes, pass to UpdateContent (0x03)
const txIdBytes = base64urlToBytes(manifestTxId); // 32 bytes
// ... build and send UpdateContent instruction to null_registrar
```

### Why Old Content Persists

Arweave data is permanent by design: miners are paid upfront from the endowment to store data for \~200 years. `UpdateContent` (0x03) only moves the PDA pointer — it does not and cannot delete data on Arweave. This means:

* Every past version of a `.null` site remains accessible at its original tx ID forever.
* The PDA at any point in time points to the current canonical version.
* Audit trail: on-chain tx history of `UpdateContent` calls shows every previous manifest tx ID with block timestamps.

```mermaid
graph LR
    PDA["PDA\narweave_txid = M2\n(offset 97)"]
    M1["Manifest M1\n(v1 site)\nPermanent on Arweave"]
    M2["Manifest M2\n(v2 site)\nPermanent on Arweave"]

    PDA -->|"current pointer"| M2
    PDA -.->|"old pointer (still live at M1 txid)"| M1

    M2 --> F1["index.html v2"]
    M2 --> F2["style.css v2"]
    M1 --> F3["index.html v1"]
    M1 --> F4["style.css v1"]
```

`UpdateContent` moves the solid arrow from M1 to M2. The dashed arrow (M1) is not removed — M1 remains permanently accessible at `arweave.net/<M1-txid>` and is visible in the on-chain instruction history.

***

## NRAP — Session Attestation

Before `location.replace` to Arweave, the resolver writes an ephemeral attestation into `chrome.storage.session`:

```javascript
// null-resolver-page.js — write attestation before leaving
await chrome.storage.session.set({
  [`null-attest:${domain}`]: {
    domain,
    programId: "H4wbFJucY9shJt95N8Bra532Z4nnkKhGEfqWvLcYfuDm",
    arweaveTx: txId43char,
    resolvedAt: Date.now(),
  },
});
location.replace(`https://gateway.irys.xyz/${txId43char}`);
```

`chrome.storage.session` is ephemeral — it expires when the browser session ends and cannot be replayed across sessions. An Arweave-hosted `.null` page can call:

```javascript
chrome.runtime.sendMessage(EXT_ID, { type: "null-attest", domain: "foo" },
  (resp) => { /* resp.programId, resp.arweaveTx, resp.resolvedAt */ }
);
```

`externally_connectable` in `manifest.json` restricts this to Arweave gateways (`gateway.irys.xyz`, `arweave.net`, `ar-io.net`) and `parad0xlabs.com` only. The attestation proves the content was reached via the canonical resolver using the real `null_registrar` program, not a fork or a direct link.

***

*Program IDs are Solana mainnet. `null_registrar` and `null-auction` are live. Dark Passport verifier, x402 middleware, and receipt-DAG are in development — labeled as such in the codebase.*


---

# 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/null-resolver-.null-domains/how-it-works.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.
