@fluxpointstudios/orynq-sdk-quickstart 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @summary Quickstart contract tests.
3
+ *
4
+ * These tests pin the solo-dev sub-5-min DX contract:
5
+ *
6
+ * 1. `loadOrCreateIdentity()` is pure-local (no network), generates a fresh
7
+ * sr25519 keypair from a fresh mnemonic when the config file does not
8
+ * exist, and reloads byte-for-byte the same address on subsequent calls.
9
+ * 2. `firstTraceBundle()` produces a deterministic-shape `TraceBundle` in
10
+ * under 1 second for a tiny payload — confirms the local-trace step
11
+ * itself never blocks the 5-minute budget.
12
+ * 3. `buildExplorerUrls()` returns the trifecta of human-friendly URLs
13
+ * (gateway blob status, Polkadot.js apps explorer, raw RPC genesis)
14
+ * that solo devs need to *see* their trace after submission.
15
+ *
16
+ * Network-bound tests (faucet drip + on-chain submit) live in
17
+ * `quickstart.live.test.ts` and are gated by `ORYNQ_RUN_LIVE_TESTS=1`. They
18
+ * exercise the real preprod gateway when present, but never block CI for
19
+ * solo devs running `pnpm test` on their laptop.
20
+ */
21
+
22
+ import { describe, it, expect, beforeAll } from "vitest";
23
+ import { mkdtempSync, rmSync, existsSync } from "fs";
24
+ import { tmpdir } from "os";
25
+ import { join } from "path";
26
+ import { cryptoWaitReady } from "@polkadot/util-crypto";
27
+ import { loadOrCreateIdentity, firstTraceBundle, buildExplorerUrls } from "../index.js";
28
+ import type { OrynqIdentity, TraceBundleLite } from "../index.js";
29
+
30
+ beforeAll(async () => {
31
+ await cryptoWaitReady();
32
+ });
33
+
34
+ describe("loadOrCreateIdentity", () => {
35
+ it("generates a fresh identity when no config file exists, then reloads the same address", async () => {
36
+ const tmpDir = mkdtempSync(join(tmpdir(), "orynq-qs-test-"));
37
+ const configPath = join(tmpDir, "config.json");
38
+ try {
39
+ // First call: generate
40
+ const id1: OrynqIdentity = await loadOrCreateIdentity({ configPath });
41
+ expect(id1.address).toMatch(/^[15][a-zA-Z0-9]{45,47}$/);
42
+ expect(id1.mnemonic.split(" ").length).toBeGreaterThanOrEqual(12);
43
+ expect(id1.generatedAt).toBeTruthy();
44
+ expect(existsSync(configPath)).toBe(true);
45
+
46
+ // Second call: reload
47
+ const id2: OrynqIdentity = await loadOrCreateIdentity({ configPath });
48
+ expect(id2.address).toBe(id1.address);
49
+ expect(id2.mnemonic).toBe(id1.mnemonic);
50
+ } finally {
51
+ rmSync(tmpDir, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ it("writes the config file with 0600 permissions on POSIX systems", async () => {
56
+ if (process.platform === "win32") return; // chmod is no-op on Windows
57
+ const tmpDir = mkdtempSync(join(tmpdir(), "orynq-qs-test-"));
58
+ const configPath = join(tmpDir, "config.json");
59
+ try {
60
+ await loadOrCreateIdentity({ configPath });
61
+ const { statSync } = await import("fs");
62
+ const mode = statSync(configPath).mode & 0o777;
63
+ expect(mode).toBe(0o600);
64
+ } finally {
65
+ rmSync(tmpDir, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ it("rejects a config file with malformed contents instead of silently regenerating", async () => {
70
+ const tmpDir = mkdtempSync(join(tmpdir(), "orynq-qs-test-"));
71
+ const configPath = join(tmpDir, "config.json");
72
+ try {
73
+ const { writeFileSync } = await import("fs");
74
+ writeFileSync(configPath, "not-json-at-all");
75
+ await expect(loadOrCreateIdentity({ configPath })).rejects.toThrow(/identity/i);
76
+ } finally {
77
+ rmSync(tmpDir, { recursive: true, force: true });
78
+ }
79
+ });
80
+ });
81
+
82
+ describe("firstTraceBundle", () => {
83
+ it("produces a finalised bundle with stable shape in under 1s", async () => {
84
+ const start = Date.now();
85
+ const bundle: TraceBundleLite = await firstTraceBundle({
86
+ agentId: "qs-test-agent",
87
+ summary: "hello from quickstart tests",
88
+ });
89
+ const elapsed = Date.now() - start;
90
+
91
+ expect(elapsed).toBeLessThan(1000);
92
+ expect(bundle.rootHash).toMatch(/^[0-9a-f]{64}$/);
93
+ expect(bundle.merkleRoot).toMatch(/^[0-9a-f]{64}$/);
94
+ expect(bundle.manifestHash).toMatch(/^[0-9a-f]{64}$/);
95
+ expect(bundle.runId).toMatch(/^[0-9a-f-]{36}$/);
96
+ expect(bundle.content.length).toBeGreaterThan(0);
97
+ });
98
+
99
+ it("produces deterministic hashes for fixed inputs given a fixed timestamp", async () => {
100
+ const fixed = new Date("2026-05-14T12:00:00.000Z");
101
+ const a = await firstTraceBundle({
102
+ agentId: "fixed-agent",
103
+ summary: "fixed",
104
+ now: () => fixed,
105
+ runId: "00000000-0000-4000-8000-000000000000",
106
+ spanId: "11111111-1111-4111-8111-111111111111",
107
+ eventId: "22222222-2222-4222-8222-222222222222",
108
+ });
109
+ const b = await firstTraceBundle({
110
+ agentId: "fixed-agent",
111
+ summary: "fixed",
112
+ now: () => fixed,
113
+ runId: "00000000-0000-4000-8000-000000000000",
114
+ spanId: "11111111-1111-4111-8111-111111111111",
115
+ eventId: "22222222-2222-4222-8222-222222222222",
116
+ });
117
+ expect(a.rootHash).toBe(b.rootHash);
118
+ expect(a.merkleRoot).toBe(b.merkleRoot);
119
+ expect(a.manifestHash).toBe(b.manifestHash);
120
+ });
121
+ });
122
+
123
+ describe("buildExplorerUrls", () => {
124
+ it("returns the four explorer surfaces solo devs need to *see* their trace", () => {
125
+ const urls = buildExplorerUrls({
126
+ contentHash: "0x" + "ab".repeat(32),
127
+ blockHash: "0x" + "cd".repeat(32),
128
+ gatewayBaseUrl: "https://materios.fluxpointstudios.com/blobs",
129
+ rpcUrl: "wss://materios.fluxpointstudios.com/rpc",
130
+ });
131
+ // The SDK's upload path is `${baseUrl}/blobs/<hash>/manifest`. The
132
+ // status URL preserves that shape so it routes through the same
133
+ // nginx mount.
134
+ expect(urls.blobStatus).toBe(
135
+ "https://materios.fluxpointstudios.com/blobs/blobs/" + "ab".repeat(32) + "/status",
136
+ );
137
+ expect(urls.explorer).toContain("polkadot.js.org/apps");
138
+ expect(urls.explorer).toContain(encodeURIComponent("wss://materios.fluxpointstudios.com/rpc"));
139
+ expect(urls.explorer).toContain("cd".repeat(32));
140
+ // /chain-info and /health live on the root express app, not the
141
+ // /blobs router — we strip the /blobs prefix for those.
142
+ expect(urls.chainInfo).toBe("https://materios.fluxpointstudios.com/chain-info");
143
+ expect(urls.gatewayHealth).toBe("https://materios.fluxpointstudios.com/health");
144
+ });
145
+
146
+ it("when baseUrl omits /blobs, the same origin is used for both blob + root URLs", () => {
147
+ const urls = buildExplorerUrls({
148
+ contentHash: "ab".repeat(32),
149
+ blockHash: "cd".repeat(32),
150
+ gatewayBaseUrl: "https://my-gateway.example.com",
151
+ rpcUrl: "wss://my-rpc.example.com",
152
+ });
153
+ expect(urls.blobStatus).toBe(
154
+ "https://my-gateway.example.com/blobs/" + "ab".repeat(32) + "/status",
155
+ );
156
+ expect(urls.chainInfo).toBe("https://my-gateway.example.com/chain-info");
157
+ expect(urls.gatewayHealth).toBe("https://my-gateway.example.com/health");
158
+ });
159
+
160
+ it("strips 0x from the gateway URL path but preserves 0x on the polkadot.js apps URL", () => {
161
+ const urls = buildExplorerUrls({
162
+ contentHash: "0x" + "ab".repeat(32),
163
+ blockHash: "0x" + "cd".repeat(32),
164
+ gatewayBaseUrl: "https://gw.example.com",
165
+ rpcUrl: "wss://rpc.example.com",
166
+ });
167
+ // Gateway paths are bare hex (no 0x), to avoid double-prefix
168
+ // collisions in the route matcher.
169
+ expect(urls.blobStatus).not.toContain("/0x");
170
+ // Polkadot.js apps explorer wants the 0x prefix. The query path is
171
+ // #/explorer/query/0x<blockhash>
172
+ expect(urls.explorer).toContain("/explorer/query/0x" + "cd".repeat(32));
173
+ });
174
+ });
@@ -0,0 +1,299 @@
1
+ /**
2
+ * @summary One-call solo-dev bootstrap.
3
+ *
4
+ * `bootstrapAndTrace()` does ALL of:
5
+ *
6
+ * 1. `loadOrCreateIdentity()` — fresh sr25519 keypair on first run.
7
+ * 2. `requestFaucet()` — free-tier MATRA drip on the gateway.
8
+ * 3. `firstTraceBundle()` — build + finalise a "hello" trace.
9
+ * 4. `submitCertifiedReceipt()` — upload blob + submit receipt on chain.
10
+ * 5. `buildExplorerUrls()` — compose the URLs the dev needs to click.
11
+ *
12
+ * Compared to the manual flow (e2e-flow.ts), this collapses ~10 lines of
13
+ * MateriosProvider config + 30 lines of error handling into a single
14
+ * `await bootstrapAndTrace({})`. Defaults target Materios preprod; pass
15
+ * env-driven overrides to point at a different chain.
16
+ *
17
+ * Designed to surface, not paper over, real failures. Faucet cooldown is
18
+ * NOT retried; certification timeout is NOT swallowed. The expectation is
19
+ * that a fresh dev sees a green path on first run and a clear error
20
+ * message otherwise — never a hung "loading...".
21
+ */
22
+
23
+ import { createHash } from "crypto";
24
+ import { Keyring } from "@polkadot/keyring";
25
+ import { cryptoWaitReady } from "@polkadot/util-crypto";
26
+ import {
27
+ MateriosProvider,
28
+ submitReceipt,
29
+ uploadBlobs,
30
+ prepareBlobData,
31
+ waitForCertification,
32
+ waitForMotra,
33
+ } from "@fluxpointstudios/orynq-sdk-anchors-materios";
34
+
35
+ import { loadOrCreateIdentity } from "./identity.js";
36
+ import type { OrynqIdentity } from "./identity.js";
37
+ import { firstTraceBundle } from "./trace.js";
38
+ import type { TraceBundleLite } from "./trace.js";
39
+ import { requestFaucet } from "./faucet.js";
40
+ import type { FaucetDripResult } from "./faucet.js";
41
+ import { buildExplorerUrls } from "./explorer.js";
42
+ import type { ExplorerUrls } from "./explorer.js";
43
+
44
+ export const DEFAULT_RPC_URL = "wss://materios.fluxpointstudios.com/rpc";
45
+ export const DEFAULT_GATEWAY_URL = "https://materios.fluxpointstudios.com/blobs";
46
+ export const DEFAULT_AGENT_ID = "orynq-quickstart";
47
+
48
+ export interface BootstrapAndTraceOptions {
49
+ /** Path to the on-disk identity (defaults to `~/.orynq/config.json`). */
50
+ configPath?: string | undefined;
51
+ /** Substrate WS RPC URL. Defaults to preprod. */
52
+ rpcUrl?: string | undefined;
53
+ /** Blob-gateway base URL (with or without /blobs suffix). */
54
+ gatewayBaseUrl?: string | undefined;
55
+ /** AgentId stamped on the trace bundle. */
56
+ agentId?: string | undefined;
57
+ /**
58
+ * One-liner observation stamped as the public event of the first trace.
59
+ * Defaults to a self-describing message that includes the env's wall
60
+ * clock, so the trace is identifiable on the explorer.
61
+ */
62
+ summary?: string | undefined;
63
+ /**
64
+ * Wait this long for the cert-daemon committee to certify the receipt.
65
+ * Defaults to 120 s. Set to 0 to skip the cert wait entirely (returns
66
+ * as soon as the receipt is on chain). On preprod the cert window is
67
+ * ~30-90 s depending on attestor load + finality gap; 120 s gives the
68
+ * happy path a comfortable margin without keeping the dev hanging.
69
+ */
70
+ certTimeoutMs?: number | undefined;
71
+ /**
72
+ * If true, a cert timeout is treated as success — the receipt is on
73
+ * chain, the explorer URL is printed, and the cert is just "still
74
+ * pending". Defaults to true so a fresh dev sees a working trace URL
75
+ * even when the committee is mid-vote.
76
+ */
77
+ treatCertTimeoutAsSuccess?: boolean | undefined;
78
+ /**
79
+ * Hook fired after each major step. Lets the CLI render a live status
80
+ * line without coupling business logic to console.log.
81
+ */
82
+ onProgress?: ((step: BootstrapStep) => void) | undefined;
83
+ /**
84
+ * If true, the faucet step is skipped — useful when the dev has
85
+ * pre-funded their address via Discord faucet, an existing wallet, etc.
86
+ * Defaults to false.
87
+ */
88
+ skipFaucet?: boolean | undefined;
89
+ }
90
+
91
+ export type BootstrapStep =
92
+ | { kind: "identity-loaded"; identity: OrynqIdentity }
93
+ | { kind: "faucet-result"; result: FaucetDripResult }
94
+ | { kind: "waiting-for-motra" }
95
+ | { kind: "motra-ready"; balance: bigint }
96
+ | { kind: "trace-built"; bundle: TraceBundleLite }
97
+ | { kind: "blob-uploaded"; contentHash: string }
98
+ | { kind: "receipt-submitted"; receiptId: string; blockHash: string }
99
+ | { kind: "certified"; certHash: string }
100
+ | { kind: "explorer-ready"; urls: ExplorerUrls };
101
+
102
+ export interface BootstrapAndTraceResult {
103
+ identity: OrynqIdentity;
104
+ bundle: TraceBundleLite;
105
+ receiptId: string;
106
+ blockHash: string;
107
+ certHash?: string;
108
+ urls: ExplorerUrls;
109
+ /** Wall-clock duration from start of bootstrap to URLs available. */
110
+ elapsedMs: number;
111
+ }
112
+
113
+ /**
114
+ * Bootstrap a fresh dev to a chain-anchored first trace.
115
+ *
116
+ * Steps (each emits a progress event via `onProgress`):
117
+ * 1. load-or-create identity ~50 ms
118
+ * 2. faucet drip (skipped if funded) ~2 s
119
+ * 3. wait for MOTRA to generate ~10-30 s (chain block production)
120
+ * 4. build local trace bundle ~10 ms
121
+ * 5. upload blob + submit receipt ~6 s (1 block)
122
+ * 6. await certification (optional) ~10-30 s (committee voting)
123
+ * 7. compose explorer URLs instant
124
+ *
125
+ * Total budget on a fresh address: 30-60 s wall-clock. With faucet skipped
126
+ * and MOTRA already in hand: <10 s.
127
+ */
128
+ export async function bootstrapAndTrace(
129
+ opts: BootstrapAndTraceOptions = {},
130
+ ): Promise<BootstrapAndTraceResult> {
131
+ await cryptoWaitReady();
132
+ const start = Date.now();
133
+ const onProgress = opts.onProgress ?? (() => {});
134
+
135
+ // Step 1: identity
136
+ const identity = await loadOrCreateIdentity({
137
+ ...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
138
+ });
139
+ onProgress({ kind: "identity-loaded", identity });
140
+
141
+ const gateway = opts.gatewayBaseUrl ?? DEFAULT_GATEWAY_URL;
142
+ const rpcUrl = opts.rpcUrl ?? DEFAULT_RPC_URL;
143
+
144
+ // Step 2: faucet (best-effort; idempotent under success + already-funded)
145
+ if (!opts.skipFaucet) {
146
+ const faucetResult = await requestFaucet({ address: identity.address, gatewayBaseUrl: gateway });
147
+ onProgress({ kind: "faucet-result", result: faucetResult });
148
+ if (faucetResult.kind === "error" || faucetResult.kind === "cooldown") {
149
+ throw new Error(
150
+ `faucet drip failed for ${identity.address}: ${faucetResult.kind} — ${faucetResult.message ?? "unknown"}. ` +
151
+ `Workarounds: (1) skipFaucet:true if you've already funded ${identity.address} elsewhere; ` +
152
+ `(2) retry in a few minutes if cooldown; (3) ask in Discord (#materios) for a top-up.`,
153
+ );
154
+ }
155
+ }
156
+
157
+ // Step 3: connect to chain, wait for MOTRA
158
+ const provider = new MateriosProvider({ rpcUrl, signerUri: identity.mnemonic });
159
+ await provider.connect();
160
+ try {
161
+ onProgress({ kind: "waiting-for-motra" });
162
+ // 1 MATRA at 6-dec = 1_000_000 units. We need enough MOTRA (the fee
163
+ // currency, auto-generated from MATRA at ~6.94e-12 MOTRA per MATRA-block)
164
+ // to cover one submit_receipt. The default min in waitForMotra
165
+ // (1.5e12) is empirically the floor that covers one extrinsic + a
166
+ // chain-tx + a chunk upload.
167
+ const balance = await waitForMotra(provider, undefined, { timeoutMs: 90_000 });
168
+ onProgress({ kind: "motra-ready", balance });
169
+
170
+ // Step 4: build the trace
171
+ const bundle = await firstTraceBundle({
172
+ agentId: opts.agentId ?? DEFAULT_AGENT_ID,
173
+ summary:
174
+ opts.summary ??
175
+ `first trace via orynq-sdk-quickstart at ${new Date().toISOString()}`,
176
+ });
177
+ onProgress({ kind: "trace-built", bundle });
178
+
179
+ // Step 5: upload blob + submit receipt + (optionally) wait for cert.
180
+ //
181
+ // We call the three SDK primitives explicitly instead of
182
+ // `submitCertifiedReceipt()` so a cert-poll timeout doesn't lose the
183
+ // submit result. The on-chain receipt + blockHash are already known
184
+ // by then — we just want to surface "submitted, pending cert" cleanly.
185
+ const keypair = provider.getKeypair();
186
+ const contentBuf = Buffer.from(bundle.content, "utf-8");
187
+ const contentHash = bundle.manifestHash; // canonical content == addressable blob
188
+ const certTimeoutMs = opts.certTimeoutMs ?? 120_000;
189
+ const treatCertTimeoutAsSuccess = opts.treatCertTimeoutAsSuccess !== false;
190
+
191
+ // 5a. Derive the receiptId the same way submit_receipt does: it's
192
+ // sha256 of the (binary) contentHash. The blob-gateway routes
193
+ // all chunk + manifest paths under this receiptId so they must
194
+ // match the on-chain id byte-for-byte.
195
+ const contentHashHex = contentHash.startsWith("0x") ? contentHash.slice(2) : contentHash;
196
+ const receiptIdHex = "0x" + createHash("sha256")
197
+ .update(Buffer.from(contentHashHex, "hex"))
198
+ .digest("hex");
199
+
200
+ // 5b. Upload the blob via sig-only auth (no API key required).
201
+ const { manifest, chunks } = prepareBlobData(receiptIdHex, contentBuf);
202
+ const uploadResult = await uploadBlobs(
203
+ receiptIdHex,
204
+ manifest,
205
+ chunks,
206
+ {
207
+ baseUrl: gateway,
208
+ signerKeypair: {
209
+ address: keypair.address,
210
+ sign: (msg: Uint8Array) => keypair.sign(msg),
211
+ },
212
+ },
213
+ );
214
+ if (!uploadResult.success) {
215
+ throw new Error(`blob upload failed: ${uploadResult.error ?? "unknown"}`);
216
+ }
217
+
218
+ // 5c. Submit the on-chain receipt. Pass receiptId explicitly so the
219
+ // gateway-side blob path + the on-chain receipt agree on the key
220
+ // (the SDK's default derivation matches what we computed above).
221
+ const submitResult = await submitReceipt(provider, {
222
+ receiptId: receiptIdHex,
223
+ contentHash,
224
+ rootHash: bundle.rootHash,
225
+ manifestHash: uploadResult.storageLocatorHash ?? bundle.manifestHash,
226
+ });
227
+ onProgress({
228
+ kind: "receipt-submitted",
229
+ receiptId: submitResult.receiptId,
230
+ blockHash: submitResult.blockHash,
231
+ });
232
+
233
+ // 5c. Optionally wait for cert. Timeouts are surfaced as "submitted,
234
+ // pending cert" rather than a hard failure so the dev still sees
235
+ // a usable URL on a slow committee.
236
+ let certHash: string | undefined;
237
+ if (certTimeoutMs > 0) {
238
+ try {
239
+ const certResult = await waitForCertification(
240
+ provider,
241
+ submitResult.receiptId,
242
+ { timeoutMs: certTimeoutMs },
243
+ );
244
+ certHash = certResult.certHash;
245
+ onProgress({ kind: "certified", certHash });
246
+ } catch (err) {
247
+ const msg = err instanceof Error ? err.message : String(err);
248
+ const isCertTimeout = /Certification timeout/i.test(msg);
249
+ if (!(isCertTimeout && treatCertTimeoutAsSuccess)) {
250
+ throw err;
251
+ }
252
+ // Cert pending — proceed with the URLs we have.
253
+ }
254
+ }
255
+
256
+ // The gateway routes blob status by the same key the SDK uploaded
257
+ // under — that's the receiptId, NOT the content sha256. Pass it as
258
+ // `contentHash` to buildExplorerUrls (the parameter name carries the
259
+ // legacy meaning from the gateway route).
260
+ const urls = buildExplorerUrls({
261
+ contentHash: receiptIdHex,
262
+ blockHash: submitResult.blockHash,
263
+ gatewayBaseUrl: gateway,
264
+ rpcUrl,
265
+ });
266
+ onProgress({ kind: "explorer-ready", urls });
267
+
268
+ const result: BootstrapAndTraceResult = {
269
+ identity,
270
+ bundle,
271
+ receiptId: submitResult.receiptId,
272
+ blockHash: submitResult.blockHash,
273
+ urls,
274
+ elapsedMs: Date.now() - start,
275
+ };
276
+ if (certHash) {
277
+ result.certHash = certHash;
278
+ }
279
+ return result;
280
+ } finally {
281
+ await provider.disconnect().catch(() => {
282
+ // Swallow disconnect errors — we already have the result the caller
283
+ // wanted. Surfacing this would mask the real (successful) outcome.
284
+ // The provider's WS will tear itself down on process exit anyway.
285
+ });
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Standalone helper: spin up a `Keyring` from a mnemonic. Exposed so the
291
+ * CLI can re-derive an address from the saved config without pulling in
292
+ * the full bootstrap path.
293
+ */
294
+ export async function deriveAddress(mnemonic: string, ss58Format = 42): Promise<string> {
295
+ await cryptoWaitReady();
296
+ const keyring = new Keyring({ type: "sr25519", ss58Format });
297
+ return keyring.addFromUri(mnemonic).address;
298
+ }
299
+
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @summary Compose the user-facing URLs that close the loop on "first trace".
3
+ *
4
+ * The DX requirement (#175) is: after submission, the SDK MUST print a URL
5
+ * the developer can click and see something. Materios doesn't yet ship a
6
+ * native trace-detail explorer page, so we compose three known-good URLs:
7
+ *
8
+ * 1. `blobStatus` — `${gateway}/blobs/<contentHash>/status` — gateway-
9
+ * side status of the receipt (HTTP 200 + JSON,
10
+ * browser-renderable).
11
+ * 2. `explorer` — Polkadot.js apps explorer pre-pointed at the
12
+ * submission block. Shows the on-chain extrinsic
13
+ * with full SCALE-decoded args.
14
+ * 3. `chainInfo` — `${gateway}/chain-info` — JSON with the live
15
+ * genesis hash + best block, useful as a sanity
16
+ * check that the gateway is the chain you think
17
+ * it is.
18
+ * 4. `gatewayHealth` — `${gateway}/health` — cluster-health summary
19
+ * (cert-daemon, anchor-worker, storage usage).
20
+ *
21
+ * A follow-up will replace `explorer` with a first-party
22
+ * `https://materios.fluxpointstudios.com/trace/<contentHash>` page (filed
23
+ * separately) — at which point the field swaps and the rest of the SDK
24
+ * surface keeps working.
25
+ */
26
+
27
+ export interface BuildExplorerUrlsInput {
28
+ /**
29
+ * Hex content hash, with or without `0x` prefix. Used to build the
30
+ * gateway status URL.
31
+ */
32
+ contentHash: string;
33
+ /**
34
+ * Hex block hash from the on-chain submission, with or without `0x`.
35
+ * Used to build the Polkadot.js apps query URL.
36
+ */
37
+ blockHash: string;
38
+ /**
39
+ * Gateway base URL. Accepts either `https://host` or `https://host/blobs`
40
+ * — the function normalises so callers don't have to remember which
41
+ * variant the env exports.
42
+ */
43
+ gatewayBaseUrl: string;
44
+ /**
45
+ * Substrate websocket RPC URL. Used to build the Polkadot.js apps
46
+ * pre-pointed-at-this-chain URL.
47
+ */
48
+ rpcUrl: string;
49
+ }
50
+
51
+ export interface ExplorerUrls {
52
+ /** Gateway blob status JSON. */
53
+ blobStatus: string;
54
+ /** Polkadot.js apps pre-pointed at this chain's submission block. */
55
+ explorer: string;
56
+ /** Gateway chain-info endpoint (genesis + best block). */
57
+ chainInfo: string;
58
+ /** Gateway top-level health roll-up. */
59
+ gatewayHealth: string;
60
+ }
61
+
62
+ /**
63
+ * Strip an optional `0x` prefix from a hex string. Returns the cleaned
64
+ * hex if present, otherwise the original string unchanged.
65
+ */
66
+ function strip0x(hex: string): string {
67
+ return hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex;
68
+ }
69
+
70
+ /**
71
+ * Normalise the gateway base URL.
72
+ *
73
+ * The blob-gateway's express routes are mounted at `/blobs/:contentHash/...`,
74
+ * and the gateway is exposed both directly AND via an nginx reverse-proxy
75
+ * that also prefixes `/blobs`. In production, the SDK is configured with
76
+ * `baseUrl="https://host/blobs"`, and constructs upload URLs like
77
+ * `${baseUrl}/blobs/<hash>/manifest` — i.e. the **upload path keeps both
78
+ * "/blobs" segments**. To produce a working *human-facing* status URL
79
+ * here, we must preserve the same shape.
80
+ *
81
+ * Accepts:
82
+ * - `https://host` — `originBase = host`
83
+ * - `https://host/blobs` — `originBase = host` (the /blobs is the
84
+ * nginx prefix; we keep it for blob URLs but
85
+ * strip it for the top-level /chain-info,
86
+ * /health endpoints which mount on the
87
+ * gateway's root express app, not the blobs
88
+ * router).
89
+ *
90
+ * Returns both the normalised forms callers need:
91
+ * - `blobsBase` the URL prefix the SDK already uses for /blobs/<h>/...
92
+ * uploads. Status URLs share this prefix.
93
+ * - `rootBase` the bare origin for /chain-info, /health.
94
+ */
95
+ function normaliseGatewayBase(base: string): { blobsBase: string; rootBase: string } {
96
+ let s = base.trim();
97
+ if (s.endsWith("/")) s = s.slice(0, -1);
98
+ if (s.endsWith("/blobs")) {
99
+ const rootBase = s.slice(0, -"/blobs".length);
100
+ return { blobsBase: s, rootBase };
101
+ }
102
+ // No /blobs in baseUrl — assume nginx mounts gateway at the root.
103
+ // Blob URLs and root URLs share the same origin.
104
+ return { blobsBase: s, rootBase: s };
105
+ }
106
+
107
+ export function buildExplorerUrls(input: BuildExplorerUrlsInput): ExplorerUrls {
108
+ const contentHash = strip0x(input.contentHash);
109
+ const blockHash = strip0x(input.blockHash);
110
+ const { blobsBase, rootBase } = normaliseGatewayBase(input.gatewayBaseUrl);
111
+
112
+ // Polkadot.js apps URL format:
113
+ // https://polkadot.js.org/apps/?rpc=<encoded-ws-url>#/explorer/query/<blockHash>
114
+ // The leading `?rpc=` lives BEFORE the hash because the apps router
115
+ // reads the query string ahead of the hash route.
116
+ const encodedRpc = encodeURIComponent(input.rpcUrl);
117
+ const explorer = `https://polkadot.js.org/apps/?rpc=${encodedRpc}#/explorer/query/0x${blockHash}`;
118
+
119
+ // Match the upload-side path shape: ${blobsBase}/blobs/<hash>/...
120
+ // (The SDK's `uploadBlobs()` does `${baseUrl}/blobs/<hash>/manifest`.)
121
+ return {
122
+ blobStatus: `${blobsBase}/blobs/${contentHash}/status`,
123
+ explorer,
124
+ chainInfo: `${rootBase}/chain-info`,
125
+ gatewayHealth: `${rootBase}/health`,
126
+ };
127
+ }