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.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/dist/cli/diagnose.d.ts +75 -0
- package/dist/cli/diagnose.js +70 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +39 -0
- package/dist/fees/estimator.d.ts +47 -0
- package/dist/fees/estimator.js +43 -0
- package/dist/fees/oracles.d.ts +46 -0
- package/dist/fees/oracles.js +88 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +28 -0
- package/dist/jito/router.d.ts +53 -0
- package/dist/jito/router.js +53 -0
- package/dist/jito/tips.d.ts +33 -0
- package/dist/jito/tips.js +40 -0
- package/dist/observability/metrics.d.ts +62 -0
- package/dist/observability/metrics.js +74 -0
- package/dist/rpc/health.d.ts +41 -0
- package/dist/rpc/health.js +120 -0
- package/dist/rpc/pool.d.ts +66 -0
- package/dist/rpc/pool.js +126 -0
- package/dist/rpc/rate-limit.d.ts +38 -0
- package/dist/rpc/rate-limit.js +65 -0
- package/dist/testing/base58.d.ts +11 -0
- package/dist/testing/base58.js +53 -0
- package/dist/testing/faults.d.ts +28 -0
- package/dist/testing/faults.js +16 -0
- package/dist/testing/index.d.ts +13 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/mock-cluster.d.ts +86 -0
- package/dist/testing/mock-cluster.js +160 -0
- package/dist/testing/mock-endpoint.d.ts +37 -0
- package/dist/testing/mock-endpoint.js +101 -0
- package/dist/testing/mock-jito.d.ts +44 -0
- package/dist/testing/mock-jito.js +94 -0
- package/dist/testing/rng.d.ts +11 -0
- package/dist/testing/rng.js +22 -0
- package/dist/tx/confirmation.d.ts +40 -0
- package/dist/tx/confirmation.js +56 -0
- package/dist/tx/sender.d.ts +57 -0
- package/dist/tx/sender.js +74 -0
- package/dist/wallet/adapter.d.ts +42 -0
- package/dist/wallet/adapter.js +34 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mihail Shumilov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# solana-resilience-kit
|
|
2
|
+
|
|
3
|
+
A vendor-neutral, **client-side resilience and observability layer for Solana dApps**, built on `@solana/kit` (web3.js v2). It unifies the reliability work that is today either left as a do-it-yourself recipe by the official SDK or locked inside a single provider: health-aware multi-RPC failover, a correct transaction send/confirm state machine, simulate-based fee/CU estimation, Jito/MEV routing with automatic RPC fallback, and standardized OpenTelemetry/Datadog telemetry β behind one clean API that works on top of any set of providers.
|
|
4
|
+
|
|
5
|
+
> **π¬ Live demo: [solana-rpc-sdk.pages.dev](https://solana-rpc-sdk.pages.dev)** β the *RPC Resilience Lab* runs the real SDK in your browser: inject faults against the simulation harness, flip the kit on/off to compare landing rates, or connect a wallet and land a real transaction on devnet. ([source](./demo))
|
|
6
|
+
|
|
7
|
+
- **Vendor-neutral** β works with any RPC provider; no gateway, no proprietary key required.
|
|
8
|
+
- **Correct by construction** β implements the send/confirm semantics most clients get wrong (no double-charge, bounded by `lastValidBlockHeight`).
|
|
9
|
+
- **Built on `@solana/kit`** β the pool *is* a kit `RpcTransport`, so it drops into existing kit code.
|
|
10
|
+
- **Deterministically tested** β an in-memory fault-injection cluster reproduces drops, expiry, 429s, desync, and MEV failures; 74 specs, coverage-gated.
|
|
11
|
+
- **Observable** β first-class client telemetry to OpenTelemetry / Datadog.
|
|
12
|
+
|
|
13
|
+
## Problem
|
|
14
|
+
|
|
15
|
+
Solana's reliability failures are not random bugs β they are direct consequences of four structural facts, and each needs a distinct client-side mitigation:
|
|
16
|
+
|
|
17
|
+
1. **No mempool.** RPC nodes forward a transaction straight to the upcoming leader over QUIC; there is no shared pending pool, so a dropped transaction leaves no trace and gets no automatic retry. ([Solana β Retry](https://solana.com/developers/guides/advanced/retry))
|
|
18
|
+
2. **Blockhash expiry.** A recent blockhash is valid for only ~150 blocks (~60β90 s); after that the transaction is permanently rejected. Re-signing *before* expiry can land both copies and **double-charge the user** β safe resend only happens once block height passes `lastValidBlockHeight`. ([Solana β Confirmation](https://solana.com/developers/guides/advanced/confirmation))
|
|
19
|
+
3. **Stake-weighted QoS (SWQoS).** Leaders reserve ~80% of inbound QUIC connections for staked validators and ~20% shared across all unstaked nodes, so unstaked submission is structurally disadvantaged under congestion. ([Helius β SWQoS](https://www.helius.dev/blog/stake-weighted-quality-of-service-everything-you-need-to-know))
|
|
20
|
+
4. **Localized fee markets.** Contention attaches to specific write-locked accounts, so a global fee number is a poor proxy for what *your* transaction needs. ([Helius β local fee markets](https://www.helius.dev/blog/solana-local-fee-markets))
|
|
21
|
+
|
|
22
|
+
These modes are dormant in calm conditions and resurface on every demand spike β the MarchβApril 2024 congestion drove non-vote failure rates near 75%. ([Cointelegraph](https://cointelegraph.com/news/solana-struggling-record-seventy-five-percent-trasnactions-fail-memecoin-mania)) Reliability therefore has to be engineered around each fact explicitly, not treated as best-effort.
|
|
23
|
+
|
|
24
|
+
## Pain points
|
|
25
|
+
|
|
26
|
+
| Pain | Who it hits | What this SDK does |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Silent transaction drop (no error, no trace) | end users, dApp devs | `TransactionSender` with bounded rebroadcast and block-height confirmation |
|
|
29
|
+
| Blockhash expiry / double-charge on resign | end users, dApp devs | outcome bounded by `lastValidBlockHeight`; never re-signs the transaction |
|
|
30
|
+
| 429 / credit exhaustion | anyone on public/shared RPC, indexers, bots | `CreditRateLimiter` (per-method weights) + pool failover |
|
|
31
|
+
| Node desync inside an RPC pool | every multi-provider dApp | `HealthMonitor` (slot-freshness ranking), routes to a fresh node |
|
|
32
|
+
| Priority-fee / compute-unit estimation | all devs, wallets, traders | `simulate β unitsConsumed + ~10%`, percentile fee oracle |
|
|
33
|
+
| MEV / frontrunning | DEX/memecoin swappers, bots | `JitoRouter` + dynamic tip + automatic fallback to RPC |
|
|
34
|
+
| Observability blind spot | infra/frontend engineers, wallets | client telemetry exported to OpenTelemetry / Datadog |
|
|
35
|
+
|
|
36
|
+
## Existing solutions & their shortcomings
|
|
37
|
+
|
|
38
|
+
The decisive finding: every robust mitigation today is **either a DIY recipe in the official SDK, or locked inside one provider's walled garden.**
|
|
39
|
+
|
|
40
|
+
| Tool / layer | Solves | Falls short |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| **`@solana/kit`** (web3.js v2) | Composable transports, better confirmation primitives, tree-shakable | Failover / round-robin / retry shipped only as **copy-paste recipes**; no Jito routing, no health-aware multi-RPC, no telemetry |
|
|
43
|
+
| **Helius / QuickNode / Triton** | Excellent landing (staked send), priority-fee & bundle APIs | **Provider lock-in** β needs their key and their gateway; server-side; doesn't unify across providers |
|
|
44
|
+
| **Jito** (bundles, low-latency send) | MEV protection, atomicity, tips | A provider service; a `bundle_id` is a receipt, **not a landing guarantee** β needs fallback + tip logic the dev must build |
|
|
45
|
+
| **`@solana/wallet-adapter`** | Wallet connect / sign / send handoff | **No resilience** β failover/retry/confirmation are explicitly the app's job |
|
|
46
|
+
| **OSS multi-RPC libs** | Thin failover wrappers | Narrow; none combine retry + confirmation + Jito + observability |
|
|
47
|
+
| **OpenTelemetry / Datadog** | Generic JSON-RPC spans, OTLP ingest | **No Solana-specific client instrumentation exists** |
|
|
48
|
+
|
|
49
|
+
**The white space:** a *vendor-neutral, client-side, systems-grade* layer that unifies all of the above behind one API on top of `@solana/kit` β which is exactly what this package provides.
|
|
50
|
+
|
|
51
|
+
## Modules
|
|
52
|
+
|
|
53
|
+
| Module | File | Responsibility |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `ResilientRpcPool` | `src/rpc/pool.ts` | Failover + freshness-aware routing behind one kit `RpcTransport`; per-request metrics |
|
|
56
|
+
| `HealthMonitor` | `src/rpc/health.ts` | Per-endpoint freshness/latency/error tracking; ejects laggards beyond `maxSlotLag` |
|
|
57
|
+
| `CreditRateLimiter` | `src/rpc/rate-limit.ts` | Weighted-credit token bucket to pre-empt 429s |
|
|
58
|
+
| `TransactionSender` | `src/tx/sender.ts` | Send/confirm state machine: `maxRetries:0`, bounded rebroadcast, **no re-sign** |
|
|
59
|
+
| `ConfirmationTracker` | `src/tx/confirmation.ts` | Decides outcome by block height vs `lastValidBlockHeight`, never polls forever |
|
|
60
|
+
| `FeeEstimator` + `NativeFeeOracle` / `HeliusFeeOracle` | `src/fees/*` | Simulate-based CU sizing + pluggable percentile fee oracle (native or Helius) |
|
|
61
|
+
| `JitoRouter` + `TipEstimator` | `src/jito/*` | Bundle routing, dynamic tips, automatic RPC fallback |
|
|
62
|
+
| `OtelMetrics` / `InMemoryMetrics` | `src/observability/metrics.ts` | Client telemetry (latency, failures, slot lag, landings) β OTel/Datadog |
|
|
63
|
+
| `ResilientWalletAdapter` | `src/wallet/adapter.ts` | Wallet-signed transactions through the resilient pipeline |
|
|
64
|
+
| `Diagnostics` | `src/cli/diagnose.ts` | Probe provider health; explain why a transaction did or didn't land |
|
|
65
|
+
|
|
66
|
+
## Architecture
|
|
67
|
+
|
|
68
|
+
```mermaid
|
|
69
|
+
flowchart LR
|
|
70
|
+
dApp[dApp] --> WA[ResilientWalletAdapter]
|
|
71
|
+
WA -->|signed wire tx| TS[TransactionSender]
|
|
72
|
+
TS <-->|send / confirm| POOL[ResilientRpcPool]
|
|
73
|
+
subgraph POOL_INTERNALS [ResilientRpcPool]
|
|
74
|
+
HM[HealthMonitor]
|
|
75
|
+
RL[CreditRateLimiter]
|
|
76
|
+
FO[failover + freshness routing]
|
|
77
|
+
end
|
|
78
|
+
POOL --> EP[(RPC endpoints)]
|
|
79
|
+
FE[FeeEstimator] -.priority fee / CU.-> TS
|
|
80
|
+
|
|
81
|
+
dApp --> JR[JitoRouter]
|
|
82
|
+
JR -->|bundle + tip| BE[Jito Block Engine]
|
|
83
|
+
JR -.fallback when bundle does not land.-> TS
|
|
84
|
+
|
|
85
|
+
TS --> M[OtelMetrics β OpenTelemetry / Datadog]
|
|
86
|
+
POOL --> M
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The pool exposes a real `@solana/kit` `RpcTransport`, so callers build a normal kit RPC with `pool.rpc()` and use it like any other β failover, freshness routing, and metrics happen underneath. The Jito path runs in parallel and **always falls back** to the resilient sender when a bundle does not land.
|
|
90
|
+
|
|
91
|
+
## Install
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npm install solana-resilience-kit @solana/kit
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Requires Node β₯ 20. The package is ESM-only and ships compiled JS with type declarations. `@solana/kit` is a peer of your app and is used directly in the API surface.
|
|
98
|
+
|
|
99
|
+
## Quickstart
|
|
100
|
+
|
|
101
|
+
Build a failover pool from two RPC endpoints and use it as a normal kit RPC:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { createDefaultRpcTransport } from "@solana/kit";
|
|
105
|
+
import { ResilientRpcPool, TransactionSender } from "solana-resilience-kit";
|
|
106
|
+
|
|
107
|
+
const pool = new ResilientRpcPool({
|
|
108
|
+
endpoints: [
|
|
109
|
+
{ name: "primary", transport: createDefaultRpcTransport({ url: PRIMARY_URL }) },
|
|
110
|
+
{ name: "backup", transport: createDefaultRpcTransport({ url: BACKUP_URL }) },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const rpc = pool.rpc(); // a normal @solana/kit RPC, failover underneath
|
|
115
|
+
const slot = await rpc.getSlot().send();
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Send a signed transaction with correct confirmation semantics:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const sender = new TransactionSender(rpc);
|
|
122
|
+
|
|
123
|
+
const result = await sender.sendAndConfirm({
|
|
124
|
+
wireTransaction, // base64, already signed (from getBase64EncodedWireTransaction)
|
|
125
|
+
signature, // from getSignatureFromTransaction
|
|
126
|
+
lastValidBlockHeight, // from the blockhash the tx was built with
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// result.outcome is "confirmed" or "expired" β decided by block height,
|
|
130
|
+
// not a timeout. The sender uses maxRetries:0, rebroadcasts the *same*
|
|
131
|
+
// signed bytes, never re-signs (so it can never double-charge), and treats a
|
|
132
|
+
// resend error on an already-landed tx as non-terminal.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Testing your own code against the fault harness
|
|
136
|
+
|
|
137
|
+
The deterministic Solana cluster simulator the SDK is tested with is shipped as a
|
|
138
|
+
secondary entry point, so you can drive *your* code through the same injected
|
|
139
|
+
faults (drops, expiry, 429s, slot lag) β no network, fully reproducible:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { MockCluster, MockEndpoint } from "solana-resilience-kit/testing";
|
|
143
|
+
import { createSolanaRpcFromTransport } from "@solana/kit";
|
|
144
|
+
|
|
145
|
+
const cluster = new MockCluster({ initialBlockHeight: 700n });
|
|
146
|
+
const endpoint = new MockEndpoint(cluster, { name: "sim" });
|
|
147
|
+
endpoint.faults = { dropRate: 1 }; // silently drop every send
|
|
148
|
+
const rpc = createSolanaRpcFromTransport(endpoint.transport);
|
|
149
|
+
|
|
150
|
+
// ...exercise your sender; advance time deterministically:
|
|
151
|
+
cluster.advanceSlots(160); // push past lastValidBlockHeight
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Interactive demo
|
|
155
|
+
|
|
156
|
+
**Live at [solana-rpc-sdk.pages.dev](https://solana-rpc-sdk.pages.dev)** Β· source in [`demo/`](./demo)
|
|
157
|
+
|
|
158
|
+
**RPC Resilience Lab** is a backend-free Vite + React app that runs the *real* SDK in your browser. In **simulation** mode it drives the fault harness (inject drops / 429s / lag / Jito failure and flip the SDK on/off to compare landing rates against a naive client); in **devnet** mode it connects a standard wallet (`@solana/wallet-adapter`), signs a real transfer, and lands it through the SDK with an explorer link.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
cd demo && npm install && npm run dev
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A headless Node example is in [`examples/devnet-demo.ts`](./examples/devnet-demo.ts) (`npx tsx examples/devnet-demo.ts`).
|
|
165
|
+
|
|
166
|
+
## Testing & simulation
|
|
167
|
+
|
|
168
|
+
Solana's failure modes β silent drops, blockhash expiry, 429s, lagging-node desync, MEV β cannot be reproduced reliably against live infrastructure, so the SDK is tested against an in-memory, deterministic model of a Solana cluster that injects exactly these faults.
|
|
169
|
+
|
|
170
|
+
- **Real `@solana/kit` integration.** Each simulated endpoint exposes a real kit `RpcTransport`; a harness self-test signs an actual kit transaction and verifies our wire-format signature extraction matches `getSignatureFromTransaction` β so web3.js-v2 compatibility is *proven*, not assumed.
|
|
171
|
+
- **Manual clock.** Nothing advances unless a test calls `cluster.advanceSlots(n)`, making blockhash-expiry and rebroadcast timing deterministic.
|
|
172
|
+
- **Seeded faults.** A seeded PRNG drives drops / 429s / latency / slot lag, so every failing sequence is reproducible.
|
|
173
|
+
- **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.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm test # full suite (harness + all modules), 74 specs
|
|
177
|
+
npm run test:cov # coverage with the thresholds enforced
|
|
178
|
+
npm run typecheck # tsc --noEmit
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Coverage thresholds (`vitest.config.ts`) are **lines 90 / functions 90 / branches 85 / statements 90**, and the suite passes them. A fully reproducible Docker environment is available via the [`Makefile`](./Makefile):
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
make verify # typecheck + always-green harness/metrics tests, in Docker
|
|
185
|
+
make test # typecheck + full suite, in Docker
|
|
186
|
+
make cov # coverage report (writes ./coverage)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Building from source
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm run build # emit dist/ β compiled JS + .d.ts for `.` and `./testing`
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The published package contains only `dist/`, `README.md`, and `LICENSE`. `prepublishOnly` re-runs typecheck + tests + build before any publish.
|
|
196
|
+
|
|
197
|
+
## Project layout
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
src/ public API β the SDK modules
|
|
201
|
+
test/ behavioral specs + test/harness/ (the simulation cluster)
|
|
202
|
+
demo/ RPC Resilience Lab (Vite + React browser app)
|
|
203
|
+
examples/ headless devnet example
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
[MIT](./LICENSE)
|
|
209
|
+
</content>
|
|
210
|
+
</invoke>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics β the injectable, deterministic core behind the `diagnose` CLI.
|
|
3
|
+
*
|
|
4
|
+
* It answers the two questions an operator asks when a Solana dApp misbehaves:
|
|
5
|
+
* 1. probeEndpoints β which providers are healthy and which is freshest?
|
|
6
|
+
* (reuses HealthMonitor so the freshness ranking matches the pool's own
|
|
7
|
+
* routing logic β the "fresh blockhash from an advanced node sent to a
|
|
8
|
+
* lagging node drops the tx" failure mode is judged the same way here.)
|
|
9
|
+
* 2. explainTransaction β point-in-time, did a transaction land, expire, or is
|
|
10
|
+
* it still in its validity window? The verdict is the canonical Solana rule
|
|
11
|
+
* (current block height vs lastValidBlockHeight), mirrored from
|
|
12
|
+
* ConfirmationTracker, with NO polling loop.
|
|
13
|
+
*
|
|
14
|
+
* The argv/console/real-RPC wiring of the binary is integration-only; nothing in
|
|
15
|
+
* this module executes at import time.
|
|
16
|
+
*/
|
|
17
|
+
import type { Rpc, SolanaRpcApi } from "@solana/kit";
|
|
18
|
+
import { HealthMonitor } from "../rpc/health.js";
|
|
19
|
+
export interface ProbeTarget {
|
|
20
|
+
name: string;
|
|
21
|
+
rpc: Rpc<SolanaRpcApi>;
|
|
22
|
+
}
|
|
23
|
+
export interface EndpointProbe {
|
|
24
|
+
name: string;
|
|
25
|
+
ok: boolean;
|
|
26
|
+
slot: bigint | null;
|
|
27
|
+
latencyMs: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ProbeReport {
|
|
31
|
+
endpoints: EndpointProbe[];
|
|
32
|
+
/** Name of the freshest healthy endpoint, or null when none are healthy. */
|
|
33
|
+
freshest: string | null;
|
|
34
|
+
/** Endpoints that responded successfully in this probe round. */
|
|
35
|
+
healthyCount: number;
|
|
36
|
+
}
|
|
37
|
+
export type TxDiagnosis = {
|
|
38
|
+
status: "confirmed";
|
|
39
|
+
slot: bigint;
|
|
40
|
+
} | {
|
|
41
|
+
status: "expired";
|
|
42
|
+
reason: string;
|
|
43
|
+
} | {
|
|
44
|
+
status: "pending";
|
|
45
|
+
reason: string;
|
|
46
|
+
};
|
|
47
|
+
export interface DiagnosticsDeps {
|
|
48
|
+
/** Inject a shared HealthMonitor to fold probe results into existing state. */
|
|
49
|
+
healthMonitor?: HealthMonitor;
|
|
50
|
+
}
|
|
51
|
+
export interface ExplainTxOptions {
|
|
52
|
+
signature: string;
|
|
53
|
+
lastValidBlockHeight: bigint;
|
|
54
|
+
/** Target commitment (informational; landing is decided by status presence). */
|
|
55
|
+
commitment?: "confirmed" | "finalized";
|
|
56
|
+
}
|
|
57
|
+
export declare class Diagnostics {
|
|
58
|
+
private readonly deps?;
|
|
59
|
+
constructor(deps?: DiagnosticsDeps | undefined);
|
|
60
|
+
/**
|
|
61
|
+
* Probes every target's current slot, recording latency and health into a
|
|
62
|
+
* HealthMonitor so the freshness ranking is identical to the pool's. An
|
|
63
|
+
* endpoint that throws (offline / errored) is flagged ok:false with slot null
|
|
64
|
+
* and never ranked.
|
|
65
|
+
*/
|
|
66
|
+
probeEndpoints(targets: ProbeTarget[]): Promise<ProbeReport>;
|
|
67
|
+
/**
|
|
68
|
+
* Point-in-time verdict on a transaction. No polling: it inspects the current
|
|
69
|
+
* signature status and current block height once.
|
|
70
|
+
*
|
|
71
|
+
* Order matters β a status check wins over the expiry bound, because a tx can
|
|
72
|
+
* land exactly at the deadline block (same precedence as ConfirmationTracker).
|
|
73
|
+
*/
|
|
74
|
+
explainTransaction(rpc: Rpc<SolanaRpcApi>, opts: ExplainTxOptions): Promise<TxDiagnosis>;
|
|
75
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { HealthMonitor } from "../rpc/health.js";
|
|
2
|
+
export class Diagnostics {
|
|
3
|
+
deps;
|
|
4
|
+
constructor(deps) {
|
|
5
|
+
this.deps = deps;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Probes every target's current slot, recording latency and health into a
|
|
9
|
+
* HealthMonitor so the freshness ranking is identical to the pool's. An
|
|
10
|
+
* endpoint that throws (offline / errored) is flagged ok:false with slot null
|
|
11
|
+
* and never ranked.
|
|
12
|
+
*/
|
|
13
|
+
async probeEndpoints(targets) {
|
|
14
|
+
const hm = this.deps?.healthMonitor ??
|
|
15
|
+
new HealthMonitor({ endpointNames: targets.map((t) => t.name) });
|
|
16
|
+
const endpoints = await Promise.all(targets.map(async (target) => {
|
|
17
|
+
const startedAt = Date.now();
|
|
18
|
+
try {
|
|
19
|
+
const slot = await target.rpc.getSlot().send();
|
|
20
|
+
const latencyMs = Date.now() - startedAt;
|
|
21
|
+
hm.recordSuccess(target.name, latencyMs, slot);
|
|
22
|
+
return { name: target.name, ok: true, slot, latencyMs };
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
const latencyMs = Date.now() - startedAt;
|
|
26
|
+
hm.recordFailure(target.name, err);
|
|
27
|
+
return {
|
|
28
|
+
name: target.name,
|
|
29
|
+
ok: false,
|
|
30
|
+
slot: null,
|
|
31
|
+
latencyMs,
|
|
32
|
+
error: String(err?.message ?? err),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}));
|
|
36
|
+
const freshest = hm.rankByFreshness()[0] ?? null;
|
|
37
|
+
const healthyCount = endpoints.filter((e) => e.ok).length;
|
|
38
|
+
return { endpoints, freshest, healthyCount };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Point-in-time verdict on a transaction. No polling: it inspects the current
|
|
42
|
+
* signature status and current block height once.
|
|
43
|
+
*
|
|
44
|
+
* Order matters β a status check wins over the expiry bound, because a tx can
|
|
45
|
+
* land exactly at the deadline block (same precedence as ConfirmationTracker).
|
|
46
|
+
*/
|
|
47
|
+
async explainTransaction(rpc, opts) {
|
|
48
|
+
const signature = opts.signature;
|
|
49
|
+
const status = (await rpc.getSignatureStatuses([signature]).send()).value[0];
|
|
50
|
+
if (status != null &&
|
|
51
|
+
status.confirmationStatus != null &&
|
|
52
|
+
status.err == null) {
|
|
53
|
+
return { status: "confirmed", slot: status.slot };
|
|
54
|
+
}
|
|
55
|
+
const blockHeight = await rpc.getBlockHeight().send();
|
|
56
|
+
if (blockHeight > opts.lastValidBlockHeight) {
|
|
57
|
+
return {
|
|
58
|
+
status: "expired",
|
|
59
|
+
reason: `block height ${blockHeight} exceeded lastValidBlockHeight ${opts.lastValidBlockHeight}; ` +
|
|
60
|
+
"the blockhash expired before the transaction landed (silent drop or congestion). " +
|
|
61
|
+
"Rebuild with a fresh blockhash β do NOT re-sign the same one.",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
status: "pending",
|
|
66
|
+
reason: `still within the validity window (height ${blockHeight} <= ${opts.lastValidBlockHeight}); ` +
|
|
67
|
+
"keep rebroadcasting the same signed transaction until it lands or expires.",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Error taxonomy for the resilience kit. Distinct types so callers can branch. */
|
|
2
|
+
export declare class SdkError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/** Thrown by every stub until the implementation phase fills it in. */
|
|
6
|
+
export declare class NotImplementedError extends SdkError {
|
|
7
|
+
constructor(what?: string);
|
|
8
|
+
}
|
|
9
|
+
/** A transaction's blockhash expired before it landed (terminal, not retryable). */
|
|
10
|
+
export declare class TransactionExpiredError extends SdkError {
|
|
11
|
+
readonly signature: string;
|
|
12
|
+
readonly lastValidBlockHeight: bigint;
|
|
13
|
+
constructor(signature: string, lastValidBlockHeight: bigint);
|
|
14
|
+
}
|
|
15
|
+
/** Every endpoint in the pool failed for a single logical request. */
|
|
16
|
+
export declare class AllEndpointsFailedError extends SdkError {
|
|
17
|
+
readonly attempts: ReadonlyArray<{
|
|
18
|
+
endpoint: string;
|
|
19
|
+
error: unknown;
|
|
20
|
+
}>;
|
|
21
|
+
constructor(attempts: ReadonlyArray<{
|
|
22
|
+
endpoint: string;
|
|
23
|
+
error: unknown;
|
|
24
|
+
}>);
|
|
25
|
+
}
|
|
26
|
+
/** A Jito bundle did not land before its deadline; caller should fall back. */
|
|
27
|
+
export declare class BundleNotLandedError extends SdkError {
|
|
28
|
+
readonly bundleId: string;
|
|
29
|
+
constructor(bundleId: string);
|
|
30
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Error taxonomy for the resilience kit. Distinct types so callers can branch. */
|
|
2
|
+
export class SdkError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = new.target.name;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** Thrown by every stub until the implementation phase fills it in. */
|
|
9
|
+
export class NotImplementedError extends SdkError {
|
|
10
|
+
constructor(what = "not implemented") {
|
|
11
|
+
super(what);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** A transaction's blockhash expired before it landed (terminal, not retryable). */
|
|
15
|
+
export class TransactionExpiredError extends SdkError {
|
|
16
|
+
signature;
|
|
17
|
+
lastValidBlockHeight;
|
|
18
|
+
constructor(signature, lastValidBlockHeight) {
|
|
19
|
+
super(`transaction ${signature} expired at block height ${lastValidBlockHeight}`);
|
|
20
|
+
this.signature = signature;
|
|
21
|
+
this.lastValidBlockHeight = lastValidBlockHeight;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Every endpoint in the pool failed for a single logical request. */
|
|
25
|
+
export class AllEndpointsFailedError extends SdkError {
|
|
26
|
+
attempts;
|
|
27
|
+
constructor(attempts) {
|
|
28
|
+
super(`all ${attempts.length} endpoint attempt(s) failed`);
|
|
29
|
+
this.attempts = attempts;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** A Jito bundle did not land before its deadline; caller should fall back. */
|
|
33
|
+
export class BundleNotLandedError extends SdkError {
|
|
34
|
+
bundleId;
|
|
35
|
+
constructor(bundleId) {
|
|
36
|
+
super(`bundle ${bundleId} did not land`);
|
|
37
|
+
this.bundleId = bundleId;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FeeEstimator β turns a transaction into a (compute-unit limit, priority-fee
|
|
3
|
+
* price) pair. CU limit comes from simulation (unitsConsumed) plus a safety
|
|
4
|
+
* margin; the priority-fee price comes from a pluggable FeeOracle. Correct CU
|
|
5
|
+
* sizing matters because the fee is charged on the *requested* limit.
|
|
6
|
+
*/
|
|
7
|
+
import type { Rpc, SolanaRpcApi } from "@solana/kit";
|
|
8
|
+
import type { FeeOracle, FeeLevel } from "./oracles.js";
|
|
9
|
+
export interface ComputeBudget {
|
|
10
|
+
/** Recommended setComputeUnitLimit value. */
|
|
11
|
+
computeUnitLimit: number;
|
|
12
|
+
/** Recommended setComputeUnitPrice value (micro-lamports per CU). */
|
|
13
|
+
computeUnitPrice: number;
|
|
14
|
+
/** Resulting priority fee in lamports = ceil(limit * price / 1e6). */
|
|
15
|
+
priorityFeeLamports: number;
|
|
16
|
+
}
|
|
17
|
+
export interface EstimateConfig {
|
|
18
|
+
/** Base64 wire transaction to simulate for unitsConsumed. */
|
|
19
|
+
wireTransaction: string;
|
|
20
|
+
writableAccounts: string[];
|
|
21
|
+
level?: FeeLevel;
|
|
22
|
+
/** Multiplier applied to simulated CU (default 1.1 = +10% margin). */
|
|
23
|
+
cuMargin?: number;
|
|
24
|
+
}
|
|
25
|
+
export declare class FeeEstimator {
|
|
26
|
+
private readonly rpc;
|
|
27
|
+
private readonly oracle;
|
|
28
|
+
constructor(rpc: Rpc<SolanaRpcApi>, oracle: FeeOracle);
|
|
29
|
+
/**
|
|
30
|
+
* Simulates the tx and returns unitsConsumed (no margin applied).
|
|
31
|
+
*
|
|
32
|
+
* We use `replaceRecentBlockhash: true` so the simulation does not fail on a
|
|
33
|
+
* stale/expired blockhash, and we never verify signatures β the only thing we
|
|
34
|
+
* care about here is the compute-unit count the program would burn.
|
|
35
|
+
*/
|
|
36
|
+
simulateComputeUnits(wireTransaction: string): Promise<number>;
|
|
37
|
+
/**
|
|
38
|
+
* Full compute-budget recommendation (CU limit + price + resulting fee).
|
|
39
|
+
*
|
|
40
|
+
* The CU limit is the simulated consumption scaled by a safety margin
|
|
41
|
+
* (`Math.round`, not `ceil` β `6000 * 1.1` is `6600.000000000001` in IEEE-754,
|
|
42
|
+
* and rounding lands on the intended 6600). The price comes from the oracle at
|
|
43
|
+
* the requested level. The resulting fee is `ceil(limit * price / 1e6)` because
|
|
44
|
+
* the network charges priority fee on the *requested* limit in micro-lamports.
|
|
45
|
+
*/
|
|
46
|
+
estimate(config: EstimateConfig): Promise<ComputeBudget>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export class FeeEstimator {
|
|
2
|
+
rpc;
|
|
3
|
+
oracle;
|
|
4
|
+
constructor(rpc, oracle) {
|
|
5
|
+
this.rpc = rpc;
|
|
6
|
+
this.oracle = oracle;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Simulates the tx and returns unitsConsumed (no margin applied).
|
|
10
|
+
*
|
|
11
|
+
* We use `replaceRecentBlockhash: true` so the simulation does not fail on a
|
|
12
|
+
* stale/expired blockhash, and we never verify signatures β the only thing we
|
|
13
|
+
* care about here is the compute-unit count the program would burn.
|
|
14
|
+
*/
|
|
15
|
+
async simulateComputeUnits(wireTransaction) {
|
|
16
|
+
const { value } = await this.rpc
|
|
17
|
+
.simulateTransaction(wireTransaction, {
|
|
18
|
+
encoding: "base64",
|
|
19
|
+
replaceRecentBlockhash: true,
|
|
20
|
+
})
|
|
21
|
+
.send();
|
|
22
|
+
return Number(value.unitsConsumed ?? 0);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Full compute-budget recommendation (CU limit + price + resulting fee).
|
|
26
|
+
*
|
|
27
|
+
* The CU limit is the simulated consumption scaled by a safety margin
|
|
28
|
+
* (`Math.round`, not `ceil` β `6000 * 1.1` is `6600.000000000001` in IEEE-754,
|
|
29
|
+
* and rounding lands on the intended 6600). The price comes from the oracle at
|
|
30
|
+
* the requested level. The resulting fee is `ceil(limit * price / 1e6)` because
|
|
31
|
+
* the network charges priority fee on the *requested* limit in micro-lamports.
|
|
32
|
+
*/
|
|
33
|
+
async estimate(config) {
|
|
34
|
+
const units = await this.simulateComputeUnits(config.wireTransaction);
|
|
35
|
+
const cuMargin = config.cuMargin ?? 1.1;
|
|
36
|
+
const computeUnitLimit = Math.round(units * cuMargin);
|
|
37
|
+
const level = config.level ?? "medium";
|
|
38
|
+
const est = await this.oracle.getPriorityFee(config.writableAccounts);
|
|
39
|
+
const computeUnitPrice = est.levels[level];
|
|
40
|
+
const priorityFeeLamports = Math.ceil((computeUnitLimit * computeUnitPrice) / 1_000_000);
|
|
41
|
+
return { computeUnitLimit, computeUnitPrice, priorityFeeLamports };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fee oracles β pluggable sources of priority-fee estimates. The native oracle
|
|
3
|
+
* uses `getRecentPrioritizationFees` (free, backward-looking minimum); the
|
|
4
|
+
* Helius/QuickNode oracles call account-aware percentile APIs. Vendor neutrality
|
|
5
|
+
* means the SDK works with any of them behind one interface.
|
|
6
|
+
*/
|
|
7
|
+
import type { Rpc, SolanaRpcApi } from "@solana/kit";
|
|
8
|
+
export type FeeLevel = "min" | "low" | "medium" | "high" | "veryHigh";
|
|
9
|
+
export interface PriorityFeeEstimate {
|
|
10
|
+
/** micro-lamports per compute unit, keyed by level. */
|
|
11
|
+
levels: Record<FeeLevel, number>;
|
|
12
|
+
}
|
|
13
|
+
export interface FeeOracle {
|
|
14
|
+
/** Estimate priority fee given the writable accounts the tx will touch. */
|
|
15
|
+
getPriorityFee(writableAccounts: string[]): Promise<PriorityFeeEstimate>;
|
|
16
|
+
}
|
|
17
|
+
/** Native estimate from getRecentPrioritizationFees over recent slots. */
|
|
18
|
+
export declare class NativeFeeOracle implements FeeOracle {
|
|
19
|
+
private readonly rpc;
|
|
20
|
+
constructor(rpc: Rpc<SolanaRpcApi>);
|
|
21
|
+
/**
|
|
22
|
+
* Derives micro-lamports-per-CU percentiles from the cluster's recent
|
|
23
|
+
* prioritization-fee samples (`getRecentPrioritizationFees`). This is the
|
|
24
|
+
* free, backward-looking source: the node returns the smallest fee paid by a
|
|
25
|
+
* landed tx per recent slot. We sort the samples and pick percentiles by
|
|
26
|
+
* nearest-rank so the levels are monotonic.
|
|
27
|
+
*/
|
|
28
|
+
getPriorityFee(writableAccounts: string[]): Promise<PriorityFeeEstimate>;
|
|
29
|
+
}
|
|
30
|
+
export interface HttpFeeOracleConfig {
|
|
31
|
+
url: string;
|
|
32
|
+
apiKey?: string;
|
|
33
|
+
fetchImpl?: typeof fetch;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Helius `getPriorityFeeEstimate` β account-aware percentile estimates. Helius
|
|
37
|
+
* already returns micro-lamports-per-CU figures keyed by the same level names we
|
|
38
|
+
* expose (`priorityFeeLevels`), so the mapping is direct. `fetchImpl` is
|
|
39
|
+
* injectable for deterministic tests and defaults to the global `fetch`; the
|
|
40
|
+
* API key, when provided, is appended to the request URL.
|
|
41
|
+
*/
|
|
42
|
+
export declare class HeliusFeeOracle implements FeeOracle {
|
|
43
|
+
private readonly config;
|
|
44
|
+
constructor(config: HttpFeeOracleConfig);
|
|
45
|
+
getPriorityFee(writableAccounts: string[]): Promise<PriorityFeeEstimate>;
|
|
46
|
+
}
|