solana-resilience-kit 1.0.0 → 1.0.1

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/README.md CHANGED
@@ -12,7 +12,7 @@ A vendor-neutral, **client-side resilience and observability layer for Solana dA
12
12
  - **Vendor-neutral** — works with any RPC provider; no gateway, no proprietary key required.
13
13
  - **Correct by construction** — implements the send/confirm semantics most clients get wrong (no double-charge, bounded by `lastValidBlockHeight`).
14
14
  - **Built on `@solana/kit`** — the pool *is* a kit `RpcTransport`, so it drops into existing kit code.
15
- - **Deterministically tested** — an in-memory fault-injection cluster reproduces drops, expiry, 429s, desync, and MEV failures; 74 specs, coverage-gated.
15
+ - **Deterministically tested** — an in-memory fault-injection cluster reproduces drops, expiry, 429s, desync, and MEV failures; 102 specs, coverage-gated.
16
16
  - **Observable** — first-class client telemetry to OpenTelemetry / Datadog.
17
17
 
18
18
  ## Problem
@@ -66,7 +66,7 @@ The decisive finding: every robust mitigation today is **either a DIY recipe in
66
66
  | `JitoRouter` + `TipEstimator` | `src/jito/*` | Bundle routing, dynamic tips, automatic RPC fallback |
67
67
  | `OtelMetrics` / `InMemoryMetrics` | `src/observability/metrics.ts` | Client telemetry (latency, failures, slot lag, landings) → OTel/Datadog |
68
68
  | `ResilientWalletAdapter` | `src/wallet/adapter.ts` | Wallet-signed transactions through the resilient pipeline |
69
- | `Diagnostics` | `src/cli/diagnose.ts` | Probe provider health; explain why a transaction did or didn't land |
69
+ | `Diagnostics` + `solana-resilience-diagnose` CLI | `src/cli/diagnose.ts`, `src/cli/index.ts` | Probe provider health; explain why a transaction did or didn't land (see [Diagnostics CLI](#diagnostics-cli)) |
70
70
 
71
71
  ## Architecture
72
72
 
