solana-resilience-kit 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +210 -0
  3. package/dist/cli/diagnose.d.ts +75 -0
  4. package/dist/cli/diagnose.js +70 -0
  5. package/dist/errors.d.ts +30 -0
  6. package/dist/errors.js +39 -0
  7. package/dist/fees/estimator.d.ts +47 -0
  8. package/dist/fees/estimator.js +43 -0
  9. package/dist/fees/oracles.d.ts +46 -0
  10. package/dist/fees/oracles.js +88 -0
  11. package/dist/index.d.ts +32 -0
  12. package/dist/index.js +28 -0
  13. package/dist/jito/router.d.ts +53 -0
  14. package/dist/jito/router.js +53 -0
  15. package/dist/jito/tips.d.ts +33 -0
  16. package/dist/jito/tips.js +40 -0
  17. package/dist/observability/metrics.d.ts +62 -0
  18. package/dist/observability/metrics.js +74 -0
  19. package/dist/rpc/health.d.ts +41 -0
  20. package/dist/rpc/health.js +120 -0
  21. package/dist/rpc/pool.d.ts +66 -0
  22. package/dist/rpc/pool.js +126 -0
  23. package/dist/rpc/rate-limit.d.ts +38 -0
  24. package/dist/rpc/rate-limit.js +65 -0
  25. package/dist/testing/base58.d.ts +11 -0
  26. package/dist/testing/base58.js +53 -0
  27. package/dist/testing/faults.d.ts +28 -0
  28. package/dist/testing/faults.js +16 -0
  29. package/dist/testing/index.d.ts +13 -0
  30. package/dist/testing/index.js +10 -0
  31. package/dist/testing/mock-cluster.d.ts +86 -0
  32. package/dist/testing/mock-cluster.js +160 -0
  33. package/dist/testing/mock-endpoint.d.ts +37 -0
  34. package/dist/testing/mock-endpoint.js +101 -0
  35. package/dist/testing/mock-jito.d.ts +44 -0
  36. package/dist/testing/mock-jito.js +94 -0
  37. package/dist/testing/rng.d.ts +11 -0
  38. package/dist/testing/rng.js +22 -0
  39. package/dist/tx/confirmation.d.ts +40 -0
  40. package/dist/tx/confirmation.js +56 -0
  41. package/dist/tx/sender.d.ts +57 -0
  42. package/dist/tx/sender.js +74 -0
  43. package/dist/wallet/adapter.d.ts +42 -0
  44. package/dist/wallet/adapter.js +34 -0
  45. package/package.json +71 -0
