Welcome to Learning Xahau, a series of articles dedicated to helping developers, builders, and blockchain enthusiasts better understand the Xahau ecosystem. Whether you're just getting started or already building advanced applications, these posts will explore Xahau's features, architecture, and best practices through practical examples and real-world use cases.

Every dApp has the same weak spot: the front-end. The Hooks may run on a decentralised ledger, but users reach them through a website on a server, a domain and a DNS provider. If any of those disappears, gets hijacked or is taken down, users can't reach the app even though the ledger is still running.

The PWALoader amendment, shipped with the 2026.10 xahaud releases, takes a direct approach: it lets an account store a web page on the ledger, and lets nodes serve that page over HTTPS. In this article we'll look at how it works in the xahaud source code, and then build three apps with it:

  1. A links page (Linktree style): a plain HTML document stored as is.
  2. A game (Flappy Hook): a page too big for the ledger, compressed into a document that unpacks itself.
  3. A live dashboard: a page that connects to the same ledger it is stored on.

All the code targets the Xahau Testnet (network 21338, wss://xahau-test.net). PWALoader is enabled there, and Testnet now has a public PWA portal at pwa.exposed, announced by Richard Holland, so every page you publish gets a real URL. You also need xahau.js 4.2.0 or later, the first release with the AppLoader field and the pwa_validate request. The full repository is at github.com/Ekiserrepe/learnxahau-pwa.

What PWALoader Adds

The amendment touches three layers: a transaction field, a ledger object and an HTTP server mode inside xahaud. It's easier to understand by following one page from submission to the browser.

1. A new field on AccountSet: AppLoader

There is no new transaction type. You publish a page with a normal AccountSet that carries an AppLoader field (a variable-length blob) with the HTML document in hex:

{ "TransactionType": "AccountSet", "Account": "rG1SciZDoTzKpzLr7PnktCW8CyrQzZuQX8", "AppLoader": "3C21646F63747970652068746D6C3E0A3C68746D6C..." } 

An empty AppLoader ("") removes the page, following the same convention as Domain and MessageKey.

2. A shallow HTML check in consensus

Every validator has to check the page before accepting it, and parsing real HTML would be slow and risky for consensus. So instead of understanding the page, the ledger only checks that it looks like a complete HTML document. A document is accepted only if:

  • it is not empty and is at most 4096 bytes;
  • it is well-formed UTF-8 (the same strict decoder URIToken uses, which also rejects U+FFFE/U+FFFF);
  • it has no control characters except TAB, LF, FF and CR, and no DEL;
  • after an optional BOM and whitespace, it starts with <!doctype (followed by whitespace) or with an <html tag;
  • it contains an <html start tag followed later by a </html> end tag;
  • only whitespace comes after the last </html>.

A document that fails any rule is rejected with temMALFORMED. If the amendment isn't enabled, the field returns temDISABLED. Think of it as a basic sanity check, not a review: nobody looks at what the page says or does, so content is not policed.

3. A new ledger object: AppLoader

When the transaction succeeds, the ledger creates (or overwrites) a new AppLoader ledger entry (type 0x004c):

Field Meaning
Owner The account that published the page
AppLoader The document bytes
AppLoaderSequence Version counter, copied from the owner's AccountRoot
PreviousTxnID / PreviousTxnLgrSeq The last transaction that wrote it (also used as the HTTP ETag)

Its ID is SHA-512Half(0x0057 'W' + AccountID). Because the key depends only on the owner, each account has at most one page. To publish three apps you need three accounts, which is why this repo has one seed per app.

Every AccountSet that carries the field, removals included, increments AppLoaderSequence on the AccountRoot. A reader can therefore tell whether a copy is current, and whether a page was removed on purpose.

4. Paid per byte, with no reserve

This is the most interesting design choice. The object is not linked into the owner directory and takes no owner reserve. Instead, you pay for storage through the transaction fee:

fee = base fee + 1 drop × document bytes 

A full 4 KB page costs about 0.0041 XAH at a 10-drop base fee. The extra fee is charged on every write, including overwrites. Removal carries an empty blob, so it costs nothing extra. An account sitting at its reserve floor can still publish a page. If you underpay, the transaction fails with telINSUF_FEE_P. When you autofill with xahau.js, the library sends the signed blob to the node's fee method, so the per-byte part is calculated for you.

5. Serving: the pwa port

Storing HTML on a ledger isn't useful unless a browser can reach it. That part is not consensus: it's an opt-in server mode in xahaud. Serving isn't gated by the amendment either, so it's purely local configuration. A node operator adds a port with protocol = pwa (see cfg/xahaud-example.cfg and the Serving PWA apps guide):

[port_pwa] port = 8088 ip = 127.0.0.1 protocol = pwa secure_gateway = 127.0.0.1 domain = pwapp.example 

The node then answers on:

https://pwa<AccountIndex>.<domain>/<r-address> the page https://<domain>/<r-address> 301 redirect to the line above 

A few details matter for app developers:

  • One host per account. The label is pwa followed by the account's AccountIndex in decimal (JSON shows it in hex, so "AccountIndex": "7ff9" becomes pwa32761). Every app gets its own browser origin, so two apps can never read each other's data. The bare zone never serves a page.
  • Only validated state is served. A page you just published appears once its ledger is validated, not before.
  • GET and HEAD only, rate-limited per IP, behind a reverse proxy that terminates TLS. The node refuses direct connections.
  • Custom domains. With custom_domains = 1, app.example.org can point at a node (CNAME) plus a TXT record _xahau-pwa.app.example.org holding the r-address. The account must consent by setting its Domain field to that host or a parent of it.
  • Operators keep control. [pwa_allowlist] and [pwa_blocklist] decide which accounts their node serves. The protocol can't take a page down, but no operator has to host it. The config comment puts it plainly: "Hosting, and a hosting provider's obligations, are yours."

6. The sandbox: what your page can and cannot do

This section shapes how you write apps. Every page is served with these headers:

Content-Type: text/html; charset=utf-8 Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups X-Frame-Options: DENY Cross-Origin-Resource-Policy: same-origin Cross-Origin-Opener-Policy: same-origin Cache-Control: no-cache ETag: "<PreviousTxnID>" X-NetworkID / X-LedgerSeq / X-LedgerHash / X-PWASeq 

The sandbox directive without allow-same-origin gives the page an opaque origin ("null"). I opened one of the pages from this article in Chrome and tested it:

Feature Result
JavaScript, forms, canvas, WebSocket, crypto.subtle ✅ works (it's still a secure context)
fetch() to other sites that send CORS * (CDNs, Xahau nodes) ✅ works
localStorage, sessionStorage, cookies ❌ SecurityError
Service worker registration ❌ SecurityError
fetch() of its own URL ❌ blocked (no CORS on the pwa port)
Clipboard write after a click ✅ worked in Chrome (keep a fallback)
Popups (target="_blank") ⚠️ open, but inherit the sandbox
Being framed in an <iframe> ❌ X-Frame-Options: DENY

So, is it really a PWA? Not quite yet. The name refers to the bootstrap (the "loader") of a Progressive Web App. You can't register a service worker or persist data from that origin, but you get JavaScript, network access and a stable, owner-controlled URL that nobody can edit except the account holder. Our three examples use that: a plain page, a packed page, and a page that talks to the ledger.

7. Verifying what you were served: pwa_validate

A node could serve you something other than what's on the ledger. To check, xahaud adds a public RPC, pwa_validate. You send it the account and the SHA-512Half of the bytes you received, and it answers against its validated ledger:

{ "command": "pwa_validate", "account": "r...", "hash": "<SHA-512Half hex>", "domain": "app.example.org" } 

The response includes valid, ledger_index, app_loader_sequence and, when invalid, a reason ("no loader", "hash mismatch" or "domain not endorsed by account"). The X-LedgerSeq header tells you which ledger the page came from. If that ledger is older than ledger_index and you get a hash mismatch, the page was just updated, so reload. A browser extension could run this check against a second, independent node on every visit. There is also an admin-only pwa_info for operators (request counters, DNS cache).

Setup

You need Node.js 18 or later (the scripts use the built-in fetch). Everything is run from a terminal inside the project folder.

The network: Testnet and pwa.exposed

Publishing only needs a node with PWALoader enabled. Serving needs a node that runs a pwa port for that network, and on Testnet that's the public portal at pwa.exposed:

https://pwa.exposed/<r-address> 301 redirect https://pwa<AccountIndex>.pwa.exposed/<r-address> the page 

To use a different network or your own node, you only need to change WSS_URL, FAUCET_URL, PWA_ZONE and EXPLORER_URL in .env (step 2). One detail if you reuse code from the RNG article: the Testnet faucet hands out secp256k1 seeds, and xahau.js doesn't infer that from the seed, so lib/config.js picks the algorithm from the seed prefix (sEd… is Ed25519).

Step 1: Get the code and install the dependencies

git clone https://github.com/Ekiserrepe/learnxahau-pwa cd learnxahau-pwa npm install 

npm install reads package.json and installs xahau.js, dotenv (to read the .env file) and qrcode (used by the links page):

package.json

{ "name": "learnxahau-pwa", "version": "1.0.0", "description": "Learning Xahau: on-ledger web apps with the PWALoader amendment (a links page, a game, a live ledger dashboard and a verified remote loader) using xahau.js 4.2.0", "license": "MIT", "private": true, "dependencies": { "dotenv": "^16.4.5", "qrcode": "^1.5.4", "xahau": "4.2.0" } } 

Step 2: Create your .env file

The scripts read their settings (network, PWA portal and one seed per app) from a .env file. Start from the example:

cp .env.example .env 

.env.example

# Xahau Testnet (network 21338, PWALoader enabled, public PWA portal at pwa.exposed). Run `node 00-create-accounts.js` # to create the accounts below with the faucet and fill this file for you WSS_URL=wss://xahau-test.net FAUCET_URL=https://xahau-test.net/newcreds PWA_ZONE=pwa.exposed # documents are served at https://pwa<AccountIndex>.<PWA_ZONE>/<r-address> EXPLORER_URL=https://explorer.xahau-test.net VERIFY_WSS_URL= # optional second node for 05-verify.js (defaults to WSS_URL) # One account per app: an account holds at most one AppLoader LINKS_SEED= # apps/links - the links page GAME_SEED= # apps/game - Flappy Hook LEDGER_SEED= # apps/ledger - live ledger dashboard 

Each app needs its own account, because an account can hold only one page. lib/config.js maps each app name to its seed. That's why every script takes the app name as an argument (links, game or ledger):

lib/config.js

// Shared settings: which account publishes which app, and how to reach the network. require('dotenv').config() const { Wallet } = require('xahau') const APPS = { links: 'LINKS_SEED', game: 'GAME_SEED', ledger: 'LEDGER_SEED' } const WSS_URL = process.env.WSS_URL || 'wss://xahau-test.net' const PWA_ZONE = process.env.PWA_ZONE || 'pwa.exposed' const EXPLORER_URL = process.env.EXPLORER_URL || 'https://explorer.xahau-test.net' function appName(arg) { if (!APPS[arg]) throw new Error(`Usage: node <script> <${Object.keys(APPS).join('|')}>`) return arg } // Ed25519 seeds start with "sEd"; the Testnet faucet hands out secp256k1 ones const walletFromSeed = seed => Wallet.fromSeed(seed, { algorithm: seed.startsWith('sEd') ? 'ed25519' : 'secp256k1' }) function walletFor(app) { const seed = process.env[APPS[app]] if (!seed) throw new Error(`${APPS[app]} is empty in .env (run node 00-create-accounts.js)`) return walletFromSeed(seed) } // Accepts an app name (uses its account) or any r-address const addressFor = arg => (APPS[arg] ? walletFor(arg).address : arg) module.exports = { APPS, WSS_URL, PWA_ZONE, EXPLORER_URL, appName, walletFromSeed, walletFor, addressFor } 

Step 3: Create and fund the three accounts

You can paste three Testnet seeds into .env yourself, or let this script ask the faucet for them. It writes the seeds into .env and prints the balances. The Testnet faucet gives one account per minute, so it takes about two minutes:

node 00-create-accounts.js 

00-create-accounts.js

// Optional: creates one funded account per app with the faucet and writes their seeds to .env. // Seeds already present in .env are kept, so you can stop it and run it again. const fs = require('fs') const path = require('path') const dotenv = require('dotenv') const { Client, dropsToXah } = require('xahau') const { walletFromSeed } = require('./lib/config') const ENV_PATH = path.join(__dirname, '.env') const ACCOUNTS = ['LINKS_SEED', 'GAME_SEED', 'LEDGER_SEED'] const COOLDOWN_SECONDS = 60 // the faucet only gives one account per minute const sleep = seconds => new Promise(resolve => setTimeout(resolve, seconds * 1000)) // Asks the faucet for a new funded account, retrying after the cooldown if it refuses async function requestAccount(faucetUrl) { for (let attempt = 1; attempt <= 5; attempt++) { let wait = COOLDOWN_SECONDS try { const res = await fetch(faucetUrl, { method: 'POST' }) const body = await res.json() if (res.ok && body.secret && body.code === 'tesSUCCESS') return body console.log(` Faucet said: ${body.error || JSON.stringify(body)}`) const seconds = /wait (\d+) seconds/.exec(body.error || '') if (seconds) wait = Number(seconds[1]) + 2 } catch (error) { console.log(` Faucet unavailable: ${error.message}`) } console.log(` Retrying in ${wait}s...`) await sleep(wait) } throw new Error('The faucet did not give an account after 5 attempts') } // Writes KEY=value into .env, keeping the comment of the line if there is one function saveSeed(key, seed) { let env = fs.readFileSync(ENV_PATH, 'utf8') const line = new RegExp(`^${key}=.*$`, 'm') env = line.test(env) ? env.replace(line, old => `${key}=${seed}${old.match(/\s(#.*)$/) ? ` ${old.match(/\s(#.*)$/)[1]}` : ''}`) : `${env.trimEnd()}\n${key}=${seed}\n` fs.writeFileSync(ENV_PATH, env) } async function createAccounts() { if (!fs.existsSync(ENV_PATH)) { fs.copyFileSync(path.join(__dirname, '.env.example'), ENV_PATH) console.log('Created .env from .env.example') } let env = dotenv.parse(fs.readFileSync(ENV_PATH)) const faucetUrl = env.FAUCET_URL || 'https://xahau-test.net/newcreds' const missing = ACCOUNTS.filter(key => !env[key]) if (missing.length > 1) { console.log(`Creating ${missing.length} accounts. It takes about ${(missing.length - 1) * COOLDOWN_SECONDS}s because of the faucet cooldown.\n`) } for (const [i, key] of missing.entries()) { if (i > 0) { console.log(` Waiting ${COOLDOWN_SECONDS}s for the faucet cooldown...`) await sleep(COOLDOWN_SECONDS) } const account = await requestAccount(faucetUrl) // Check that the seed really opens that address const wallet = walletFromSeed(account.secret) if (wallet.address !== account.address) throw new Error(`Seed does not match ${account.address}`) saveSeed(key, account.secret) console.log(`${key.padEnd(12)} ${account.address} (+${account.xrp} XAH)`) } // Show what every account in .env holds now env = dotenv.parse(fs.readFileSync(ENV_PATH)) const client = new Client(env.WSS_URL || 'wss://xahau-test.net') await client.connect() console.log('\nBalances:') for (const key of ACCOUNTS) { const { address } = walletFromSeed(env[key]) let balance = 'not funded yet (try again in a few seconds)' // The last faucet payment may still be waiting for its ledger to close for (let attempt = 0; attempt < 10; attempt++) { try { const { result } = await client.request({ command: 'account_info', account: address, ledger_index: 'validated' }) balance = `${dropsToXah(result.account_data.Balance)} XAH` break } catch { await sleep(2) } } console.log(`${key.padEnd(12)} ${address} ${balance}`) } await client.disconnect() } createAccounts().catch(console.error) 
$ node 00-create-accounts.js Creating 3 accounts. It takes about 120s because of the faucet cooldown. LINKS_SEED rsHcmQM3LxjxUpLgBzUzbvzVDTyD7p1s2C (+1000 XAH) Waiting 60s for the faucet cooldown... GAME_SEED r9KrqiUab4za8YCJPAZPNc1NeFFowMDzt3 (+1000 XAH) Waiting 60s for the faucet cooldown... LEDGER_SEED rwEKoNJXBZtoo2U1caroJCPsBBK1DfHZim (+1000 XAH) Balances: LINKS_SEED rsHcmQM3LxjxUpLgBzUzbvzVDTyD7p1s2C 1000 XAH GAME_SEED r9KrqiUab4za8YCJPAZPNc1NeFFowMDzt3 1000 XAH LEDGER_SEED rwEKoNJXBZtoo2U1caroJCPsBBK1DfHZim 1000 XAH 

If it stops halfway, run it again: it keeps the seeds already in <...