@@ -137,6 +137,61 @@ const result = await sender.sendAndConfirm({
137
137
  // resend error on an already-landed tx as non-terminal.
138
138
  ```
139
139
 
140
+ ## Diagnostics CLI
141
+
142
+ The package ships an executable, `solana-resilience-diagnose`, built on the same
143
+ `Diagnostics` core (`src/cli/diagnose.ts`). It answers the two questions an
144
+ operator asks when a Solana dApp misbehaves — *which of my providers is healthy
145
+ and freshest?* and *did this transaction land, expire, or is it still pending?* —
146
+ without writing any code. Run it with `npx` (no install) or from a dependency's
147
+ `node_modules/.bin`:
148
+
149
+ ```bash
150
+ # Probe provider health across one or more endpoints (reuses the pool's own
151
+ # slot-freshness ranking, so "freshest" matches what routing would pick):
152
+ npx solana-resilience-diagnose probe \
153
+ --rpc https://api.mainnet-beta.solana.com \
154
+ --rpc https://my-backup.rpc
155
+ ```
156
+
157
+ ```
158
+ ENDPOINT HEALTH SLOT LATENCY FRESHEST
159
+ https://api.mainnet-beta.solana.com ok 287654812 142ms *
160
+ https://my-backup.rpc down - 19ms
161
+
162
+ Freshest: https://api.mainnet-beta.solana.com · 1/2 healthy.
163
+ https://my-backup.rpc: fetch failed
164
+ ```
165
+
166
+ ```bash
167
+ # Explain a transaction's outcome point-in-time (no polling loop): it compares
168
+ # the current signature status and block height against lastValidBlockHeight —
169
+ # the canonical Solana rule — and never re-signs.
170
+ npx solana-resilience-diagnose explain \
171
+ --rpc https://api.mainnet-beta.solana.com \
172
+ --sig 5xRe...your-signature \
173
+ --lvbh 287654321
174
+ ```
175
+
176
+ ```
177
+ Signature: 5xRe...your-signature
178
+ Verdict: EXPIRED
179
+ block height 287654400 exceeded lastValidBlockHeight 287654321; the blockhash
180
+ expired before the transaction landed (silent drop or congestion). Rebuild with
181
+ a fresh blockhash — do NOT re-sign the same one.
182
+ ```
183
+
184
+ | Flag | Command | Meaning |
185
+ |---|---|---|
186
+ | `--rpc <url>` | both | RPC endpoint URL. Repeat for `probe`; exactly one for `explain`. Accepts `--rpc=<url>` too. |
187
+ | `--sig <sig>` | `explain` | Transaction signature to explain. |
188
+ | `--lvbh <n>` | `explain` | `lastValidBlockHeight` the transaction was built against. |
189
+
190
+ **Exit codes:** `0` success · `1` a substantive failure (no healthy endpoint, or an
191
+ expired transaction) · `2` a usage error. Run with no command, `help`, or
192
+ `--help` to print usage. The argv parser is a pure, network-free function and is
193
+ unit-tested in isolation (`test/cli/argv.test.ts`).
194
+
140
195
  ## Testing your own code against the fault harness
141
196
 
142
197
  The deterministic Solana cluster simulator the SDK is tested with is shipped as a
@@ -178,7 +233,7 @@ Solana's failure modes — silent drops, blockhash expiry, 429s, lagging-node de
178
233
  - **Injected `sleep`.** Time-based loops take a `sleep` dependency; tests pass one that advances the mock clock, so the whole state machine runs instantly and deterministically.
179
234
 
180
235
  ```bash
181
- npm test # full suite (harness + all modules), 74 specs
236
+ npm test # full suite (harness + all modules), 102 specs
182
237
  npm run test:cov # coverage with the thresholds enforced
183
238
  npm run typecheck # tsc --noEmit
184
239
  ```
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ import type { Rpc, SolanaRpcApi } from "@solana/kit";
3
+ import { SdkError } from "../errors.js";
4
+ import { Diagnostics } from "./diagnose.js";
5
+ import type { ProbeReport, TxDiagnosis } from "./diagnose.js";
6
+ /** A malformed invocation (unknown command, missing/invalid flag). Message carries usage text. */
7
+ export declare class CliUsageError extends SdkError {
8
+ }
9
+ export interface ProbeCommand {
10
+ command: "probe";
11
+ rpcUrls: string[];
12
+ }
13
+ export interface ExplainCommand {
14
+ command: "explain";
15
+ rpcUrl: string;
16
+ signature: string;
17
+ lastValidBlockHeight: bigint;
18
+ }
19
+ export type ParsedCommand = ProbeCommand | ExplainCommand;
20
+ export declare const USAGE = "solana-resilience-diagnose \u2014 probe RPC health and explain transaction outcomes\n\nUsage:\n solana-resilience-diagnose probe --rpc <url> [--rpc <url> ...]\n solana-resilience-diagnose explain --rpc <url> --sig <signature> --lvbh <lastValidBlockHeight>\n\nCommands:\n probe Probe each endpoint's slot / latency / health and report the freshest.\n explain Point-in-time verdict (confirmed | expired | pending) for one signature.\n\nFlags:\n --rpc <url> RPC endpoint URL. Repeat for probe; exactly one for explain.\n --sig <sig> Transaction signature to explain.\n --lvbh <n> lastValidBlockHeight the transaction was built against.\n\nExamples:\n solana-resilience-diagnose probe --rpc https://api.mainnet-beta.solana.com --rpc https://my-backup.rpc\n solana-resilience-diagnose explain --rpc https://api.mainnet-beta.solana.com --sig 5xRe... --lvbh 287654321";
21
+ /**
22
+ * Pure argv parser. Accepts argv WITHOUT the `node script` prefix (i.e.
23
+ * `process.argv.slice(2)`). Supports `--flag value` and `--flag=value`. Throws
24
+ * {@link CliUsageError} (with help text) on anything malformed. No I/O.
25
+ */
26
+ export declare function parseArgs(argv: string[]): ParsedCommand;
27
+ /** Render a {@link ProbeReport} as an aligned text table plus a summary line. */
28
+ export declare function formatProbeReport(report: ProbeReport): string;
29
+ /** Render an {@link TxDiagnosis} as a human verdict. */
30
+ export declare function formatDiagnosis(signature: string, diag: TxDiagnosis): string;
31
+ export interface CliDeps {
32
+ /** Build a kit RPC from a URL. Defaults to `createSolanaRpc`. */
33
+ createRpc?: (url: string) => Rpc<SolanaRpcApi>;
34
+ /** Sink for output lines. Defaults to `console.log`. */
35
+ log?: (line: string) => void;
36
+ /** Override the diagnostics core (tests inject a shared HealthMonitor here). */
37
+ diagnostics?: Diagnostics;
38
+ }
39
+ /**
40
+ * Execute one parsed command. Returns the process exit code. All side effects
41
+ * (RPC, stdout) go through {@link CliDeps}, so tests run it network-free.
42
+ */
43
+ export declare function run(argv: string[], deps?: CliDeps): Promise<number>;
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * solana-resilience-diagnose — the executable CLI over the {@link Diagnostics} core.
4
+ *
5
+ * Two commands map 1:1 to the two questions an operator asks when a Solana dApp
6
+ * misbehaves:
7
+ *
8
+ * probe --rpc <url> [--rpc <url> ...] → which providers are healthy, and which is freshest?
9
+ * explain --rpc <url> --sig <signature> --lvbh <n> → did this tx land, expire, or is it still pending?
10
+ *
11
+ * Layering: {@link parseArgs} is a PURE, network-free function (turns argv into a
12
+ * typed command or throws {@link CliUsageError}); {@link formatProbeReport} /
13
+ * {@link formatDiagnosis} are pure renderers; {@link run} wires kit RPC + stdout
14
+ * through injectable deps so it stays deterministic under the harness. Only the
15
+ * process bootstrap at the very bottom touches process.argv / real RPC, and is
16
+ * integration-only — nothing else executes at import time.
17
+ *
18
+ * Exit codes: 0 success · 1 a substantive failure (no healthy endpoint / expired
19
+ * tx) · 2 a usage error.
20
+ */
21
+ import { createSolanaRpc } from "@solana/kit";
22
+ import { pathToFileURL } from "node:url";
23
+ import { SdkError } from "../errors.js";
24
+ import { Diagnostics } from "./diagnose.js";
25
+ /** A malformed invocation (unknown command, missing/invalid flag). Message carries usage text. */
26
+ export class CliUsageError extends SdkError {
27
+ }
28
+ export const USAGE = `solana-resilience-diagnose — probe RPC health and explain transaction outcomes
29
+
30
+ Usage:
31
+ solana-resilience-diagnose probe --rpc <url> [--rpc <url> ...]
32
+ solana-resilience-diagnose explain --rpc <url> --sig <signature> --lvbh <lastValidBlockHeight>
33
+
34
+ Commands:
35
+ probe Probe each endpoint's slot / latency / health and report the freshest.
36
+ explain Point-in-time verdict (confirmed | expired | pending) for one signature.
37
+
38
+ Flags:
39
+ --rpc <url> RPC endpoint URL. Repeat for probe; exactly one for explain.
40
+ --sig <sig> Transaction signature to explain.
41
+ --lvbh <n> lastValidBlockHeight the transaction was built against.
42
+
43
+ Examples:
44
+ solana-resilience-diagnose probe --rpc https://api.mainnet-beta.solana.com --rpc https://my-backup.rpc
45
+ solana-resilience-diagnose explain --rpc https://api.mainnet-beta.solana.com --sig 5xRe... --lvbh 287654321`;
46
+ /**
47
+ * Pure argv parser. Accepts argv WITHOUT the `node script` prefix (i.e.
48
+ * `process.argv.slice(2)`). Supports `--flag value` and `--flag=value`. Throws
49
+ * {@link CliUsageError} (with help text) on anything malformed. No I/O.
50
+ */
51
+ export function parseArgs(argv) {
52
+ const [command, ...rest] = argv;
53
+ if (command === undefined ||
54
+ command === "help" ||
55
+ command === "--help" ||
56
+ command === "-h") {
57
+ throw usage();
58
+ }
59
+ switch (command) {
60
+ case "probe":
61
+ return parseProbe(rest);
62
+ case "explain":
63
+ return parseExplain(rest);
64
+ default:
65
+ throw usage(`unknown command "${command}".`);
66
+ }
67
+ }
68
+ function parseProbe(args) {
69
+ const flags = collectFlags(args);
70
+ const rpcUrls = flags.get("rpc") ?? [];
71
+ if (rpcUrls.length === 0) {
72
+ throw usage('"probe" requires at least one --rpc <url>.');
73
+ }
74
+ return { command: "probe", rpcUrls };
75
+ }
76
+ function parseExplain(args) {
77
+ const flags = collectFlags(args);
78
+ const rpcUrl = single(flags, "rpc");
79
+ const signature = single(flags, "sig");
80
+ const lvbhRaw = single(flags, "lvbh");
81
+ let lastValidBlockHeight;
82
+ try {
83
+ lastValidBlockHeight = BigInt(lvbhRaw);
84
+ }
85
+ catch {
86
+ throw usage(`--lvbh must be an integer, got "${lvbhRaw}".`);
87
+ }
88
+ if (lastValidBlockHeight < 0n) {
89
+ throw usage(`--lvbh must be non-negative, got "${lvbhRaw}".`);
90
+ }
91
+ return { command: "explain", rpcUrl, signature, lastValidBlockHeight };
92
+ }
93
+ /** Parse repeated `--flag value` / `--flag=value` tokens into a multimap. */
94
+ function collectFlags(args) {
95
+ const flags = new Map();
96
+ const queue = [...args];
97
+ while (queue.length > 0) {
98
+ const tok = queue.shift();
99
+ if (!tok.startsWith("--")) {
100
+ throw usage(`unexpected argument "${tok}".`);
101
+ }
102
+ const body = tok.slice(2);
103
+ const eq = body.indexOf("=");
104
+ let key;
105
+ let value;
106
+ if (eq >= 0) {
107
+ key = body.slice(0, eq);
108
+ value = body.slice(eq + 1);
109
+ }
110
+ else {
111
+ key = body;
112
+ const next = queue[0];
113
+ if (next !== undefined && !next.startsWith("--")) {
114
+ value = queue.shift();
115
+ }
116
+ }
117
+ if (value === undefined || value === "") {
118
+ throw usage(`flag --${key} requires a value.`);
119
+ }
120
+ const list = flags.get(key) ?? [];
121
+ list.push(value);
122
+ flags.set(key, list);
123
+ }
124
+ return flags;
125
+ }
126
+ /** Read a flag expected exactly once. */
127
+ function single(flags, key) {
128
+ const values = flags.get(key);
129
+ if (values === undefined || values.length === 0) {
130
+ throw usage(`"explain" requires --${key} <value>.`);
131
+ }
132
+ if (values.length > 1) {
133
+ throw usage(`--${key} may be given only once.`);
134
+ }
135
+ return values[0];
136
+ }
137
+ function usage(detail) {
138
+ return new CliUsageError(detail ? `${detail}\n\n${USAGE}` : USAGE);
139
+ }
140
+ /** Render a {@link ProbeReport} as an aligned text table plus a summary line. */
141
+ export function formatProbeReport(report) {
142
+ const header = ["ENDPOINT", "HEALTH", "SLOT", "LATENCY", "FRESHEST"];
143
+ const rows = report.endpoints.map((e) => [
144
+ e.name,
145
+ e.ok ? "ok" : "down",
146
+ e.slot === null ? "-" : e.slot.toString(),
147
+ `${e.latencyMs}ms`,
148
+ e.ok && e.name === report.freshest ? "*" : "",
149
+ ]);
150
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
151
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
152
+ const lines = [fmt(header), ...rows.map(fmt), ""];
153
+ lines.push(report.healthyCount === 0
154
+ ? `No healthy endpoints (0/${report.endpoints.length} up).`
155
+ : `Freshest: ${report.freshest} · ${report.healthyCount}/${report.endpoints.length} healthy.`);
156
+ for (const e of report.endpoints) {
157
+ if (!e.ok && e.error)
158
+ lines.push(` ${e.name}: ${e.error}`);
159
+ }
160
+ return lines.join("\n");
161
+ }
162
+ /** Render an {@link TxDiagnosis} as a human verdict. */
163
+ export function formatDiagnosis(signature, diag) {
164
+ const head = `Signature: ${signature}`;
165
+ switch (diag.status) {
166
+ case "confirmed":
167
+ return `${head}\nVerdict: CONFIRMED (landed in slot ${diag.slot})`;
168
+ case "expired":
169
+ return `${head}\nVerdict: EXPIRED\n${diag.reason}`;
170
+ case "pending":
171
+ return `${head}\nVerdict: PENDING\n${diag.reason}`;
172
+ }
173
+ }
174
+ /* v8 ignore start -- real-RPC + stdout wiring; exercised by the installed binary, not unit-tested */
175
+ const defaultCreateRpc = (url) => createSolanaRpc(url);
176
+ const defaultLog = (line) => {
177
+ console.log(line);
178
+ };
179
+ /* v8 ignore stop */
180
+ /**
181
+ * Execute one parsed command. Returns the process exit code. All side effects
182
+ * (RPC, stdout) go through {@link CliDeps}, so tests run it network-free.
183
+ */
184
+ export async function run(argv, deps = {}) {
185
+ const log = deps.log ?? defaultLog;
186
+ /* v8 ignore next -- default RPC factory is real-network, covered via the binary */
187
+ const createRpc = deps.createRpc ?? defaultCreateRpc;
188
+ let parsed;
189
+ try {
190
+ parsed = parseArgs(argv);
191
+ }
192
+ catch (err) {
193
+ if (err instanceof CliUsageError) {
194
+ log(err.message);
195
+ return 2;
196
+ }
197
+ throw err;
198
+ }
199
+ const diag = deps.diagnostics ?? new Diagnostics();
200
+ if (parsed.command === "probe") {
201
+ const targets = parsed.rpcUrls.map((url) => ({ name: url, rpc: createRpc(url) }));
202
+ const report = await diag.probeEndpoints(targets);
203
+ log(formatProbeReport(report));
204
+ return report.healthyCount > 0 ? 0 : 1;
205
+ }
206
+ const result = await diag.explainTransaction(createRpc(parsed.rpcUrl), {
207
+ signature: parsed.signature,
208
+ lastValidBlockHeight: parsed.lastValidBlockHeight,
209
+ });
210
+ log(formatDiagnosis(parsed.signature, result));
211
+ return result.status === "expired" ? 1 : 0;
212
+ }
213
+ /* v8 ignore start -- process bootstrap; only runs when invoked as the binary */
214
+ const entry = process.argv[1];
215
+ if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
216
+ run(process.argv.slice(2)).then((code) => {
217
+ process.exitCode = code;
218
+ }, (err) => {
219
+ console.error(err instanceof Error ? err.message : String(err));
220
+ process.exitCode = 1;
221
+ });
222
+ }
223
+ /* v8 ignore stop */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solana-resilience-kit",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Vendor-neutral, client-side resilience and observability layer for Solana dApps, built on @solana/kit (web3.js v2).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,9 @@
31
31
  "main": "./dist/index.js",
32
32
  "module": "./dist/index.js",
33
33
  "types": "./dist/index.d.ts",
34
+ "bin": {
35
+ "solana-resilience-diagnose": "./dist/cli/index.js"
36
+ },
34
37
  "exports": {
35
38
  ".": {
36
39
  "types": "./dist/index.d.ts",
@@ -54,6 +57,7 @@
54
57
  "test:watch": "vitest",
55
58
  "test:cov": "vitest run --coverage",
56
59
  "typecheck": "tsc --noEmit",
60
+ "docs:api": "typedoc",
57
61
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
58
62
  },
59
63
  "dependencies": {
@@ -65,6 +69,7 @@
65
69
  "@types/node": "^22.19.21",
66
70
  "@vitest/coverage-v8": "^4.1.9",
67
71
  "tsx": "^4.22.4",
72
+ "typedoc": "^0.28.19",
68
73
  "typescript": "^5.6.0",
69
74
  "vitest": "^4.1.9"
70
75
  }