Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/deploy-to-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Deploy to GitHub Pages (Preview)

on:
workflow_dispatch: # Only manual trigger

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: "pages"
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: "yarn"

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Clear Next.js build cache
run: rm -rf .next out

- name: Build
run: yarn build
env:
NODE_ENV: production
BASE_PATH: /ar-io-docs
NEXT_PUBLIC_BASE_PATH: /ar-io-docs
NEXT_PUBLIC_SITE_URL: https://ar-io.github.io/ar-io-docs

- name: Setup Pages
uses: actions/configure-pages@v6

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: ./out

deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
8 changes: 1 addition & 7 deletions .github/workflows/pr-preview.yaml
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
name: PR Preview Deployment

on:
pull_request:
types: [opened, synchronize, reopened, closed]
paths:
- "content/**"
- "src/**"
workflow_dispatch:

jobs:
lint:
runs-on: ubuntu-latest
if: github.event.action != 'closed'

steps:
- name: Checkout repository
Expand All @@ -31,7 +26,6 @@ jobs:
deploy-preview:
runs-on: ubuntu-latest
needs: lint
if: github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'closed'

steps:
- name: Checkout repository
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,8 @@ The default public Solana RPC is rate-limited and may block `getProgramAccounts`

The observer uploads report bundles to Turbo. The upload signer is resolved from the first matching env in the [precedence chain](/build/run-a-gateway/manage/solana-migration#upload-signing-precedence). Setting envs from more than one chain group at once is rejected at startup.

If your observer logs warn that `TurboReportSink` is not configured, explicitly set a Solana upload signer. Most operators can use the same base58 secret for both `OBSERVER_PRIVATE_KEY` and `SOLANA_UPLOAD_PRIVATE_KEY`.

| Variable | Type | Default | Description |
| --------------------------------- | ------ | ------- | --------------------------------------------------------------------------- |
| `ARWEAVE_UPLOAD_KEY_FILE` | string | - | Path to an Arweave JWK file. Highest priority for upload signing |
Expand All @@ -391,7 +393,7 @@ The observer uploads report bundles to Turbo. The upload signer is resolved from
| `SOLANA_UPLOAD_KEYPAIR_PATH` | string | - | Path to a separate Solana keypair JSON for uploads. Ignored when any `ARWEAVE_UPLOAD_*` or `ETHEREUM_UPLOAD_*` is set |
| `SOLANA_UPLOAD_PRIVATE_KEY` | string | - | Alternative to above: base58 secret. Mutually exclusive with the file form |

When none of the above are set, uploads fall back to the observer key, then the operator key.
When none of the above are set, uploads fall back to the observer key, then the operator key. For production observers, prefer setting `SOLANA_UPLOAD_KEYPAIR_PATH` or `SOLANA_UPLOAD_PRIVATE_KEY` explicitly so report uploads do not depend on fallback behavior.

### Offset Observation

Expand Down
21 changes: 17 additions & 4 deletions content/build/run-a-gateway/manage/solana-migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Complete these steps before the cutover date to ensure uninterrupted reward elig
</Callout>

<Callout type="info" title="Phantom-export keys">
If you already have a Phantom-exported base58 secret string for the observer or operator, you can skip the JSON keypair file entirely: set `OBSERVER_PRIVATE_KEY=<base58>` (or `SOLANA_PRIVATE_KEY=<base58>`) instead of the `*_KEYPAIR_PATH` env. Setting both forms for the same role is rejected at startup.
If you already have a Phantom-exported base58 secret string for the observer or operator, you can skip the JSON keypair file entirely: set `OBSERVER_PRIVATE_KEY=<base58>` (or `SOLANA_PRIVATE_KEY=<base58>`) instead of the `*_KEYPAIR_PATH` env. When using an inline observer key, also set `SOLANA_UPLOAD_PRIVATE_KEY=<base58>` so observer report uploads to Turbo use the same signer. Setting both forms for the same role is rejected at startup.
</Callout>
</Step>

Expand Down Expand Up @@ -223,7 +223,7 @@ The gateway uses up to four distinct wallet roles. Understanding these helps you
|---|---|---|---|
| **Operator** (+ cranker) | `join_network`, `update_gateway_settings`, permissionless cranker instructions | `SOLANA_KEYPAIR_PATH` or `SOLANA_PRIVATE_KEY` | — (required) |
| **Observer** | `save_observations` transactions | `OBSERVER_KEYPAIR_PATH` or `OBSERVER_PRIVATE_KEY` | Falls back to operator key |
| **Upload** | Observer report bundles sent to Turbo | See [upload precedence](#upload-signing-precedence) below | Falls back to observer → operator Solana key |
| **Upload** | Observer report bundles sent to Turbo | See [upload precedence](#upload-signing-precedence) below | Falls back to observer → operator Solana key, but explicit upload env is recommended |
| **HTTPSIG signer** | RFC 9421 response headers | Uses observer Solana key when set | Auto-generated standalone Ed25519 key |

<Callout type="info">
Expand All @@ -247,12 +247,20 @@ These are the five supported wallet setups. **Pattern 1 is the recommended defau
```bash
# One key for operator + observer + uploads
SOLANA_KEYPAIR_PATH=/app/wallets/operator-keypair.json
SOLANA_UPLOAD_KEYPAIR_PATH=/app/wallets/operator-keypair.json
SOLANA_RPC_URL=<your dedicated RPC endpoint>
AR_IO_WALLET=<your Solana pubkey>
OBSERVER_WALLET=<your Solana pubkey>
ENABLE_EPOCH_CRANKING=false # flip to true when ready
```

If you use a base58 private key instead of a keypair file, explicitly set the upload key too:

```bash
OBSERVER_PRIVATE_KEY=<your base58 private key>
SOLANA_UPLOAD_PRIVATE_KEY=<your base58 private key>
```

#### Pattern 2 — Keep existing Arweave JWK for uploads

The most common path for operators migrating from a pre-Solana setup. Your existing Arweave JWK continues signing report bundles while the Solana keypair handles protocol interactions.
Expand All @@ -275,14 +283,19 @@ The gateway picks the first matching upload signer from this list:
2. ARWEAVE_UPLOAD_JWK (inline) → ArweaveSigner
3. ETHEREUM_UPLOAD_PRIVATE_KEY_FILE (file) → EthereumSigner
4. ETHEREUM_UPLOAD_PRIVATE_KEY (inline) → EthereumSigner
5. SOLANA_UPLOAD_KEYPAIR_PATH (explicit) → SolanaSigner
6. Fallback: OBSERVER_KEYPAIR_PATH ?? SOLANA_KEYPAIR_PATH → SolanaSigner
5. SOLANA_UPLOAD_KEYPAIR_PATH (explicit file) → SolanaSigner
6. SOLANA_UPLOAD_PRIVATE_KEY (explicit inline) → SolanaSigner
7. Fallback: OBSERVER_KEYPAIR_PATH ?? SOLANA_KEYPAIR_PATH → SolanaSigner
```

<Callout type="warn">
Setting upload envs from more than one chain at once (e.g. `ARWEAVE_UPLOAD_KEY_FILE` **plus** `ETHEREUM_UPLOAD_PRIVATE_KEY`) raises a startup error listing every conflicting env. Pick exactly one upload chain.
</Callout>

<Callout type="info" title="TurboReportSink not configured">
If observer logs warn that `TurboReportSink` is not configured, the observer does not have an upload signer for report data. Add `SOLANA_UPLOAD_PRIVATE_KEY=<same base58 key as OBSERVER_PRIVATE_KEY>` or `SOLANA_UPLOAD_KEYPAIR_PATH=<same keypair path as OBSERVER_KEYPAIR_PATH>`, then restart the observer and check the next epoch logs.
</Callout>

### Key Formats

Solana keypairs come in two common formats. Both encode the same 64-byte secret (`seed(32) || pubkey(32)`):
Expand Down
20 changes: 19 additions & 1 deletion content/build/run-a-gateway/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ Ready to run a gateway with your own domain name and SSL certificates? Follow th
ARNS_ROOT_HOST=<your-domain>
AR_IO_WALLET=<your-solana-pubkey>
OBSERVER_WALLET=<observer-solana-pubkey>
OBSERVER_PRIVATE_KEY=<observer-base58-private-key>
SOLANA_UPLOAD_PRIVATE_KEY=<observer-base58-private-key>
```

<Callout type="info">
Expand All @@ -123,8 +125,15 @@ Ready to run a gateway with your own domain name and SSL certificates? Follow th
**Supply Observer Wallet Keyfile:**
Save your Solana keypair JSON file as `<Observer-Wallet-Address>.json` in the `wallets` directory.

If you use a keypair file instead of inline base58 keys, remove `OBSERVER_PRIVATE_KEY` and `SOLANA_UPLOAD_PRIVATE_KEY` from `.env`, then set both paths explicitly:

```bash
OBSERVER_KEYPAIR_PATH=/app/wallets/<Observer-Wallet-Address>.json
SOLANA_UPLOAD_KEYPAIR_PATH=/app/wallets/<Observer-Wallet-Address>.json
```

<Callout type="info" title="Payment For Observer Report Uploads">
By default, the Observer will use [Turbo Credits](https://docs.ardrive.io/docs/turbo/credits) to pay for uploading reports to Arweave. This allows reports under 100kb to be uploaded for free, but larger reports will fail if the Observer wallet does not contain Credits. Including `REPORT_DATA_SINK=arweave` in your `.env` file will configure the Observer to use AR tokens instead of Turbo Credits, without any free limit.
By default, the Observer will use [Turbo Credits](https://docs.ardrive.io/docs/turbo/credits) to pay for uploading reports to Arweave. This allows reports under 100kb to be uploaded for free, but larger reports will fail if the Observer wallet does not contain Credits. Set `SOLANA_UPLOAD_PRIVATE_KEY` to the same base58 secret as `OBSERVER_PRIVATE_KEY` so Turbo has an explicit upload signer. If logs warn that `TurboReportSink` is not configured, this upload key is usually missing. Including `REPORT_DATA_SINK=arweave` in your `.env` file will configure the Observer to use AR tokens instead of Turbo Credits, without any free limit.
</Callout>

**Start the Docker container:**
Expand Down Expand Up @@ -306,12 +315,21 @@ Ready to run a gateway with your own domain name and SSL certificates? Follow th
ARNS_ROOT_HOST=<your-domain>
AR_IO_WALLET=<your-public-wallet-address>
OBSERVER_WALLET=<hot-wallet-public-address>
OBSERVER_PRIVATE_KEY=<observer-base58-private-key>
SOLANA_UPLOAD_PRIVATE_KEY=<observer-base58-private-key>
```

**Save as `.env`** (select "All Files" as file type)

**Supply Observer Wallet Keyfile:**
Save your wallet keyfile as `<Observer-Wallet-Address>.json` in the `wallets` directory.

If you use a keypair file instead of inline base58 keys, remove `OBSERVER_PRIVATE_KEY` and `SOLANA_UPLOAD_PRIVATE_KEY` from `.env`, then set both paths explicitly:

```bash
OBSERVER_KEYPAIR_PATH=/app/wallets/<Observer-Wallet-Address>.json
SOLANA_UPLOAD_KEYPAIR_PATH=/app/wallets/<Observer-Wallet-Address>.json
```
</Step>

<Step>
Expand Down
1 change: 1 addition & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const config = {
// Enable static export only for production builds
output: process.env.NODE_ENV === "production" ? "export" : "standalone",
trailingSlash: process.env.NODE_ENV === "production" ? true : false,
basePath: process.env.BASE_PATH || "",
reactStrictMode: true,
eslint: {
// Warning: This allows production builds to successfully complete even if
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build": "NODE_ENV=production next build",
"build": "NODE_ENV=production next build && tsx scripts/inject-chunk-load-recovery.ts",
"dev": "next dev --turbo",
"start": "next start",
"lint": "eslint src/ content/ --ext .ts,.tsx,.mdx",
Expand All @@ -14,6 +14,7 @@
"generate-sdk-llm-texts": "node scripts/generate-sdk-llm-texts.js",
"generate-all-docs": "npm run generate-sdk-docs && npm run generate-llm-text && npm run generate-sdk-llm-texts",
"check-links": "node scripts/check-links.mjs",
"test:chunk-recovery": "node tests/chunk-load-recovery.test.mjs",
"test-arns": "node scripts/test-arns-update.js",
"test-signer": "node scripts/test-signer-only.js"
},
Expand Down
91 changes: 91 additions & 0 deletions scripts/inject-chunk-load-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Post-build step: inject the chunk-load recovery script as a real,
* parser-blocking inline <script> at the very top of <head> in every exported
* HTML file.
*
* Why a post-build step instead of rendering it in the React tree: under
* `output: "export"` (Next.js static export + Turbopack + React 19) the RSC
* renderer serializes any inline <script> — whether authored via next/script
* or a plain <script> tag — into the React Flight payload (__next_f) rather
* than emitting an executable tag. Such a script only runs after hydration,
* which depends on the very app chunks it is meant to recover. Injecting into
* the emitted HTML guarantees it executes before the async chunk scripts.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chunkLoadRecoveryScript } from "../src/lib/chunk-load-recovery-script";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_DIR = path.resolve(__dirname, "../out");
const MARKER_ID = "chunk-load-recovery";
const SCRIPT_TAG = `<script id="${MARKER_ID}">${chunkLoadRecoveryScript}</script>`;

async function collectHtmlFiles(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return collectHtmlFiles(full);
return entry.isFile() && entry.name.endsWith(".html") ? [full] : [];
}),
);
return files.flat();
}

async function main() {
try {
await fs.access(OUT_DIR);
} catch {
throw new Error(
`Output directory not found: ${OUT_DIR}. Run "next build" first.`,
);
}

const htmlFiles = await collectHtmlFiles(OUT_DIR);
if (htmlFiles.length === 0) {
throw new Error(`No HTML files found under ${OUT_DIR}.`);
}

let injected = 0;
let skipped = 0;
const missingHead: string[] = [];

for (const file of htmlFiles) {
const html = await fs.readFile(file, "utf8");

if (html.includes(`id="${MARKER_ID}"`)) {
skipped += 1;
continue;
}

const headIndex = html.indexOf("<head>");
if (headIndex === -1) {
missingHead.push(path.relative(OUT_DIR, file));
continue;
}

const insertAt = headIndex + "<head>".length;
const next = html.slice(0, insertAt) + SCRIPT_TAG + html.slice(insertAt);
await fs.writeFile(file, next);
injected += 1;
}

console.log(
`[inject-chunk-load-recovery] injected into ${injected} file(s), ` +
`skipped ${skipped} already-injected file(s).`,
);

if (missingHead.length > 0) {
throw new Error(
`No <head> found in ${missingHead.length} HTML file(s): ` +
missingHead.slice(0, 10).join(", ") +
(missingHead.length > 10 ? ", …" : ""),
);
}
}

main().catch((error) => {
console.error("[inject-chunk-load-recovery]", error);
process.exit(1);
});
Loading