@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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/bin/orynq.mjs +306 -0
- package/dist/index.cjs +392 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +423 -0
- package/dist/index.d.ts +423 -0
- package/dist/index.js +380 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
- package/src/__tests__/quickstart-live.test.ts +54 -0
- package/src/__tests__/quickstart.test.ts +174 -0
- package/src/bootstrap.ts +299 -0
- package/src/explorer.ts +127 -0
- package/src/faucet.ts +158 -0
- package/src/identity.ts +193 -0
- package/src/index.ts +67 -0
- package/src/trace.ts +210 -0
|
@@ -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
|
+
});
|
package/src/bootstrap.ts
ADDED
|
@@ -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
|
+
|
package/src/explorer.ts
ADDED
|
@@ -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
|
+
}
|