@@ -0,0 +1,66 @@
1
+ /**
2
+ * ResilientRpcPool — the heart of the RPC layer. Wraps N endpoints behind a
3
+ * single `@solana/kit`-compatible RpcTransport that:
4
+ * - routes to the freshest healthy endpoint (via HealthMonitor),
5
+ * - fails over to the next endpoint on 429 / transport error,
6
+ * - meters weighted credits to pre-empt 429s (CreditRateLimiter),
7
+ * - emits per-request metrics.
8
+ *
9
+ * Because it exposes a real RpcTransport, callers build a normal kit RPC with
10
+ * `pool.rpc()` and use it exactly like any kit RPC — that is the web3.js-v2
11
+ * compatibility guarantee plus DX win.
12
+ */
13
+ import type { RpcTransport } from "@solana/rpc-spec";
14
+ import { type Rpc, type SolanaRpcApi } from "@solana/kit";
15
+ import { HealthMonitor, type EndpointHealth } from "./health.js";
16
+ import type { CreditRateLimiter } from "./rate-limit.js";
17
+ import type { Metrics } from "../observability/metrics.js";
18
+ export interface ResilientEndpoint {
19
+ name: string;
20
+ /** A kit-compatible transport (HTTP transport in prod, MockEndpoint in tests). */
21
+ transport: RpcTransport;
22
+ /** Relative routing weight among equally-fresh endpoints (default 1). */
23
+ weight?: number;
24
+ }
25
+ export interface ResilientRpcConfig {
26
+ endpoints: ResilientEndpoint[];
27
+ /** Max endpoint attempts per logical request (default = endpoints.length). */
28
+ maxAttempts?: number;
29
+ /** Route to the freshest healthy node first (default true). */
30
+ freshnessAware?: boolean;
31
+ /** Send the same read to N endpoints and take the first response (default 1). */
32
+ hedge?: number;
33
+ healthMonitor?: HealthMonitor;
34
+ rateLimiter?: CreditRateLimiter;
35
+ metrics?: Metrics;
36
+ }
37
+ export declare class ResilientRpcPool {
38
+ private readonly endpoints;
39
+ private readonly endpointNames;
40
+ private readonly byName;
41
+ private readonly healthMonitor;
42
+ private readonly rateLimiter?;
43
+ private readonly metrics?;
44
+ private readonly freshnessAware;
45
+ private readonly maxAttempts;
46
+ constructor(config: ResilientRpcConfig);
47
+ /** The failover transport. Plug into `createSolanaRpcFromTransport`. */
48
+ get transport(): RpcTransport;
49
+ /** A ready-to-use kit RPC backed by the resilient transport. */
50
+ rpc(): Rpc<SolanaRpcApi>;
51
+ /** Current per-endpoint health snapshot (for monitoring / CLI). */
52
+ health(): EndpointHealth[];
53
+ /**
54
+ * Builds the per-request attempt order. When freshness-aware, probe every
55
+ * endpoint's slot first so the HealthMonitor can rank fresh nodes ahead of
56
+ * laggards, then fall back to any configured endpoint not in the ranking
57
+ * (so unhealthy nodes stay as a last resort). Probe errors never escape.
58
+ *
59
+ * NOTE: this minimal form double-counts getSlot traffic (one probe + one
60
+ * serve per logical request). A real deployment would gate probing behind a
61
+ * refresh interval; the contract only requires correct routing here.
62
+ */
63
+ private attemptOrder;
64
+ /** Probe a single endpoint's getSlot, feeding health/metrics. Never throws. */
65
+ private probe;
66
+ }
@@ -0,0 +1,126 @@
1
+ import { createSolanaRpcFromTransport } from "@solana/kit";
2
+ import { AllEndpointsFailedError } from "../errors.js";
3
+ import { HealthMonitor } from "./health.js";
4
+ /** True when an error is an HTTP 429 (rate-limit) carrying a numeric statusCode. */
5
+ function isRateLimited(err) {
6
+ return (err !== null &&
7
+ typeof err === "object" &&
8
+ err.statusCode === 429);
9
+ }
10
+ /** Defensively read a bigint slot off a getSlot response (result is the slot). */
11
+ function slotFromResponse(response) {
12
+ return typeof response.result === "bigint" ? response.result : undefined;
13
+ }
14
+ export class ResilientRpcPool {
15
+ endpoints;
16
+ endpointNames;
17
+ byName;
18
+ healthMonitor;
19
+ rateLimiter;
20
+ metrics;
21
+ freshnessAware;
22
+ maxAttempts;
23
+ constructor(config) {
24
+ this.endpoints = config.endpoints;
25
+ this.endpointNames = config.endpoints.map((e) => e.name);
26
+ this.byName = new Map(config.endpoints.map((e) => [e.name, e]));
27
+ this.healthMonitor =
28
+ config.healthMonitor ??
29
+ new HealthMonitor({ endpointNames: this.endpointNames, maxSlotLag: 150n });
30
+ this.rateLimiter = config.rateLimiter;
31
+ this.metrics = config.metrics;
32
+ this.freshnessAware = config.freshnessAware ?? true;
33
+ this.maxAttempts = config.maxAttempts ?? config.endpoints.length;
34
+ }
35
+ /** The failover transport. Plug into `createSolanaRpcFromTransport`. */
36
+ get transport() {
37
+ const transport = async (config) => {
38
+ const payload = config.payload;
39
+ const method = payload.method;
40
+ const order = await this.attemptOrder();
41
+ const attempts = [];
42
+ let used = 0;
43
+ for (const name of order) {
44
+ if (used >= this.maxAttempts)
45
+ break;
46
+ const endpoint = this.byName.get(name);
47
+ if (endpoint === undefined)
48
+ continue;
49
+ used += 1;
50
+ // Optional credit gating: a dry bucket is a soft failure — advance on.
51
+ if (this.rateLimiter !== undefined && !this.rateLimiter.tryAcquire(method)) {
52
+ attempts.push({ endpoint: name, error: new Error("rate limiter: no credits") });
53
+ continue;
54
+ }
55
+ // Date.now() here is a metric value, not loop control — acceptable.
56
+ const start = Date.now();
57
+ try {
58
+ const response = (await endpoint.transport(config));
59
+ const latencyMs = Date.now() - start;
60
+ const slot = method === "getSlot" ? slotFromResponse(response) : undefined;
61
+ this.healthMonitor.recordSuccess(name, latencyMs, slot);
62
+ if (slot !== undefined)
63
+ this.metrics?.recordSlot(name, slot);
64
+ this.metrics?.recordRequest(name, method, latencyMs, true);
65
+ return response;
66
+ }
67
+ catch (err) {
68
+ const latencyMs = Date.now() - start;
69
+ if (isRateLimited(err))
70
+ this.metrics?.recordRateLimited(name);
71
+ this.healthMonitor.recordFailure(name, err);
72
+ this.metrics?.recordRequest(name, method, latencyMs, false);
73
+ attempts.push({ endpoint: name, error: err });
74
+ }
75
+ }
76
+ throw new AllEndpointsFailedError(attempts);
77
+ };
78
+ return transport;
79
+ }
80
+ /** A ready-to-use kit RPC backed by the resilient transport. */
81
+ rpc() {
82
+ return createSolanaRpcFromTransport(this.transport);
83
+ }
84
+ /** Current per-endpoint health snapshot (for monitoring / CLI). */
85
+ health() {
86
+ return this.healthMonitor.snapshot();
87
+ }
88
+ /**
89
+ * Builds the per-request attempt order. When freshness-aware, probe every
90
+ * endpoint's slot first so the HealthMonitor can rank fresh nodes ahead of
91
+ * laggards, then fall back to any configured endpoint not in the ranking
92
+ * (so unhealthy nodes stay as a last resort). Probe errors never escape.
93
+ *
94
+ * NOTE: this minimal form double-counts getSlot traffic (one probe + one
95
+ * serve per logical request). A real deployment would gate probing behind a
96
+ * refresh interval; the contract only requires correct routing here.
97
+ */
98
+ async attemptOrder() {
99
+ if (!this.freshnessAware)
100
+ return this.endpointNames;
101
+ await Promise.all(this.endpoints.map((e) => this.probe(e)));
102
+ const ranked = this.healthMonitor.rankByFreshness();
103
+ if (ranked.length === 0)
104
+ return this.endpointNames;
105
+ const seen = new Set(ranked);
106
+ const fallback = this.endpointNames.filter((n) => !seen.has(n));
107
+ return [...ranked, ...fallback];
108
+ }
109
+ /** Probe a single endpoint's getSlot, feeding health/metrics. Never throws. */
110
+ async probe(endpoint) {
111
+ const probePayload = { jsonrpc: "2.0", id: 1, method: "getSlot", params: [] };
112
+ const start = Date.now();
113
+ try {
114
+ const response = (await endpoint.transport({ payload: probePayload }));
115
+ const latencyMs = Date.now() - start;
116
+ const slot = slotFromResponse(response);
117
+ this.healthMonitor.recordSuccess(endpoint.name, latencyMs, slot);
118
+ if (slot !== undefined)
119
+ this.metrics?.recordSlot(endpoint.name, slot);
120
+ }
121
+ catch (err) {
122
+ // Swallow: a probe failure must never abort the real request path.
123
+ this.healthMonitor.recordFailure(endpoint.name, err);
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * CreditRateLimiter — token-bucket limiter that meters by *weighted credits*,
3
+ * not raw request count, because providers charge heavy methods (e.g.
4
+ * getProgramAccounts) many times a getBalance. Avoiding 429s requires modeling
5
+ * that weighting client-side.
6
+ */
7
+ /** Default method weights, modeled on common provider credit tables. */
8
+ export declare const DEFAULT_METHOD_WEIGHTS: Readonly<Record<string, number>>;
9
+ export interface RateLimiterConfig {
10
+ /** Credits replenished per window. */
11
+ creditsPerWindow: number;
12
+ /** Window length in ms. */
13
+ windowMs: number;
14
+ /** Per-method weights; falls back to 1 for unknown methods. */
15
+ weights?: Record<string, number>;
16
+ /** Clock injection for deterministic tests. */
17
+ now?: () => number;
18
+ }
19
+ export declare class CreditRateLimiter {
20
+ private readonly weights;
21
+ private readonly creditsPerWindow;
22
+ private readonly windowMs;
23
+ private readonly now;
24
+ private availableCredits;
25
+ private windowStart;
26
+ constructor(config: RateLimiterConfig);
27
+ /** Credit cost of a method under the configured weights. */
28
+ cost(method: string): number;
29
+ /**
30
+ * Lazily replenishes the bucket: if a full window has elapsed since
31
+ * `windowStart`, reset credits and anchor a new window. No timers.
32
+ */
33
+ private refill;
34
+ /** Attempts to spend credits for `method`; returns false if the bucket is dry. */
35
+ tryAcquire(method: string): boolean;
36
+ /** Credits currently available (after refill). */
37
+ available(): number;
38
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * CreditRateLimiter — token-bucket limiter that meters by *weighted credits*,
3
+ * not raw request count, because providers charge heavy methods (e.g.
4
+ * getProgramAccounts) many times a getBalance. Avoiding 429s requires modeling
5
+ * that weighting client-side.
6
+ */
7
+ /** Default method weights, modeled on common provider credit tables. */
8
+ export const DEFAULT_METHOD_WEIGHTS = {
9
+ getBalance: 1,
10
+ getSlot: 1,
11
+ getBlockHeight: 1,
12
+ getLatestBlockhash: 1,
13
+ getSignatureStatuses: 1,
14
+ sendTransaction: 1,
15
+ simulateTransaction: 10,
16
+ getRecentPrioritizationFees: 10,
17
+ getProgramAccounts: 10,
18
+ getSignaturesForAddress: 10,
19
+ };
20
+ export class CreditRateLimiter {
21
+ weights;
22
+ creditsPerWindow;
23
+ windowMs;
24
+ now;
25
+ availableCredits;
26
+ windowStart;
27
+ constructor(config) {
28
+ this.weights = { ...DEFAULT_METHOD_WEIGHTS, ...config.weights };
29
+ this.creditsPerWindow = config.creditsPerWindow;
30
+ this.windowMs = config.windowMs;
31
+ this.now = config.now ?? Date.now;
32
+ this.availableCredits = config.creditsPerWindow;
33
+ this.windowStart = this.now();
34
+ }
35
+ /** Credit cost of a method under the configured weights. */
36
+ cost(method) {
37
+ return this.weights[method] ?? 1;
38
+ }
39
+ /**
40
+ * Lazily replenishes the bucket: if a full window has elapsed since
41
+ * `windowStart`, reset credits and anchor a new window. No timers.
42
+ */
43
+ refill() {
44
+ const elapsed = this.now() - this.windowStart;
45
+ if (elapsed >= this.windowMs) {
46
+ this.availableCredits = this.creditsPerWindow;
47
+ this.windowStart = this.now();
48
+ }
49
+ }
50
+ /** Attempts to spend credits for `method`; returns false if the bucket is dry. */
51
+ tryAcquire(method) {
52
+ this.refill();
53
+ const c = this.cost(method);
54
+ if (this.availableCredits >= c) {
55
+ this.availableCredits -= c;
56
+ return true;
57
+ }
58
+ return false;
59
+ }
60
+ /** Credits currently available (after refill). */
61
+ available() {
62
+ this.refill();
63
+ return this.availableCredits;
64
+ }
65
+ }
@@ -0,0 +1,11 @@
1
+ export declare function base58Encode(bytes: Uint8Array): string;
2
+ /** Reads a Solana compact-u16 (shortvec) length prefix. */
3
+ export declare function readShortU16(bytes: Uint8Array, offset: number): {
4
+ value: number;
5
+ length: number;
6
+ };
7
+ /**
8
+ * Extracts the first signature (base58) from a base64-encoded wire transaction.
9
+ * Wire layout: [compact-u16 signature count][sig0 (64 bytes)]...[message].
10
+ */
11
+ export declare function firstSignatureFromWireBase64(wireBase64: string): string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Minimal, dependency-free base58 (Bitcoin alphabet) encoder plus a wire-format
3
+ * signature extractor. Used so the mock cluster can derive the same transaction
4
+ * signature that `@solana/kit`'s `getSignatureFromTransaction` produces, letting
5
+ * tests build/sign a real kit transaction and assert against the mock.
6
+ */
7
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
8
+ export function base58Encode(bytes) {
9
+ let zeros = 0;
10
+ while (zeros < bytes.length && bytes[zeros] === 0)
11
+ zeros++;
12
+ const digits = [];
13
+ for (let i = zeros; i < bytes.length; i++) {
14
+ let carry = bytes[i];
15
+ for (let j = 0; j < digits.length; j++) {
16
+ carry += digits[j] << 8;
17
+ digits[j] = carry % 58;
18
+ carry = (carry / 58) | 0;
19
+ }
20
+ while (carry > 0) {
21
+ digits.push(carry % 58);
22
+ carry = (carry / 58) | 0;
23
+ }
24
+ }
25
+ let out = "1".repeat(zeros);
26
+ for (let i = digits.length - 1; i >= 0; i--)
27
+ out += ALPHABET[digits[i]];
28
+ return out;
29
+ }
30
+ /** Reads a Solana compact-u16 (shortvec) length prefix. */
31
+ export function readShortU16(bytes, offset) {
32
+ let value = 0;
33
+ let shift = 0;
34
+ let length = 0;
35
+ for (;;) {
36
+ const byte = bytes[offset + length];
37
+ value |= (byte & 0x7f) << shift;
38
+ length++;
39
+ if ((byte & 0x80) === 0)
40
+ break;
41
+ shift += 7;
42
+ }
43
+ return { value, length };
44
+ }
45
+ /**
46
+ * Extracts the first signature (base58) from a base64-encoded wire transaction.
47
+ * Wire layout: [compact-u16 signature count][sig0 (64 bytes)]...[message].
48
+ */
49
+ export function firstSignatureFromWireBase64(wireBase64) {
50
+ const bytes = Uint8Array.from(Buffer.from(wireBase64, "base64"));
51
+ const { length } = readShortU16(bytes, 0);
52
+ return base58Encode(bytes.slice(length, length + 64));
53
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Fault model for a single simulated RPC endpoint. Every field is optional;
3
+ * an empty profile is a perfectly healthy node. These map 1:1 to the real
4
+ * failure modes documented in the problem analysis (drops, 429s, lag, etc.).
5
+ */
6
+ export interface EndpointFaultProfile {
7
+ /** Artificial latency in ms. A tuple draws uniformly in [min, max]. */
8
+ latencyMs?: number | [number, number];
9
+ /** Probability (0..1) a `sendTransaction` is silently dropped (never lands). */
10
+ dropRate?: number;
11
+ /** Probability (0..1) any request fails with a generic transport error. */
12
+ errorRate?: number;
13
+ /** Probability (0..1) any request fails with an HTTP 429 rate-limit error. */
14
+ rate429Rate?: number;
15
+ /** Slots this node lags behind cluster truth (models a stale/lagging node). */
16
+ slotLag?: number;
17
+ /** When true, every request rejects immediately (node down). */
18
+ offline?: boolean;
19
+ }
20
+ /** Transport-level error carrying an HTTP-style status, like a provider gateway 429/5xx. */
21
+ export declare class HttpTransportError extends Error {
22
+ readonly statusCode: number;
23
+ constructor(statusCode: number, message?: string);
24
+ }
25
+ /** A request that never produced a response (connection dropped / node offline). */
26
+ export declare class TransportDroppedError extends Error {
27
+ constructor(message?: string);
28
+ }
@@ -0,0 +1,16 @@
1
+ /** Transport-level error carrying an HTTP-style status, like a provider gateway 429/5xx. */
2
+ export class HttpTransportError extends Error {
3
+ statusCode;
4
+ constructor(statusCode, message) {
5
+ super(message ?? `HTTP ${statusCode}`);
6
+ this.statusCode = statusCode;
7
+ this.name = "HttpTransportError";
8
+ }
9
+ }
10
+ /** A request that never produced a response (connection dropped / node offline). */
11
+ export class TransportDroppedError extends Error {
12
+ constructor(message = "transport request dropped") {
13
+ super(message);
14
+ this.name = "TransportDroppedError";
15
+ }
16
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Public surface of the simulation harness. Tests (and, after handoff, the
3
+ * Claude Code implementation runs) import everything they need from here.
4
+ */
5
+ export { MockCluster } from "./mock-cluster.js";
6
+ export type { MockClusterOptions, BlockhashRecord, Commitment } from "./mock-cluster.js";
7
+ export { MockEndpoint } from "./mock-endpoint.js";
8
+ export type { MockEndpointOptions } from "./mock-endpoint.js";
9
+ export { MockJitoEngine, bundleId } from "./mock-jito.js";
10
+ export type { MockJitoOptions, BundleState } from "./mock-jito.js";
11
+ export { type EndpointFaultProfile, HttpTransportError, TransportDroppedError, } from "./faults.js";
12
+ export { makeRng, chance, randInt, type Rng } from "./rng.js";
13
+ export { base58Encode, firstSignatureFromWireBase64 } from "./base58.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Public surface of the simulation harness. Tests (and, after handoff, the
3
+ * Claude Code implementation runs) import everything they need from here.
4
+ */
5
+ export { MockCluster } from "./mock-cluster.js";
6
+ export { MockEndpoint } from "./mock-endpoint.js";
7
+ export { MockJitoEngine, bundleId } from "./mock-jito.js";
8
+ export { HttpTransportError, TransportDroppedError, } from "./faults.js";
9
+ export { makeRng, chance, randInt } from "./rng.js";
10
+ export { base58Encode, firstSignatureFromWireBase64 } from "./base58.js";
@@ -0,0 +1,86 @@
1
+ export type Commitment = "processed" | "confirmed" | "finalized";
2
+ export interface MockClusterOptions {
3
+ initialSlot?: bigint;
4
+ initialBlockHeight?: bigint;
5
+ /** Slots between when a tx is accepted and when it lands (default 1). */
6
+ defaultLandingDelaySlots?: number;
7
+ /** Seed used to mint deterministic blockhash strings. */
8
+ blockhashSeed?: number;
9
+ }
10
+ interface PendingTx {
11
+ signature: string;
12
+ acceptedAtBlockHeight: bigint;
13
+ deadlineBlockHeight: bigint;
14
+ landAtBlockHeight: bigint;
15
+ dropped: boolean;
16
+ landedSlot: bigint | null;
17
+ status: "pending" | "landed" | "expired";
18
+ err: unknown | null;
19
+ }
20
+ export interface BlockhashRecord {
21
+ blockhash: string;
22
+ lastValidBlockHeight: bigint;
23
+ issuedAtSlot: bigint;
24
+ }
25
+ export declare class MockCluster {
26
+ slot: bigint;
27
+ blockHeight: bigint;
28
+ private readonly defaultLandingDelaySlots;
29
+ private blockhashCounter;
30
+ private readonly blockhashSeed;
31
+ private latest;
32
+ private readonly txs;
33
+ /** Per-signature override of landing delay; -1 means "never lands". */
34
+ private readonly landingOverrides;
35
+ private prioritizationFees;
36
+ constructor(opts?: MockClusterOptions);
37
+ /** Advances the cluster by `n` slots, processing tx landings and expiries. */
38
+ advanceSlots(n: number): void;
39
+ /** Force the next tx with this signature to land after `slots` (or never if <0). */
40
+ scheduleLanding(signature: string, slots: number): void;
41
+ setPrioritizationFees(fees: bigint[]): void;
42
+ getTx(signature: string): PendingTx | undefined;
43
+ private mintBlockhash;
44
+ rpcGetSlot(): bigint;
45
+ rpcGetBlockHeight(): bigint;
46
+ rpcGetLatestBlockhash(): {
47
+ context: {
48
+ slot: bigint;
49
+ };
50
+ value: {
51
+ blockhash: string;
52
+ lastValidBlockHeight: bigint;
53
+ };
54
+ };
55
+ /** Accepts a base64 wire tx (or a raw signature string for low-level tests). */
56
+ rpcSendTransaction(rawTxOrSig: string, opts?: {
57
+ dropped?: boolean;
58
+ }): string;
59
+ rpcGetSignatureStatuses(signatures: string[]): {
60
+ context: {
61
+ slot: bigint;
62
+ };
63
+ value: Array<null | {
64
+ slot: bigint;
65
+ confirmations: number | null;
66
+ err: unknown | null;
67
+ confirmationStatus: Commitment;
68
+ }>;
69
+ };
70
+ rpcSimulateTransaction(): {
71
+ context: {
72
+ slot: bigint;
73
+ };
74
+ value: {
75
+ err: unknown | null;
76
+ logs: string[];
77
+ unitsConsumed: bigint;
78
+ };
79
+ };
80
+ rpcGetRecentPrioritizationFees(): Array<{
81
+ slot: bigint;
82
+ prioritizationFee: bigint;
83
+ }>;
84
+ private looksLikeBase64WireTx;
85
+ }
86
+ export {};
@@ -0,0 +1,160 @@
1
+ /**
2
+ * MockCluster — an in-memory, deterministic model of a Solana cluster's RPC
3
+ * surface, sufficient to exercise the resilience SDK's real behavior offline.
4
+ *
5
+ * Design notes:
6
+ * - The clock is fully manual: nothing advances unless a test calls
7
+ * `advanceSlots()`. This makes blockhash-expiry and rebroadcast timing
8
+ * deterministic instead of wall-clock dependent.
9
+ * - u64 fields (slot, blockHeight, lastValidBlockHeight, unitsConsumed) are
10
+ * returned as `bigint`, matching `@solana/kit` response types.
11
+ * - `sendTransaction` accepts the base64 wire tx and derives the signature the
12
+ * same way kit does, so a test can sign a real kit transaction and assert.
13
+ * - Landing semantics model the two terminal failure modes precisely:
14
+ * * silent drop -> tx stays pending forever, status is always null
15
+ * * blockhash expiry -> once blockHeight passes the tx deadline it can
16
+ * never land (status stays null); the SDK must give up at
17
+ * lastValidBlockHeight rather than poll forever.
18
+ */
19
+ import { base58Encode, firstSignatureFromWireBase64 } from "./base58.js";
20
+ const BLOCKHASH_VALIDITY_BLOCKS = 150n;
21
+ export class MockCluster {
22
+ slot;
23
+ blockHeight;
24
+ defaultLandingDelaySlots;
25
+ blockhashCounter = 0;
26
+ blockhashSeed;
27
+ latest;
28
+ txs = new Map();
29
+ /** Per-signature override of landing delay; -1 means "never lands". */
30
+ landingOverrides = new Map();
31
+ prioritizationFees = [10000n, 25000n, 50000n, 75000n, 95000n];
32
+ constructor(opts = {}) {
33
+ this.slot = opts.initialSlot ?? 1000n;
34
+ this.blockHeight = opts.initialBlockHeight ?? 1000n;
35
+ this.defaultLandingDelaySlots = opts.defaultLandingDelaySlots ?? 1;
36
+ this.blockhashSeed = opts.blockhashSeed ?? 1;
37
+ this.latest = this.mintBlockhash();
38
+ }
39
+ // --- clock control -------------------------------------------------------
40
+ /** Advances the cluster by `n` slots, processing tx landings and expiries. */
41
+ advanceSlots(n) {
42
+ for (let i = 0; i < n; i++) {
43
+ this.slot += 1n;
44
+ this.blockHeight += 1n;
45
+ for (const tx of this.txs.values()) {
46
+ if (tx.status !== "pending")
47
+ continue;
48
+ if (tx.dropped)
49
+ continue; // silently dropped: never lands
50
+ if (this.blockHeight > tx.deadlineBlockHeight) {
51
+ tx.status = "expired";
52
+ continue;
53
+ }
54
+ if (tx.landAtBlockHeight >= 0n && this.blockHeight >= tx.landAtBlockHeight) {
55
+ tx.status = "landed";
56
+ tx.landedSlot = this.slot;
57
+ }
58
+ }
59
+ }
60
+ }
61
+ // --- test scripting ------------------------------------------------------
62
+ /** Force the next tx with this signature to land after `slots` (or never if <0). */
63
+ scheduleLanding(signature, slots) {
64
+ this.landingOverrides.set(signature, slots);
65
+ }
66
+ setPrioritizationFees(fees) {
67
+ this.prioritizationFees = fees;
68
+ }
69
+ getTx(signature) {
70
+ return this.txs.get(signature);
71
+ }
72
+ // --- internal ------------------------------------------------------------
73
+ mintBlockhash() {
74
+ this.blockhashCounter += 1;
75
+ // Deterministic 32-byte hash -> base58 string.
76
+ const bytes = new Uint8Array(32);
77
+ let x = (this.blockhashSeed + this.blockhashCounter * 2654435761) >>> 0;
78
+ for (let i = 0; i < 32; i++) {
79
+ x = (x * 1664525 + 1013904223) >>> 0;
80
+ bytes[i] = x & 0xff;
81
+ }
82
+ return {
83
+ blockhash: base58Encode(bytes),
84
+ lastValidBlockHeight: this.blockHeight + BLOCKHASH_VALIDITY_BLOCKS,
85
+ issuedAtSlot: this.slot,
86
+ };
87
+ }
88
+ // --- RPC method implementations (return the JSON-RPC `result` value) ------
89
+ rpcGetSlot() {
90
+ return this.slot;
91
+ }
92
+ rpcGetBlockHeight() {
93
+ return this.blockHeight;
94
+ }
95
+ rpcGetLatestBlockhash() {
96
+ this.latest = this.mintBlockhash();
97
+ return {
98
+ context: { slot: this.slot },
99
+ value: {
100
+ blockhash: this.latest.blockhash,
101
+ lastValidBlockHeight: this.latest.lastValidBlockHeight,
102
+ },
103
+ };
104
+ }
105
+ /** Accepts a base64 wire tx (or a raw signature string for low-level tests). */
106
+ rpcSendTransaction(rawTxOrSig, opts) {
107
+ const signature = this.looksLikeBase64WireTx(rawTxOrSig)
108
+ ? firstSignatureFromWireBase64(rawTxOrSig)
109
+ : rawTxOrSig;
110
+ const override = this.landingOverrides.get(signature);
111
+ const delay = override ?? this.defaultLandingDelaySlots;
112
+ const dropped = opts?.dropped === true || override === -1;
113
+ if (!this.txs.has(signature)) {
114
+ this.txs.set(signature, {
115
+ signature,
116
+ acceptedAtBlockHeight: this.blockHeight,
117
+ deadlineBlockHeight: this.latest.lastValidBlockHeight,
118
+ landAtBlockHeight: dropped ? -1n : this.blockHeight + BigInt(Math.max(0, delay)),
119
+ dropped,
120
+ landedSlot: null,
121
+ status: "pending",
122
+ err: null,
123
+ });
124
+ }
125
+ return signature;
126
+ }
127
+ rpcGetSignatureStatuses(signatures) {
128
+ return {
129
+ context: { slot: this.slot },
130
+ value: signatures.map((sig) => {
131
+ const tx = this.txs.get(sig);
132
+ if (!tx || tx.status !== "landed" || tx.landedSlot === null)
133
+ return null;
134
+ const age = Number(this.slot - tx.landedSlot);
135
+ return {
136
+ slot: tx.landedSlot,
137
+ confirmations: age >= 32 ? null : age,
138
+ err: tx.err,
139
+ confirmationStatus: age >= 32 ? "finalized" : "confirmed",
140
+ };
141
+ }),
142
+ };
143
+ }
144
+ rpcSimulateTransaction() {
145
+ return {
146
+ context: { slot: this.slot },
147
+ value: { err: null, logs: [], unitsConsumed: 6000n },
148
+ };
149
+ }
150
+ rpcGetRecentPrioritizationFees() {
151
+ return this.prioritizationFees.map((fee, i) => ({
152
+ slot: this.slot - BigInt(this.prioritizationFees.length - i),
153
+ prioritizationFee: fee,
154
+ }));
155
+ }
156
+ looksLikeBase64WireTx(s) {
157
+ // A base58 signature is <=88 chars; a base64 wire transaction is far longer.
158
+ return s.length > 100;
159
+ }
160
+ }