@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/src/faucet.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @summary Free-tier MATRA faucet client.
3
+ *
4
+ * The Materios preprod blob-gateway exposes `POST /blobs/faucet/drip` (and
5
+ * `POST /faucet/drip` mounted at the same handler). One-shot per SS58
6
+ * address, IP-cooldown 5 min on the un-prefixed path. The dripped MATRA
7
+ * generates MOTRA (fee currency) over the next few blocks — that's how a
8
+ * fresh dev pays for their first `submit_receipt` extrinsic without an
9
+ * out-of-band funding step.
10
+ *
11
+ * Returns a discriminated union so callers can branch on `kind` without
12
+ * sniffing error messages.
13
+ */
14
+
15
+ export interface FaucetDripSuccess {
16
+ kind: "success";
17
+ txHash: string;
18
+ amount: string;
19
+ message: string;
20
+ }
21
+
22
+ export interface FaucetDripAlreadyFunded {
23
+ kind: "already-funded";
24
+ drippedAtMs: number;
25
+ }
26
+
27
+ export interface FaucetDripCooldown {
28
+ kind: "cooldown";
29
+ retryAfterMs: number;
30
+ message: string;
31
+ }
32
+
33
+ export interface FaucetDripError {
34
+ kind: "error";
35
+ status: number;
36
+ message: string;
37
+ }
38
+
39
+ export type FaucetDripResult =
40
+ | FaucetDripSuccess
41
+ | FaucetDripAlreadyFunded
42
+ | FaucetDripCooldown
43
+ | FaucetDripError;
44
+
45
+ export interface RequestFaucetOptions {
46
+ /** SS58 address to drip MATRA into. */
47
+ address: string;
48
+ /**
49
+ * Gateway base URL. Accepts either `https://host` or `https://host/blobs`.
50
+ * The /blobs/-prefixed faucet path is preferred (per-address ledger);
51
+ * the bare /faucet path adds an IP-level 5-min cooldown so we leave it
52
+ * alone here.
53
+ */
54
+ gatewayBaseUrl: string;
55
+ /**
56
+ * Optional fetch impl injection (for tests + Cloudflare Workers).
57
+ * Defaults to the global `fetch`.
58
+ */
59
+ fetchImpl?: typeof fetch | undefined;
60
+ /**
61
+ * Optional AbortSignal — propagated to the underlying fetch so callers
62
+ * can wire up a Ctrl-C handler.
63
+ */
64
+ signal?: AbortSignal | undefined;
65
+ }
66
+
67
+ /**
68
+ * Strip a trailing slash + trailing /blobs from a gateway base URL,
69
+ * leaving the bare origin. The faucet route is mounted on the express
70
+ * root (not the /blobs router) but is reachable via the nginx
71
+ * reverse-proxy that prefixes /blobs — so the publicly-working URL
72
+ * needs the /blobs segment exactly once.
73
+ */
74
+ function normaliseToRoot(base: string): string {
75
+ let s = base.trim();
76
+ if (s.endsWith("/")) s = s.slice(0, -1);
77
+ if (s.endsWith("/blobs")) s = s.slice(0, -"/blobs".length);
78
+ return s;
79
+ }
80
+
81
+ /**
82
+ * Drip MATRA to a fresh SS58 address.
83
+ *
84
+ * Idempotent at the caller's level: if the address has already been
85
+ * dripped (per-address ledger), returns `kind: "already-funded"` instead
86
+ * of throwing — the caller can treat both `success` and `already-funded`
87
+ * as "we have MATRA, proceed".
88
+ */
89
+ export async function requestFaucet(
90
+ opts: RequestFaucetOptions,
91
+ ): Promise<FaucetDripResult> {
92
+ const f = opts.fetchImpl ?? fetch;
93
+ const rootBase = normaliseToRoot(opts.gatewayBaseUrl);
94
+ // /blobs/faucet/drip == the per-address-ledger faucet (preferred).
95
+ // /faucet/drip == the IP-cooldown variant (we avoid it).
96
+ const url = `${rootBase}/blobs/faucet/drip`;
97
+
98
+ const fetchOpts: RequestInit = {
99
+ method: "POST",
100
+ headers: { "content-type": "application/json" },
101
+ body: JSON.stringify({ address: opts.address }),
102
+ };
103
+ if (opts.signal) {
104
+ fetchOpts.signal = opts.signal;
105
+ }
106
+ const res = await f(url, fetchOpts);
107
+
108
+ const text = await res.text();
109
+ let json: Record<string, unknown> = {};
110
+ try {
111
+ json = text ? (JSON.parse(text) as Record<string, unknown>) : {};
112
+ } catch {
113
+ // Non-JSON body — surface the raw text as the error message.
114
+ return {
115
+ kind: "error",
116
+ status: res.status,
117
+ message: text.slice(0, 256),
118
+ };
119
+ }
120
+
121
+ if (res.ok && json["success"] === true) {
122
+ return {
123
+ kind: "success",
124
+ txHash: String(json["tx_hash"] ?? ""),
125
+ amount: String(json["amount"] ?? ""),
126
+ message: String(json["message"] ?? "MATRA dripped"),
127
+ };
128
+ }
129
+
130
+ // Per-address dedup: 409 + "Address already received a drip" + dripped_at.
131
+ if (res.status === 409 && typeof json["dripped_at"] === "number") {
132
+ return { kind: "already-funded", drippedAtMs: Number(json["dripped_at"]) };
133
+ }
134
+
135
+ // IP-level cooldown (the un-prefixed /faucet/drip path uses this).
136
+ if (
137
+ res.status === 429 ||
138
+ /cooldown/i.test(String(json["error"] ?? ""))
139
+ ) {
140
+ const retryAfterMs =
141
+ typeof json["cooldown_ms"] === "number"
142
+ ? Number(json["cooldown_ms"])
143
+ : typeof json["retry_after_seconds"] === "number"
144
+ ? Number(json["retry_after_seconds"]) * 1000
145
+ : 0;
146
+ return {
147
+ kind: "cooldown",
148
+ retryAfterMs,
149
+ message: String(json["error"] ?? "Faucet cooldown active"),
150
+ };
151
+ }
152
+
153
+ return {
154
+ kind: "error",
155
+ status: res.status,
156
+ message: String(json["error"] ?? text.slice(0, 256) ?? "Unknown faucet error"),
157
+ };
158
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @summary Local sr25519 identity bootstrap for solo-dev quickstart.
3
+ *
4
+ * Generates a fresh BIP39 mnemonic on first run, derives an sr25519 keypair,
5
+ * and persists the mnemonic to `~/.orynq/config.json` (or any caller-supplied
6
+ * path) with 0600 permissions on POSIX systems. Subsequent calls reload the
7
+ * same identity so the address stays stable across processes.
8
+ *
9
+ * This is intentionally pure-local: no network, no chain RPC, no faucet.
10
+ * Anchoring + faucet drip belong in `bootstrap.ts` so callers who already
11
+ * have an identity can skip identity generation entirely.
12
+ *
13
+ * Trust model: the mnemonic on disk is treated like any other developer
14
+ * secret. The config file is created with 0600 perms; an explicit warning is
15
+ * emitted via the returned `OrynqIdentity.warnings` array when the env
16
+ * suggests a shared filesystem (which is reserved for a follow-up — kept
17
+ * as `warnings: []` today so the public shape stays stable).
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "fs";
21
+ import { dirname } from "path";
22
+ import { homedir } from "os";
23
+ import {
24
+ cryptoWaitReady,
25
+ mnemonicGenerate,
26
+ mnemonicValidate,
27
+ } from "@polkadot/util-crypto";
28
+ import { Keyring } from "@polkadot/keyring";
29
+
30
+ /**
31
+ * Solo-dev identity loaded from disk or freshly generated.
32
+ *
33
+ * Fields:
34
+ * - `mnemonic` BIP39 12-word seed phrase. Required to sign on-chain
35
+ * txs and blob-gateway uploads. Treat as a secret.
36
+ * - `address` sr25519 SS58 address derived from `mnemonic`. Safe to
37
+ * log; this is the public chain identity.
38
+ * - `generatedAt` ISO timestamp of original generation.
39
+ * - `configPath` Where the identity is persisted.
40
+ * - `freshlyGenerated` True iff this call generated a new mnemonic (vs
41
+ * reloading an existing one). Lets the CLI print "saved
42
+ * new identity to..." only on the first run.
43
+ * - `warnings` Non-fatal advisories from the loader. Empty today;
44
+ * reserved for shared-FS / world-readable-perm checks.
45
+ */
46
+ export interface OrynqIdentity {
47
+ mnemonic: string;
48
+ address: string;
49
+ generatedAt: string;
50
+ configPath: string;
51
+ freshlyGenerated: boolean;
52
+ warnings: string[];
53
+ }
54
+
55
+ export interface LoadOrCreateIdentityOptions {
56
+ /**
57
+ * Path to the persistent identity file. Defaults to
58
+ * `${HOME}/.orynq/config.json`. The parent directory is created
59
+ * recursively if it does not exist.
60
+ */
61
+ configPath?: string | undefined;
62
+
63
+ /**
64
+ * SS58 prefix for the encoded address. Defaults to 42 (generic Substrate).
65
+ * Materios uses 42 in v6 preprod; pass a different value here if you're
66
+ * targeting a chain with a custom prefix.
67
+ */
68
+ ss58Format?: number | undefined;
69
+ }
70
+
71
+ /**
72
+ * Default config-file location: `~/.orynq/config.json`.
73
+ *
74
+ * Exposed so other code (`bootstrap.ts`, the CLI) can reference the same
75
+ * default without duplicating the homedir join.
76
+ */
77
+ export function defaultConfigPath(): string {
78
+ return `${homedir()}/.orynq/config.json`;
79
+ }
80
+
81
+ interface OnDiskConfig {
82
+ version: 1;
83
+ mnemonic: string;
84
+ address: string;
85
+ generatedAt: string;
86
+ }
87
+
88
+ /**
89
+ * Load an existing identity from `configPath`, or generate + persist a new
90
+ * one if the file does not exist.
91
+ *
92
+ * Throws if the config file exists but cannot be parsed — better to fail
93
+ * loudly than silently regenerate and orphan whatever identity used to be
94
+ * there (and any MATRA balance on it).
95
+ */
96
+ export async function loadOrCreateIdentity(
97
+ opts: LoadOrCreateIdentityOptions = {},
98
+ ): Promise<OrynqIdentity> {
99
+ await cryptoWaitReady();
100
+
101
+ const configPath = opts.configPath ?? defaultConfigPath();
102
+ const ss58Format = opts.ss58Format ?? 42;
103
+ const warnings: string[] = [];
104
+
105
+ if (existsSync(configPath)) {
106
+ let parsed: OnDiskConfig;
107
+ try {
108
+ const raw = readFileSync(configPath, "utf-8");
109
+ parsed = JSON.parse(raw) as OnDiskConfig;
110
+ } catch (err) {
111
+ const msg = err instanceof Error ? err.message : String(err);
112
+ throw new Error(
113
+ `orynq identity config at ${configPath} is corrupt and cannot be parsed: ${msg}. ` +
114
+ `Inspect the file by hand — do NOT delete it without first checking whether the ` +
115
+ `mnemonic inside is still recoverable. If you want a fresh identity, move the file ` +
116
+ `aside (e.g. mv ${configPath} ${configPath}.broken) and rerun.`,
117
+ );
118
+ }
119
+ if (
120
+ !parsed ||
121
+ typeof parsed !== "object" ||
122
+ typeof parsed.mnemonic !== "string" ||
123
+ typeof parsed.address !== "string"
124
+ ) {
125
+ throw new Error(
126
+ `orynq identity config at ${configPath} is missing required fields (mnemonic, address). ` +
127
+ `File contents may be from an older or unrelated tool. Move it aside and rerun.`,
128
+ );
129
+ }
130
+ if (!mnemonicValidate(parsed.mnemonic)) {
131
+ throw new Error(
132
+ `orynq identity config at ${configPath} has an invalid mnemonic. ` +
133
+ `Move it aside and rerun, or restore from your secure backup.`,
134
+ );
135
+ }
136
+ const keyring = new Keyring({ type: "sr25519", ss58Format });
137
+ const pair = keyring.addFromUri(parsed.mnemonic);
138
+ if (pair.address !== parsed.address) {
139
+ // Likely an SS58 prefix mismatch between when it was written and now.
140
+ // Re-encode at the caller-specified prefix and continue, but warn.
141
+ warnings.push(
142
+ `address re-encoded under ss58Format=${ss58Format} (config had ${parsed.address})`,
143
+ );
144
+ }
145
+ return {
146
+ mnemonic: parsed.mnemonic,
147
+ address: pair.address,
148
+ generatedAt: parsed.generatedAt,
149
+ configPath,
150
+ freshlyGenerated: false,
151
+ warnings,
152
+ };
153
+ }
154
+
155
+ // No config — generate fresh.
156
+ const mnemonic = mnemonicGenerate(12);
157
+ const keyring = new Keyring({ type: "sr25519", ss58Format });
158
+ const pair = keyring.addFromUri(mnemonic);
159
+ const generatedAt = new Date().toISOString();
160
+
161
+ const config: OnDiskConfig = {
162
+ version: 1,
163
+ mnemonic,
164
+ address: pair.address,
165
+ generatedAt,
166
+ };
167
+
168
+ mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
169
+ // Set the file mode at creation time via the `mode` option (POSIX
170
+ // O_CREAT honors it). The previous post-create `chmodSync` left the
171
+ // file briefly world-readable in the window between write and chmod —
172
+ // for the default `~/.orynq/config.json` path the 0o700 parent dir
173
+ // mitigates, but for env-var paths whose parent dir pre-exists at
174
+ // 0o755 (e.g. /tmp/devkey.json) a co-tenant could race the chmod
175
+ // and read the mnemonic. Setting mode at open(2) closes that window.
176
+ // Sec-review finding: PR #54, MEDIUM, 9/10 confidence.
177
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
178
+ if (process.platform !== "win32") {
179
+ // Defense-in-depth — handles the case where the file already existed
180
+ // and `writeFileSync` overwrote without re-applying mode. (Per Node
181
+ // docs, `mode` is ignored when the file already exists.)
182
+ chmodSync(configPath, 0o600);
183
+ }
184
+
185
+ return {
186
+ mnemonic,
187
+ address: pair.address,
188
+ generatedAt,
189
+ configPath,
190
+ freshlyGenerated: true,
191
+ warnings,
192
+ };
193
+ }
package/src/index.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @fluxpointstudios/orynq-sdk-quickstart
3
+ *
4
+ * Solo-developer DX surface. Get from `npm install` to a chain-anchored
5
+ * first trace in under 5 minutes — no signer URI to manage, no wallet to
6
+ * seed, no Cardano addresses to look up.
7
+ *
8
+ * Three layers:
9
+ *
10
+ * - **CLI** (`bin/orynq.mjs`): `orynq init`, `orynq trace`,
11
+ * `orynq whoami`, `orynq status`.
12
+ * - **One-call API**: `bootstrapAndTrace()` — identity,
13
+ * faucet, submit, certify, URL.
14
+ * - **Primitives**: `loadOrCreateIdentity`,
15
+ * `firstTraceBundle`, `requestFaucet`,
16
+ * `buildExplorerUrls`. Mix and match
17
+ * when you're past the hello-world tier.
18
+ *
19
+ * All primitives are pure ESM, zero side-effects on import. The first
20
+ * filesystem write happens only when you call into `loadOrCreateIdentity`
21
+ * (or any helper that wraps it), so this package is safe to require()
22
+ * from a Cloudflare Worker or a Vite client bundle.
23
+ */
24
+
25
+ export {
26
+ loadOrCreateIdentity,
27
+ defaultConfigPath,
28
+ } from "./identity.js";
29
+ export type {
30
+ OrynqIdentity,
31
+ LoadOrCreateIdentityOptions,
32
+ } from "./identity.js";
33
+
34
+ export { firstTraceBundle } from "./trace.js";
35
+ export type {
36
+ TraceBundleLite,
37
+ FirstTraceBundleOptions,
38
+ DeterministicHooks,
39
+ } from "./trace.js";
40
+
41
+ export { requestFaucet } from "./faucet.js";
42
+ export type {
43
+ FaucetDripResult,
44
+ FaucetDripSuccess,
45
+ FaucetDripAlreadyFunded,
46
+ FaucetDripCooldown,
47
+ FaucetDripError,
48
+ RequestFaucetOptions,
49
+ } from "./faucet.js";
50
+
51
+ export { buildExplorerUrls } from "./explorer.js";
52
+ export type { ExplorerUrls, BuildExplorerUrlsInput } from "./explorer.js";
53
+
54
+ export {
55
+ bootstrapAndTrace,
56
+ deriveAddress,
57
+ DEFAULT_RPC_URL,
58
+ DEFAULT_GATEWAY_URL,
59
+ DEFAULT_AGENT_ID,
60
+ } from "./bootstrap.js";
61
+ export type {
62
+ BootstrapAndTraceOptions,
63
+ BootstrapAndTraceResult,
64
+ BootstrapStep,
65
+ } from "./bootstrap.js";
66
+
67
+ export const VERSION = "0.1.0";
package/src/trace.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @summary Minimal trace-bundle factory used by `orynq init` / `orynq trace`.
3
+ *
4
+ * Wraps `@fluxpointstudios/orynq-sdk-process-trace` with a one-call helper
5
+ * that produces a finalised bundle from a single observation event. The
6
+ * heavyweight builder (multi-span, multi-event, custom kinds) lives in
7
+ * the underlying package — quickstart deliberately ships only the
8
+ * "hello world" path so a fresh dev sees a trace land before they're
9
+ * forced to learn span semantics.
10
+ */
11
+
12
+ import { createHash } from "crypto";
13
+ import {
14
+ createTrace,
15
+ addSpan,
16
+ addEvent,
17
+ closeSpan,
18
+ finalizeTrace,
19
+ } from "@fluxpointstudios/orynq-sdk-process-trace";
20
+ import type { TraceBundle } from "@fluxpointstudios/orynq-sdk-process-trace";
21
+
22
+ /**
23
+ * Slimmed-down public view of a `TraceBundle` — exposes only the fields
24
+ * `orynq init` / `orynq trace` needs to print + the raw `content` JSON the
25
+ * caller will upload as a blob. The full `bundle` is preserved on the
26
+ * returned object so power-users can still walk events/spans.
27
+ */
28
+ export interface TraceBundleLite {
29
+ runId: string;
30
+ agentId: string;
31
+ rootHash: string;
32
+ merkleRoot: string;
33
+ /**
34
+ * SHA-256 of the canonical JSON content payload as a hex string. Set by
35
+ * `firstTraceBundle()` so the same hash that ends up in the on-chain
36
+ * receipt is available without re-canonicalising downstream.
37
+ */
38
+ manifestHash: string;
39
+ /**
40
+ * Canonical JSON serialisation of `bundle.publicView`. This is what we
41
+ * upload to the blob gateway under `contentHash = sha256(content)`.
42
+ */
43
+ content: string;
44
+ /** Original full bundle, in case callers want spans/events. */
45
+ bundle: TraceBundle;
46
+ }
47
+
48
+ /**
49
+ * Optional deterministic-clock + identifier hooks. Used by the
50
+ * documentation tests + recipes that need stable hashes across runs.
51
+ *
52
+ * In normal use (production), callers pass nothing here and let the
53
+ * trace-builder pick wall-clock timestamps + random UUIDs.
54
+ */
55
+ export interface DeterministicHooks {
56
+ /** Pin `new Date()`/`Date.now()` for the duration of this call. */
57
+ now?: () => Date;
58
+ /** Pin the run UUID returned by `createTrace`. */
59
+ runId?: string;
60
+ /** Pin the span UUID returned by `addSpan`. */
61
+ spanId?: string;
62
+ /** Pin the event UUID returned by `addEvent`. */
63
+ eventId?: string;
64
+ }
65
+
66
+ export interface FirstTraceBundleOptions extends DeterministicHooks {
67
+ agentId: string;
68
+ /** Free-form one-liner appended as the public observation event. */
69
+ summary: string;
70
+ }
71
+
72
+ /**
73
+ * Build, finalise, and serialise a one-event, one-span trace bundle.
74
+ *
75
+ * The optional `now`/`runId`/`spanId`/`eventId` hooks are useful for tests
76
+ * that need byte-stable hashes; they patch the globals only for the
77
+ * duration of this single call and restore them in a `finally` block so
78
+ * we never leak the patch into surrounding code.
79
+ */
80
+ export async function firstTraceBundle(
81
+ opts: FirstTraceBundleOptions,
82
+ ): Promise<TraceBundleLite> {
83
+ const restore = installDeterministicHooks(opts);
84
+ try {
85
+ const run = await createTrace({ agentId: opts.agentId });
86
+ const span = addSpan(run, { name: "first-trace", visibility: "public" });
87
+ await addEvent<"observation">(run, span.id, {
88
+ kind: "observation",
89
+ observation: opts.summary,
90
+ visibility: "public",
91
+ });
92
+ await closeSpan(run, span.id);
93
+ const bundle = await finalizeTrace(run);
94
+
95
+ // Canonicalise the public view (the part we publish) — this is the
96
+ // exact bytes the blob-gateway will checksum at upload time.
97
+ const content = canonicalJson(bundle.publicView);
98
+ const manifestHash = sha256Hex(content);
99
+
100
+ return {
101
+ runId: bundle.publicView.runId,
102
+ agentId: bundle.publicView.agentId,
103
+ rootHash: bundle.rootHash,
104
+ merkleRoot: bundle.merkleRoot,
105
+ manifestHash,
106
+ content,
107
+ bundle,
108
+ };
109
+ } finally {
110
+ restore();
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Apply the deterministic-clock + UUID hooks and return a function that
116
+ * undoes them. No-op when no hooks are supplied — the production hot path
117
+ * pays zero cost.
118
+ */
119
+ function installDeterministicHooks(hooks: DeterministicHooks): () => void {
120
+ // Capture all originals up front so multiple-restore is a no-op.
121
+ const originalRandomUuid = globalThis.crypto?.randomUUID?.bind(globalThis.crypto);
122
+ const originalDate = globalThis.Date;
123
+
124
+ let didPatchUuid = false;
125
+ let didPatchDate = false;
126
+
127
+ if (hooks.runId || hooks.spanId || hooks.eventId) {
128
+ const queue: string[] = [];
129
+ if (hooks.runId) queue.push(hooks.runId);
130
+ if (hooks.spanId) queue.push(hooks.spanId);
131
+ if (hooks.eventId) queue.push(hooks.eventId);
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
+ (globalThis.crypto as any).randomUUID = (): string => {
134
+ const next = queue.shift();
135
+ if (next) return next;
136
+ return originalRandomUuid
137
+ ? originalRandomUuid()
138
+ : "00000000-0000-4000-8000-000000000000";
139
+ };
140
+ didPatchUuid = true;
141
+ }
142
+
143
+ if (hooks.now) {
144
+ const fixed = hooks.now();
145
+ const fixedMs = fixed.getTime();
146
+ // Wrap the Date constructor so `new Date()` (no args) returns the
147
+ // pinned moment; `new Date(ms)` and `new Date(str)` still work.
148
+ const Wrapped = new Proxy(originalDate, {
149
+ construct(target, args) {
150
+ if (args.length === 0) {
151
+ return new (target as DateConstructor)(fixedMs);
152
+ }
153
+ return new (target as DateConstructor)(
154
+ ...(args as ConstructorParameters<DateConstructor>),
155
+ );
156
+ },
157
+ get(target, prop, receiver) {
158
+ if (prop === "now") return () => fixedMs;
159
+ return Reflect.get(target, prop, receiver);
160
+ },
161
+ });
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ (globalThis as any).Date = Wrapped;
164
+ didPatchDate = true;
165
+ }
166
+
167
+ return function restore() {
168
+ if (didPatchUuid && originalRandomUuid) {
169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
+ (globalThis.crypto as any).randomUUID = originalRandomUuid;
171
+ }
172
+ if (didPatchDate) {
173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
+ (globalThis as any).Date = originalDate;
175
+ }
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Minimal RFC 8785-ish canonical JSON.
181
+ *
182
+ * Sorts keys, strips nulls/undefined. The full RFC 8785 implementation
183
+ * lives in `@fluxpointstudios/orynq-sdk-core/utils` but we deliberately
184
+ * avoid that dependency here so quickstart stays tiny + has zero
185
+ * transitive deps beyond polkadot.
186
+ */
187
+ function canonicalJson(value: unknown): string {
188
+ return JSON.stringify(sortValue(value));
189
+ }
190
+
191
+ function sortValue(value: unknown): unknown {
192
+ if (Array.isArray(value)) {
193
+ return value.map((v) => sortValue(v));
194
+ }
195
+ if (value !== null && typeof value === "object") {
196
+ const obj = value as Record<string, unknown>;
197
+ const sorted: Record<string, unknown> = {};
198
+ for (const key of Object.keys(obj).sort()) {
199
+ const v = obj[key];
200
+ if (v === undefined || v === null) continue;
201
+ sorted[key] = sortValue(v);
202
+ }
203
+ return sorted;
204
+ }
205
+ return value;
206
+ }
207
+
208
+ function sha256Hex(s: string): string {
209
+ return createHash("sha256").update(s, "utf-8").digest("hex");
210
+ }