27 Commits

Author SHA1 Message Date
f7acac3fc8 v0.2.3-rel.001 2026-08-18 17:42:12 +02:00
039b93cfb3 v0.2.3-pre.009 2026-08-18 17:33:46 +02:00
c9a0f7fd37 v0.2.3-pre.008 2026-08-18 16:47:54 +02:00
704110d509 v0.2.3-pre.007 2026-08-18 16:12:07 +02:00
fb32ed03f8 v0.2.3-pre.006-fix.001 2026-08-18 12:40:24 +02:00
4439589f0d v0.2.3-pre.006 2026-08-18 12:35:29 +02:00
1e1bc0d421 v0.2.3-pre.005 2026-08-18 12:33:16 +02:00
bd26d4f636 v0.2.3-pre.004 2026-08-18 12:14:59 +02:00
2a7f3e8f40 v0.2.3-pre.003 2026-08-18 11:35:51 +02:00
1d37f04d63 v0.2.3-pre.002-fix.001 2026-08-18 11:27:24 +02:00
1ccf11bcc1 v0.2.3-pre.002 2026-08-18 11:13:04 +02:00
d06284fca2 v0.2.3-pre.001 2026-08-18 10:47:19 +02:00
fff69ead9c v0.2.2-rel.001 2026-08-18 10:14:43 +02:00
c93a833bac v0.2.2-pre.007-fix.002 2026-08-18 10:04:18 +02:00
05e0e96083 v0.2.2-pre.007-fix.001 2026-08-18 09:56:55 +02:00
b57f796187 v0.2.2-pre.007 2026-08-18 09:48:11 +02:00
ecfb9500eb v0.2.2-pre.006-fix.001 2026-08-18 09:37:03 +02:00
e14ce1c36f v0.2.2-pre.006 2026-08-18 08:27:42 +02:00
83cb861e54 v0.2.2-pre.005 2026-08-18 08:17:10 +02:00
1d1bc6a4d6 v0.2.2-pre.004 2026-08-18 08:09:20 +02:00
c4636ac8b9 v0.2.2-pre.003-fix.001 2026-08-18 07:53:17 +02:00
f15448ff6e v0.2.2-pre.003 2026-08-18 07:31:07 +02:00
bec1f2ec08 v0.2.2-pre.002-fix.002 2026-08-18 07:20:44 +02:00
f625ee5979 v0.2.2-pre.002-fix.001 2026-08-18 07:17:49 +02:00
e68f073505 v0.2.2-pre.002 2026-08-18 07:15:59 +02:00
bffb4f9a31 v0.2.2-pre.001-fix.001 2026-08-18 06:46:59 +02:00
9059a2dc45 v0.2.2-pre.001 2026-08-18 06:41:41 +02:00
140 changed files with 16916 additions and 125 deletions

View File

@@ -1,10 +1,18 @@
<!-- file: CHANGELOG.md -->
<!-- version: 5 -->
<!-- version: 7 -->
# Changelog KSP
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
## 0.2.3 — HTTP Transactions — 2026-08-18
`0.2.3` complète la famille HTTP Transactions de `ksp-onchain-transport-lib` avec 11 wrappers typés, portant la surface HTTP typée courante à 37 méthodes : 4 foundation, 22 Accounts/Tokens/Cluster et 11 Transactions. La release stabilise la classification exacte `8 Read / RetrySafe`, `2 WriteSubmission / NeverAfterDispatch` (`requestAirdrop`, `sendTransaction`) et `1 Simulation / RetrySafe` (`simulateTransaction`), avec preuves end-to-end qu'une write submission n'est jamais resoumise après dispatch ambigu mais peut retenter lorsqu'un échec est prouvé `NotDispatched`. `getTransaction` couvre la config moderne complète et la forme bare encoding legacy dépréciée ; les variantes de transaction, metadata, version et `transactionIndex` restent préservées losslessly. `simulateTransaction` couvre sa config complète, ses invariants déterministes et son résultat riche sans introduire de client RPC Solana haut niveau ni de décodage transactionnel dans Transport. La release formalise `KSP-TRANSPORT-007` et réaudite rétroactivement les 37 wrappers courants sans remédiation fonctionnelle. Les canaries conservent 52 méthodes HTTP courantes, 14 historiques Deprecated/Removed et 15 Blocks/Economics réservées à `0.2.4`. Les graphes Cargo et les deux smokes Devnet — Transport pur read-only et composition Config -> Transport transitoire — ont été validés avant publication. Le prompt `prompts/009-V0_2_4_START_PROMPT.md` ouvre `0.2.4 — HTTP Blocks + Economics + compliance HTTP finale`.
## 0.2.2 — HTTP Accounts + Tokens + Cluster — 2026-08-18
`0.2.2` complète la surface HTTP typée de `ksp-onchain-transport-lib` avec 22 wrappers supplémentaires : 5 Accounts, 5 Tokens et 12 Cluster, portant la couverture typée totale à 26 méthodes avec les quatre canaris foundation de `0.2.1`. La release stabilise les DTOs/configs wire communs, préserve les formes Account encodées/`jsonParsed`, les `null` et champs optionnels, les selectors Token exclusifs, les contextes RPC, les structures Cluster/leader/vote et les limites locales auditées (`getMultipleAccounts <= 100`, `getProgramAccounts <= 4` filtres, `memcmp` raw <= 128 octets, `getSlotLeaders` entre 1 et 5000). Les 22 descriptors restent `Read / RetrySafe` et passent exclusivement par le flux central `descriptor -> execute_standard_rpc -> pool/admission -> executor HTTP`. La release n'ajoute aucune dépendance externe, ne crée aucun client HTTP parallèle et conserve le firewall Transport -> Config/Store/Program. Elle ajoute un smoke Devnet pur à Transport avec settings programmatiques couvrant Accounts/Tokens/Cluster, conserve séparément le smoke historique Config -> Transport comme exception transitoire, et valide les deux explicitement avant publication. Les canaries figent toujours 52 méthodes HTTP courantes, 14 historiques Deprecated/Removed et la partition typed `4 / 22 / 11 / 15`. Le prompt `prompts/008-V0_2_3_START_PROMPT.md` ouvre `0.2.3 — HTTP Transactions`.
## 0.2.1 — HTTP Solana foundation — 2026-08-17
`0.2.1` stabilise `ksp-onchain-transport-lib` comme foundation HTTP JSON-RPC Solana provider-neutral : settings publics, endpoints/pool/rôles, priorités et fairness, RPS/burst/concurrence/cooldown, deadline commune, retry/backoff, classification no-resend après dispatch ambigu, snapshots sûrs et exécution HTTP réelle via `reqwest`/rustls. La release fige un registre audité de 52 méthodes HTTP courantes et 14 méthodes historiques Deprecated/Removed, avec quatre wrappers typés canari (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) et une partition explicite des 48 méthodes restantes sur `0.2.2``0.2.4`. Elle ajoute `std.transport`, son schema/exemple et l'adapter `ksp-config-lib -> ksp-onchain-transport-lib`, sans dépendance inverse, ainsi que la redaction des URLs/provider credentials, la neutralisation des URLs contenues dans les `reqwest::Error`, un sink Logging Transport dédié à `info`, des fixtures HTTP déterministes, des canaries de complétude et un smoke Devnet opt-in validant la composition Config -> Transport. Le smoke cross-crates reste temporairement hébergé dans Config et doit migrer vers une future surface d'intégration/orchestration ; ce placement n'est pas un modèle pour les futurs smokes `Config + autre crate`. Le prompt `prompts/007-V0_2_2_START_PROMPT.md` ouvre `0.2.2 — HTTP Accounts + Tokens + Cluster`.

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 109
# version: 131
[workspace]
resolver = "3"
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib"]
[workspace.package]
version = "0.2.1"
version = "0.2.3"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md -->
<!-- version: 34 -->
<!-- version: 40 -->
# Roadmap KSP
@@ -46,8 +46,8 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
### Releases fonctionnelles décidées/pressenties
- [X] `0.2.1`**HTTP transport foundation réduite par le gate `pre.001`** : crate/settings/JSON-RPC/registry 52 current + 14 deprecated historiques, pool/rôles/limites/retry, Config adapter, documentation et 4 méthodes typées canari (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) publiés stables.
- [ ] `0.2.2` Compléter HTTP Accounts + Tokens + Cluster : 5 méthodes Accounts restantes + 5 Tokens + 12 Cluster restantes, soit 22 méthodes.
- [ ] `0.2.3`Compléter les 11 méthodes HTTP Transactions, y compris write/submission technique avec politique no-resend ambigu.
- [X] `0.2.2` — HTTP Accounts + Tokens + Cluster : 22 wrappers typés (5 Accounts + 5 Tokens + 12 Cluster), canaries de complétude 52+14, smoke Devnet Transport pur et smoke historique Config -> Transport validés, documentation durable et prompt `0.2.3` publiés stables.
- [X] `0.2.3`HTTP Transactions stable : 11/11 wrappers typés publiés, classification `8 Read / 2 WriteSubmission / 1 Simulation`, no-resend ambigu prouvé pour les write submissions, `KSP-TRANSPORT-007` réaudité conforme sur les 37 wrappers HTTP courants, graphes Cargo et deux smokes Devnet validés ; `0.2.4` reprend les 15 Blocks/Economics restants.
- [ ] `0.2.4` — Compléter les 10 méthodes HTTP Blocks + 5 Economics et exécuter la compliance finale de toute la surface HTTP 52 current + 14 deprecated historiques.
- [ ] `0.2.5` — Introduire `ksp-wallet-lib`, le format `.kspwallet`, la gestion sûre des secrets et une architecture d'import/export extensible ; exclure `WalletPolicy`.
- [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP, notamment pour afficher l'identité et le solde d'un wallet.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
<!-- version: 1 -->
<!-- version: 7 -->
# `ksp-onchain-transport-lib`
@@ -69,16 +69,21 @@ Le registre porte notamment :
- remplacement historique éventuel ;
- release de couverture typée KSP.
Les quatre wrappers typés de la foundation sont :
La release stable `0.2.3` porte la surface typée à **37 méthodes courantes** :
```text
getBalance
getGenesisHash
getHealth
getVersion
0.2.1 foundation : 4
0.2.2 Accounts : 5
0.2.2 Tokens : 5
0.2.2 Cluster : 12
0.2.3 Transactions : 11
```
Les autres méthodes courantes peuvent déjà passer par l'exécuteur JSON-RPC standard générique lorsqu'un consumer fournit explicitement descriptor et paramètres JSON. Cette surface raw/générique **ne vaut pas couverture typée** : les wrappers et DTOs typés restants sont introduits selon la matrice HTTP KSP.
Les quatre canaris foundation restent `getBalance`, `getGenesisHash`, `getHealth` et `getVersion`. `0.2.2` ajoute les 22 wrappers Accounts/Tokens/Cluster. `0.2.3` ajoute les 11 wrappers Transactions, dont `requestAirdrop` et `sendTransaction` en `WriteSubmission / NeverAfterDispatch` et `simulateTransaction` en `Simulation / RetrySafe`.
Les **15 méthodes Blocks/Economics** encore affectées à `0.2.4` peuvent déjà passer par l'exécuteur JSON-RPC standard générique lorsqu'un consumer fournit explicitement descriptor et paramètres JSON, mais cette surface raw/générique **ne vaut pas couverture typée**.
`KSP-TRANSPORT-007` impose qu'un wrapper typé couvre toutes les possibilités RPC supportées retenues par l'audit : paramètres/options, overloads et formes legacy encore supportées, contraintes déterministes utiles et variantes de réponse pertinentes sans perte. Le réaudit `0.2.3-pre.008` confirme cette complétude sur les 37 wrappers courants.
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
@@ -118,18 +123,27 @@ La configuration Logging de référence conserve un fichier dédié Transport à
Les tests par défaut sont déterministes et n'exigent pas Internet : fixtures JSON et serveur HTTP local couvrent requêtes, réponses, retry, 429, timeout, redaction et routing.
Un smoke Devnet live existe côté `ksp-config-lib` afin de tester la chaîne réelle :
Deux smokes Devnet opt-in sont séparés par responsabilité :
```text
Config -> std.transport/devnet_public -> HttpTransportPool
Transport pur : settings programmatiques -> HttpTransportPool
-> Accounts/Tokens/Cluster représentatifs
-> getLatestBlockhash/isBlockhashValid/getTransactionCount
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
-> getHealth/getGenesisHash/getVersion/getBalance
```
Il est `ignored` par défaut et doit être exécuté explicitement.
Le smoke Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide.
Les deux sont `ignored` par défaut. Le smoke Transport appartient durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée.
## Documentation
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw et inspection runtime ;
- [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — plan et matrice HTTP ;
- [`../../docs/validation/003-V0_2_1_ONCHAIN_HTTP.md`](../../docs/validation/003-V0_2_1_ONCHAIN_HTTP.md) — matrice de clôture ;
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw, smokes et inspection runtime ;
- [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — foundation HTTP stable ;
- [`../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — extension typed Accounts/Tokens/Cluster ;
- [`../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) — matrice finale validée `0.2.2` ;
- [`../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé de `0.2.3` ;
- [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée `0.2.3` ;
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
<!-- version: 1 -->
<!-- version: 7 -->
# Utilisation de `ksp-onchain-transport-lib`
@@ -86,17 +86,34 @@ let balance = pool
.await;
```
Les types de retour associés sont :
Les quatre canaris `0.2.1` restent disponibles. `0.2.2` ajoute les wrappers typés Accounts, Tokens et Cluster. Exemples représentatifs :
```text
SolanaNodeHealth
SolanaGenesisHash
SolanaNodeVersion
GetBalanceResult
SolanaRpcContext
```rust
let account = pool
.get_account_info(&role, &ksp_core_lib::PRGIDPK_SOLANA_SYSTEM, None)
.await;
let epoch = pool.get_epoch_info(&role, None).await;
let vote_accounts = pool.get_vote_accounts(&role, None).await;
```
`GetBalanceResult::value()` renvoie les lamports et `context()` fournit le slot/API version retournés par Solana.
La release stable `0.2.3` contient 37 wrappers typés : 4 foundation + 5 Accounts + 5 Tokens + 12 Cluster + 11 Transactions. Les DTOs Transport conservent les `null`, options et formes wire sans décodage Program/SPL métier.
Exemples Transaction représentatifs :
```rust
let context = ksp_onchain_transport_lib::SolanaContextConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::None,
);
let latest = pool
.get_latest_blockhash(&role, std::option::Option::Some(&context))
.await;
let transaction_count = pool
.get_transaction_count(&role, std::option::Option::Some(&context))
.await;
```
`getTransaction` expose la config moderne complète et une forme bare-encoding legacy séparée et deprecated. `requestAirdrop` et `sendTransaction` sont des write submissions : elles utilisent la protection centrale `NeverAfterDispatch`. `simulateTransaction` reste une simulation retry-safe et conserve son résultat riche sans introduire de décodage Program.
## 4. Exécution JSON-RPC standard générique
@@ -153,12 +170,22 @@ Ne jamais journaliser l'URL complète, un token provider, un body massif, une tr
La configuration standard route les événements `info` de Transport vers un fichier dédié. Pour une investigation temporaire, élever uniquement ce target/sink à `debug` ou `trace`, puis revenir à `info` avant clôture du développement.
## 9. Smoke Devnet opt-in
## 9. Smokes Devnet opt-in
Le smoke live est volontairement hors des tests par défaut :
Le smoke **Transport pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif de `0.2.2` puis trois reads Transactions de `0.2.3` :
```bash
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
```
Il appelle `getAccountInfo`, `getTokenAccountsByOwner`, `getEpochInfo`, `getVoteAccounts`, puis `getLatestBlockhash`, `isBlockhashValid` et `getTransactionCount`. La branche Token suit la forme Devnet documentée : owner Pubkey ordinaire de l'exemple officiel, selector `programId` avec l'ID canonique du programme SPL Token, puis config explicite `commitment: finalized` + `encoding: jsonParsed`. Une réponse vide reste acceptable. La branche Transaction reste read-only : elle ne déclenche ni airdrop ni soumission de transaction et ne remplace pas les fixtures déterministes couvrant les 11 wrappers.
Le smoke historique de **composition Config -> Transport** reste également disponible :
```bash
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
```
Il charge le profil Config `devnet_public`, construit le pool puis appelle les quatre wrappers typés. Les endpoints publics Solana étant rate-limités et non destinés à la production, un échec réseau externe n'est pas interprété comme un échec déterministe de la suite locale.
Il valide le profil committé `devnet_public` et les quatre canaris foundation. Il reste transitoirement hébergé dans Config : les futurs smokes cross-crates ne doivent pas faire de Config leur destination générale et devront migrer vers une surface d'intégration/orchestration dédiée lorsqu'elle existera.
Les endpoints publics Solana sont rate-limités et non destinés à la production. Un échec réseau externe n'est pas assimilé automatiquement à une régression locale ; les fixtures HTTP locales restent les gates reproductibles.

View File

@@ -0,0 +1,5 @@
[
{"lamports":1,"data":"3MN5","owner":"11111111111111111111111111111111","executable":false,"rentEpoch":0,"space":null},
{"lamports":2,"data":["KLUv/Q==","base64+zstd"],"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":1,"space":4},
{"lamports":3,"data":{"program":"spl-token","parsed":{"type":"account","info":{"state":"initialized"}},"space":165},"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":2,"space":165}
]

View File

@@ -0,0 +1 @@
{"pubkey":"11111111111111111111111111111111","featureSet":123,"gossip":"127.0.0.1:8001","pubsub":null,"rpc":"127.0.0.1:8899","serveRepair":"127.0.0.1:8003","shredVersion":456,"tpu":"127.0.0.1:8004","tpuForwards":null,"tpuForwardsQuic":"127.0.0.1:8006","tpuQuic":"127.0.0.1:8005","tpuVote":null,"tvu":"127.0.0.1:8002","version":"4.2.1","clientId":"Agave"}

View File

@@ -0,0 +1,36 @@
[
{
"slot": 430000123,
"blockTime": 1787000000,
"transaction": ["AQIDBAUGBwg=", "base64"],
"meta": {
"err": null,
"status": {"Ok": null},
"fee": 5000,
"preBalances": [10000],
"postBalances": [5000]
},
"version": 0,
"transactionIndex": 7
},
{
"slot": 430000100,
"blockTime": null,
"transaction": "3Bxs4NN8M2Yn4TLb",
"meta": null,
"version": "legacy",
"transactionIndex": null
},
{
"slot": 430000050,
"blockTime": null,
"transaction": {
"signatures": [],
"message": {
"accountKeys": [],
"recentBlockhash": "11111111111111111111111111111111",
"instructions": []
}
}
}
]

View File

@@ -0,0 +1,12 @@
[
"3Bxs4NN8M2Yn4TLb",
["AQIDBAUGBwg=", "base64"],
{
"signatures": ["5h6xBEauJ3PK6SWQkgLxHcQpWyRZyG7c4pUpK9fd2B2B7hRQ1S6F9J7BqvQPEmxQxK4DThfYF5f5f5f5f5f5f5f5"],
"message": {
"accountKeys": [],
"recentBlockhash": "11111111111111111111111111111111",
"instructions": []
}
}
]

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000003},"value":{"data":["","base64"],"executable":false,"lamports":1,"owner":"not-a-pubkey","rentEpoch":0,"space":0}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000002},"value":null},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000001},"value":{"data":["AQIDBA==","base64"],"executable":false,"lamports":2039280,"owner":"11111111111111111111111111111111","rentEpoch":18446744073709551615,"space":4}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"pubkey":"not-a-pubkey","rpc":"127.0.0.1:8899"}],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"pubkey":"11111111111111111111111111111111","featureSet":3073396398,"gossip":"127.0.0.1:8001","pubsub":null,"rpc":"127.0.0.1:8899","serveRepair":"127.0.0.1:8004","shredVersion":50093,"tpu":"127.0.0.1:8003","tpuForwards":"127.0.0.1:8004","tpuForwardsQuic":"127.0.0.1:8006","tpuQuic":"127.0.0.1:8009","tpuVote":"127.0.0.1:8005","tvu":"127.0.0.1:8000","version":"4.2.1","clientId":"Agave"},{"pubkey":"Stake11111111111111111111111111111111111111"}],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"absoluteSlot":430000001,"blockHeight":429900000,"epoch":995,"slotIndex":12345,"slotsInEpoch":432000,"transactionCount":null},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"firstNormalEpoch":0,"firstNormalSlot":0,"leaderScheduleSlotOffset":432000,"slotsPerEpoch":432000,"warmup":false},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param: unable to decode message"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000002},"value":null},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000001},"value":5000},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32008,"message":"No snapshot"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"full":429990000,"incremental":null},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"identity":"invalid-identity"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"identity":"ComputeBudget111111111111111111111111111111"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000005},"value":[{"address":"invalid-address","lamports":1}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000004},"value":[{"address":"11111111111111111111111111111111","lamports":999999999},{"address":"ComputeBudget111111111111111111111111111111","lamports":888888888}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000004},"value":{"blockhash":"","lastValidBlockHeight":431123456}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000003},"value":{"blockhash":"11111111111111111111111111111111","lastValidBlockHeight":431123456}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":null,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"11111111111111111111111111111111":[0,2,4],"ComputeBudget111111111111111111111111111111":[1,3]} ,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":430000010,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":430000011,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":890880,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000006},"value":[{"data":["","base64"],"executable":false,"lamports":10,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":0},null,{"data":{"program":"system","parsed":{"type":"nonce"},"space":80},"executable":false,"lamports":20,"owner":"11111111111111111111111111111111","rentEpoch":1,"space":80}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":3}}],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000007},"value":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":3}}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"slot":431000030,"prioritizationFee":1200},{"slot":431000029,"prioritizationFee":0}],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000052},"value":[null]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000051},"value":[]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000053},"value":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000050},"value":[null,{"slot":431000049,"confirmations":null,"status":{"Ok":null},"err":null,"confirmationStatus":"finalized"},{"slot":431000048,"confirmations":2,"status":{"Err":{"InstructionError":[0,"Custom"]}},"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32015,"message":"Transaction history is not available from this node"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"signature":"newest-signature","slot":431000040,"err":null,"memo":"memo-newest","blockTime":1787000100,"confirmationStatus":"finalized","transactionIndex":9},{"signature":"older-signature","slot":431000039,"err":{"InstructionError":[0,"Custom"]},"memo":null,"blockTime":null,"confirmationStatus":"confirmed"}],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":430000020,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":"not-a-pubkey","id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":"ComputeBudget111111111111111111111111111111","id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":["11111111111111111111111111111111","not-a-pubkey"],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":["11111111111111111111111111111111","ComputeBudget111111111111111111111111111111"],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param: could not find account"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000001},"value":{"amount":"18446744073709551615","decimals":9,"uiAmount":null,"uiAmountString":"18446744073.709551615"}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000002},"value":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":{"program":"spl-token","parsed":{"type":"account","info":{"tokenAmount":{"amount":"1","decimals":1,"uiAmount":0.1,"uiAmountString":"0.1"}}},"space":165},"executable":false,"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":9,"space":165}}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"slot":420000004},"value":[{"pubkey":"not-a-pubkey","account":{"data":["","base64"],"executable":false,"lamports":1,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":0,"space":165}}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":null,"slot":420000003},"value":[{"pubkey":"Stake11111111111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":10,"space":165}}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"slot":420000006},"value":[{"address":"invalid-address","amount":"1","decimals":0,"uiAmount":1.0,"uiAmountString":"1"}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000005},"value":[{"address":"11111111111111111111111111111111","amount":"9000","decimals":2,"uiAmount":90.0,"uiAmountString":"90"},{"address":"ComputeBudget111111111111111111111111111111","amount":"8000","decimals":2,"uiAmount":80.0,"uiAmountString":"80"}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000007},"value":{"amount":"1000000000000000000000000","decimals":6,"uiAmount":1000000000000000000.0,"uiAmountString":"1000000000000000000"}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"slot":431000061,"blockTime":1787000201,"transaction":["3Bxs4NN8M2Yn4TLb","base58"],"meta":null,"version":"legacy","transactionIndex":0},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"slot":431000062,"blockTime":1787000202,"transaction":["AQIDBAUGBwg=","base64"],"meta":null,"version":0,"transactionIndex":1},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"slot":431000060,"blockTime":null,"transaction":"3Bxs4NN8M2Yn4TLb","meta":null,"version":"legacy"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32015,"message":"Transaction version (1) is not supported by the requesting client. Please try the request again with the following configuration parameter: \"maxSupportedTransactionVersion\": 1"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"slot":431000063,"blockTime":1787000203,"transaction":{"signatures":["fixture-signature"],"message":{"header":{"numRequiredSignatures":1,"numReadonlySignedAccounts":0,"numReadonlyUnsignedAccounts":1},"accountKeys":["11111111111111111111111111111111","SysvarRent111111111111111111111111111111111"],"recentBlockhash":"11111111111111111111111111111111","instructions":[{"programIdIndex":0,"accounts":[0,1],"data":"3Bxs4NN8","stackHeight":1}],"addressTableLookups":null}},"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[100000,1],"postBalances":[95000,1],"innerInstructions":[],"logMessages":["Program 11111111111111111111111111111111 invoke [1]","Program 11111111111111111111111111111111 success"],"preTokenBalances":[],"postTokenBalances":[],"rewards":null,"loadedAddresses":{"writable":[],"readonly":[]},"returnData":{"programId":"11111111111111111111111111111111","data":["AQID","base64"]},"computeUnitsConsumed":150,"costUnits":190},"version":0,"transactionIndex":7},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"slot":431000064,"blockTime":1787000204,"transaction":{"signatures":["fixture-signature-parsed"],"message":{"accountKeys":[{"pubkey":"11111111111111111111111111111111","signer":true,"writable":true,"source":"transaction"},{"pubkey":"SysvarRent111111111111111111111111111111111","signer":false,"writable":false,"source":"transaction"}],"recentBlockhash":"11111111111111111111111111111111","instructions":[{"program":"system","programId":"11111111111111111111111111111111","stackHeight":1,"parsed":{"type":"transfer","info":{"lamports":1,"source":"11111111111111111111111111111111","destination":"SysvarRent111111111111111111111111111111111"}}}]}},"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[100000,1],"postBalances":[95000,1],"innerInstructions":null,"logMessages":null,"preTokenBalances":[],"postTokenBalances":[],"rewards":null,"returnData":null,"computeUnitsConsumed":150,"costUnits":190},"version":"legacy","transactionIndex":null},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":null,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":987654321,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"current":[{"votePubkey":"not-a-pubkey","nodePubkey":"11111111111111111111111111111111","activatedStake":1,"commission":5,"epochVoteAccount":true,"epochCredits":[],"lastVote":2,"rootSlot":1}],"delinquent":[]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"current":[{"votePubkey":"11111111111111111111111111111111","nodePubkey":"ComputeBudget111111111111111111111111111111","activatedStake":424242,"commission":8,"inflationRewardsCommissionBps":750,"epochVoteAccount":true,"epochCredits":[[700,100,90],[701,115,100]],"lastVote":999,"rootSlot":990}],"delinquent":[{"votePubkey":"ComputeBudget111111111111111111111111111111","nodePubkey":"11111111111111111111111111111111","activatedStake":1,"commission":5,"epochVoteAccount":false,"epochCredits":[],"lastVote":0,"rootSlot":0}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000005},"value":true},"id":1}

View File

@@ -0,0 +1,4 @@
{
"blockhash": "11111111111111111111111111111111",
"lastValidBlockHeight": 430123456
}

View File

@@ -0,0 +1,4 @@
[
{"slot": 430000123, "prioritizationFee": 1000},
{"slot": 430000122, "prioritizationFee": 0}
]

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param: supplied blockhash is not valid"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":"airdrop-fixture-signature-111111111111111111111111111111111111111111111111","id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32002,"message":"Transaction simulation failed: Error processing Instruction 0","data":{"err":{"InstructionError":[0,"Custom"]},"logs":["Program log: fixture preflight failure"],"unitsConsumed":123}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":"send-fixture-signature-22222222222222222222222222222222222222222222222222","id":1}

View File

@@ -0,0 +1,19 @@
[
{
"signature": "5h6xBEauJ3PK6SWQkgLxHcQpWyRZyG7c4pUpK9fd2B2B7hRQ1S6F9J7BqvQPEmxQxK4DThfYF5f5f5f5f5f5f5f5",
"slot": 430000123,
"err": null,
"memo": "fixture memo",
"blockTime": 1787000000,
"confirmationStatus": "finalized",
"transactionIndex": 7
},
{
"signature": "4Nd1mC5QfVhNq8xqP7z8d7K6a5m4n3b2v1c9x8z7y6w5u4t3s2r1q9p8o7n6m5L4K3J2H1G9F8E7D6C5B4A3",
"slot": 430000100,
"err": {"InstructionError": [0, "Custom"]},
"memo": null,
"blockTime": null,
"confirmationStatus": "confirmed"
}
]

View File

@@ -0,0 +1,17 @@
[
null,
{
"slot": 430000123,
"confirmations": null,
"status": {"Ok": null},
"err": null,
"confirmationStatus": "finalized"
},
{
"slot": 430000120,
"confirmations": 3,
"status": {"Err": {"InstructionError": [0, "Custom"]}},
"err": {"InstructionError": [0, "Custom"]},
"confirmationStatus": "confirmed"
}
]

View File

@@ -0,0 +1,8 @@
{
"jsonrpc": "2.0",
"error": {
"code": -32602,
"message": "invalid transaction: failed to sanitize accounts offsets correctly"
},
"id": 1
}

View File

@@ -0,0 +1,18 @@
{
"jsonrpc": "2.0",
"result": {
"context": {
"slot": 431000031,
"apiVersion": "4.2.1"
},
"value": {
"err": null,
"logs": null,
"accounts": null,
"unitsConsumed": 99,
"returnData": null,
"replacementBlockhash": null
}
},
"id": 1
}

View File

@@ -0,0 +1,60 @@
{
"jsonrpc": "2.0",
"result": {
"context": {
"slot": 431000030,
"apiVersion": "4.2.1"
},
"value": {
"err": null,
"logs": [
"Program 11111111111111111111111111111111 invoke [1]",
"Program 11111111111111111111111111111111 success"
],
"accounts": [
{
"lamports": 1000,
"data": [
"",
"base64"
],
"owner": "11111111111111111111111111111111",
"executable": false,
"rentEpoch": 0,
"space": 0
},
null
],
"unitsConsumed": 150,
"loadedAccountsDataSize": 64,
"returnData": {
"programId": "11111111111111111111111111111111",
"data": [
"AQI=",
"base64"
]
},
"innerInstructions": [],
"replacementBlockhash": {
"blockhash": "ComputeBudget111111111111111111111111111111",
"lastValidBlockHeight": 430123999
},
"fee": 5000,
"preBalances": [
10000,
0
],
"postBalances": [
5000,
5000
],
"preTokenBalances": [],
"postTokenBalances": [],
"loadedAddresses": {
"writable": [],
"readonly": []
}
}
},
"id": 1
}

View File

@@ -0,0 +1,35 @@
{
"err": null,
"logs": ["Program 11111111111111111111111111111111 invoke [1]", "Program 11111111111111111111111111111111 success"],
"accounts": [
{
"lamports": 1000,
"data": ["", "base64"],
"owner": "11111111111111111111111111111111",
"executable": false,
"rentEpoch": 0,
"space": 0
},
null
],
"unitsConsumed": 150,
"loadedAccountsDataSize": 64,
"returnData": {
"programId": "11111111111111111111111111111111",
"data": ["AQI=", "base64"]
},
"innerInstructions": [],
"replacementBlockhash": {
"blockhash": "ComputeBudget111111111111111111111111111111",
"lastValidBlockHeight": 430123999
},
"fee": 5000,
"preBalances": [10000, 0],
"postBalances": [5000, 5000],
"preTokenBalances": [],
"postTokenBalances": [],
"loadedAddresses": {
"writable": [],
"readonly": []
}
}

View File

@@ -0,0 +1 @@
{"amount":"18446744073709551615","decimals":9,"uiAmount":null,"uiAmountString":"18446744073.709551615"}

View File

@@ -0,0 +1 @@
{"votePubkey":"11111111111111111111111111111111","nodePubkey":"11111111111111111111111111111111","activatedStake":424242,"commission":8,"inflationRewardsCommissionBps":750,"epochVoteAccount":true,"epochCredits":[[700,100,90],[701,115,100]],"lastVote":999,"rootSlot":990}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/error.rs
// version: 1
// version: 2
/// Error code used when HTTP transport runtime settings are invalid.
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_settings");
@@ -25,3 +25,5 @@ pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_l
pub const ERROR_CODE_METHOD_REMOVED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "method_removed");
/// Error code used when a decoded response cannot satisfy the KSP transport contract expected by the caller.
pub const ERROR_CODE_INVALID_RESPONSE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_response");
/// Error code used when typed Solana RPC parameters violate a locally enforceable method contract.
pub const ERROR_CODE_INVALID_RPC_PARAMETERS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_rpc_parameters");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 6
// version: 16
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -9,8 +9,10 @@
//! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains
//! independent from `ksp-config-lib`, Store and Program layers. `ksp-config-lib` now constructs these public settings through its one-way Config ->
//! Transport adapter without creating a reverse dependency. Logical endpoint clients, priority-aware pools, bounded admission limits and retry/no-resend policy
//! are available. The first typed Solana HTTP canaries execute real
//! JSON-RPC requests while the remaining audited methods stay staged by subsequent `0.2.x` releases.
//! are available. The four typed Solana HTTP foundation canaries plus all 22 typed `0.2.2` Accounts, Tokens and Cluster wrappers execute real JSON-RPC
//! requests through the shared transport path. `0.2.3` exposes its shared Transaction wire/config primitives and all eleven Transaction wrappers through
//! `pre.007`: eight reads, two write submissions with centralized no-resend protection, and retry-safe `simulateTransaction`, including complete
//! modern/legacy `getTransaction` coverage. The `0.2.4` family remains staged.
mod client;
mod constants;
@@ -19,8 +21,13 @@ mod executor;
mod json_rpc;
mod pool;
mod resilience;
mod rpc_accounts;
mod rpc_canary;
mod rpc_cluster;
mod rpc_common;
mod rpc_method;
mod rpc_tokens;
mod rpc_transactions;
mod settings;
pub(crate) use self::constants::TRACING_TARGET;
@@ -41,6 +48,8 @@ pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED;
pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED;
/// Error code used when a decoded response cannot satisfy the expected KSP transport contract.
pub use self::error::ERROR_CODE_INVALID_RESPONSE;
/// Error code used when typed Solana RPC parameters violate a locally enforceable method contract.
pub use self::error::ERROR_CODE_INVALID_RPC_PARAMETERS;
/// Error code used when HTTP transport runtime settings are invalid.
pub use self::error::ERROR_CODE_INVALID_SETTINGS;
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
@@ -87,20 +96,82 @@ pub use self::resilience::HttpRetryCause;
pub use self::resilience::HttpRetryDecision;
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
pub use self::resilience::evaluate_transport_retry;
/// Typed transport-level Solana account without Program/SPL decoding.
pub use self::rpc_accounts::SolanaAccount;
/// Address and lamport balance returned by `getLargestAccounts`.
pub use self::rpc_accounts::SolanaAccountBalance;
/// Wire-preserving account data returned by Solana HTTP account methods.
pub use self::rpc_accounts::SolanaAccountData;
/// Account-data encoding accepted by Solana HTTP account methods.
pub use self::rpc_accounts::SolanaAccountEncoding;
/// Shared account configuration used by account-info and token-account list methods.
pub use self::rpc_accounts::SolanaAccountInfoConfig;
/// Byte range requested from account data without decoding it locally.
pub use self::rpc_accounts::SolanaDataSliceConfig;
/// One public key plus its account returned by account-list RPC methods.
pub use self::rpc_accounts::SolanaKeyedAccount;
/// Optional configuration for `getLargestAccounts`.
pub use self::rpc_accounts::SolanaLargestAccountsConfig;
/// Filter accepted by `getLargestAccounts`.
pub use self::rpc_accounts::SolanaLargestAccountsFilter;
/// Bytes used by a `memcmp` program-account filter.
pub use self::rpc_accounts::SolanaMemcmpBytes;
/// One `memcmp` filter applied to account data.
pub use self::rpc_accounts::SolanaMemcmpFilter;
/// Parsed account payload returned by the RPC node for `jsonParsed` account data.
pub use self::rpc_accounts::SolanaParsedAccountData;
/// Filter accepted by the current `getProgramAccounts` implementation.
pub use self::rpc_accounts::SolanaProgramAccountFilter;
/// Configuration for `getProgramAccounts`.
pub use self::rpc_accounts::SolanaProgramAccountsConfig;
/// Result union returned by `getProgramAccounts` with or without an RPC context.
pub use self::rpc_accounts::SolanaProgramAccountsResult;
/// Optional typed configuration for the `getBalance` canary.
pub use self::rpc_canary::GetBalanceConfig;
/// Typed lamport balance returned by the `getBalance` canary.
pub use self::rpc_canary::GetBalanceResult;
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
pub use self::rpc_canary::SolanaCommitment;
/// Typed genesis hash returned by the `getGenesisHash` canary.
pub use self::rpc_canary::SolanaGenesisHash;
/// Typed healthy result returned by the `getHealth` canary.
pub use self::rpc_canary::SolanaNodeHealth;
/// Typed software-version response returned by the `getVersion` canary.
pub use self::rpc_canary::SolanaNodeVersion;
/// Typed Solana RPC context used by the initial account canary.
pub use self::rpc_canary::SolanaRpcContext;
/// Contact information returned for one cluster node.
pub use self::rpc_cluster::SolanaClusterNode;
/// Epoch-credit history entry returned by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaEpochCredits;
/// Epoch information returned by `getEpochInfo`.
pub use self::rpc_cluster::SolanaEpochInfo;
/// Epoch schedule returned by `getEpochSchedule`.
pub use self::rpc_cluster::SolanaEpochSchedule;
/// Leader schedule mapping validator identities to relative epoch slot indices.
pub use self::rpc_cluster::SolanaLeaderSchedule;
/// Optional configuration accepted by `getLeaderSchedule`.
pub use self::rpc_cluster::SolanaLeaderScheduleConfig;
/// Typed parameter overload for `getLeaderSchedule`.
pub use self::rpc_cluster::SolanaLeaderScheduleRequest;
/// Highest full and optional incremental snapshot slots returned by `getHighestSnapshotSlot`.
pub use self::rpc_cluster::SolanaSnapshotSlotInfo;
/// One validator vote-account record returned by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaVoteAccountInfo;
/// Current and delinquent validator vote-account groups returned by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaVoteAccountStatus;
/// Configuration accepted by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaVoteAccountsConfig;
/// Commitment level accepted by typed Solana HTTP RPC adapters.
pub use self::rpc_common::SolanaCommitment;
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
pub use self::rpc_common::SolanaCommitmentConfig;
/// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods.
pub use self::rpc_common::SolanaContextConfig;
/// Typed Solana RPC context shared by contextual HTTP responses.
pub use self::rpc_common::SolanaRpcContext;
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
pub use self::rpc_common::SolanaRpcResponse;
/// Decodes one private serde wire type into the shared Transport error domain for typed RPC adapters.
pub(crate) use self::rpc_common::decode_wire_json;
/// Parses a base58 public key without echoing its wire value into diagnostics for typed RPC adapters.
pub(crate) use self::rpc_common::parse_wire_pubkey;
/// Functional category used by the audited Solana HTTP JSON-RPC registry.
pub use self::rpc_method::HttpRpcCategory;
/// Release that owns typed KSP coverage for one audited HTTP RPC method.
@@ -123,6 +194,50 @@ pub use self::rpc_method::current_http_rpc_methods;
pub use self::rpc_method::find_http_rpc_method;
/// Returns historically documented deprecated HTTP RPC descriptors retained for compliance history.
pub use self::rpc_method::historical_http_rpc_methods;
/// Token-account balance entry returned by `getTokenLargestAccounts`.
pub use self::rpc_tokens::SolanaTokenAccountBalance;
/// Exclusive selector accepted by token-account list RPC methods.
pub use self::rpc_tokens::SolanaTokenAccountSelector;
/// Token amount returned by Solana HTTP token RPC methods.
pub use self::rpc_tokens::SolanaTokenAmount;
/// Confirmed transaction result returned by `getTransaction` when the RPC result is non-null.
pub use self::rpc_transactions::SolanaConfirmedTransaction;
/// Wire-preserving transaction payload returned by `getTransaction`.
pub use self::rpc_transactions::SolanaEncodedTransaction;
/// Modern configuration object accepted by `getTransaction`.
pub use self::rpc_transactions::SolanaGetTransactionConfig;
/// Blockhash information returned by `getLatestBlockhash` and optionally by simulation.
pub use self::rpc_transactions::SolanaLatestBlockhash;
/// One recent prioritization-fee sample returned by `getRecentPrioritizationFees`.
pub use self::rpc_transactions::SolanaPrioritizationFee;
/// Configuration accepted by `requestAirdrop`.
pub use self::rpc_transactions::SolanaRequestAirdropConfig;
/// Configuration accepted by `sendTransaction` without changing KSP transport retry semantics.
pub use self::rpc_transactions::SolanaSendTransactionConfig;
/// One ordered signature record returned by `getSignaturesForAddress`.
pub use self::rpc_transactions::SolanaSignatureInfo;
/// One non-null position returned by `getSignatureStatuses`.
pub use self::rpc_transactions::SolanaSignatureStatus;
/// Optional historical-search configuration accepted by `getSignatureStatuses`.
pub use self::rpc_transactions::SolanaSignatureStatusesConfig;
/// Pagination and context configuration accepted by `getSignaturesForAddress`.
pub use self::rpc_transactions::SolanaSignaturesForAddressConfig;
/// Configuration accepted by `simulateTransaction`.
pub use self::rpc_transactions::SolanaSimulateTransactionConfig;
/// Rich result payload returned inside the contextual `simulateTransaction` response.
pub use self::rpc_transactions::SolanaSimulateTransactionResult;
/// Account-return configuration nested under `simulateTransaction`.
pub use self::rpc_transactions::SolanaSimulationAccountsConfig;
/// Binary encoding accepted for serialized transaction input payloads.
pub use self::rpc_transactions::SolanaTransactionBinaryEncoding;
/// Confirmation state reported for a signature or transaction status.
pub use self::rpc_transactions::SolanaTransactionConfirmationStatus;
/// Encoding accepted by `getTransaction`, including its retained legacy `binary` alias.
pub use self::rpc_transactions::SolanaTransactionEncoding;
/// Transaction version reported by `getTransaction` when the version field is present.
pub use self::rpc_transactions::SolanaTransactionVersion;
/// Three-state wire field used when Solana distinguishes omission from an explicit JSON `null`.
pub use self::rpc_transactions::SolanaWireField;
/// Open cluster or network descriptor used by HTTP endpoint settings.
pub use self::settings::HttpClusterName;
/// Runtime settings for one role declared by an HTTP endpoint.

View File

@@ -0,0 +1,991 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
// version: 4
/// Account-data encoding accepted by Solana HTTP account methods.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaAccountEncoding {
/// Legacy binary/base58 request encoding.
Binary,
/// Base58 text encoding.
Base58,
/// Base64 text encoding.
Base64,
/// Parsed JSON representation when the RPC node has a parser for the account owner.
JsonParsed,
/// Base64 text containing zstd-compressed bytes.
Base64Zstd,
}
impl SolanaAccountEncoding {
/// Returns the Solana JSON-RPC encoding string.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Binary => "binary",
Self::Base58 => "base58",
Self::Base64 => "base64",
Self::JsonParsed => "jsonParsed",
Self::Base64Zstd => "base64+zstd",
};
}
fn from_wire(value: &str) -> std::option::Option<Self> {
return match value {
"binary" => std::option::Option::Some(Self::Binary),
"base58" => std::option::Option::Some(Self::Base58),
"base64" => std::option::Option::Some(Self::Base64),
"jsonParsed" => std::option::Option::Some(Self::JsonParsed),
"base64+zstd" => std::option::Option::Some(Self::Base64Zstd),
_ => std::option::Option::None,
};
}
}
/// Byte range requested from account data without decoding it locally.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct SolanaDataSliceConfig {
offset: usize,
length: usize,
}
impl SolanaDataSliceConfig {
/// Creates an account-data slice configuration.
#[must_use]
pub const fn new(offset: usize, length: usize) -> Self {
return Self { offset, length };
}
/// Returns the byte offset.
#[must_use]
pub const fn offset(&self) -> usize {
return self.offset;
}
/// Returns the requested byte length.
#[must_use]
pub const fn length(&self) -> usize {
return self.length;
}
fn to_json_value(self) -> serde_json::Value {
return serde_json::json!({"offset": self.offset, "length": self.length});
}
}
/// Shared account configuration used by account-info and token-account list methods.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaAccountInfoConfig {
encoding: std::option::Option<crate::SolanaAccountEncoding>,
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
context: crate::SolanaContextConfig,
}
impl SolanaAccountInfoConfig {
/// Creates an explicit account-info configuration.
#[must_use]
pub const fn new(
encoding: std::option::Option<crate::SolanaAccountEncoding>,
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
commitment: std::option::Option<crate::SolanaCommitment>,
min_context_slot: std::option::Option<u64>,
) -> Self {
return Self { encoding, data_slice, context: crate::SolanaContextConfig::new(commitment, min_context_slot) };
}
/// Returns the optional account-data encoding.
#[must_use]
pub const fn encoding(&self) -> std::option::Option<crate::SolanaAccountEncoding> {
return self.encoding;
}
/// Returns the optional account-data slice.
#[must_use]
pub const fn data_slice(&self) -> std::option::Option<crate::SolanaDataSliceConfig> {
return self.data_slice;
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.context.commitment();
}
/// Returns the optional minimum context slot.
#[must_use]
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
return self.context.min_context_slot();
}
pub(crate) fn is_empty(&self) -> bool {
return self.encoding.is_none() && self.data_slice.is_none() && self.commitment().is_none() && self.min_context_slot().is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let context_value = self.context.to_json_value();
let mut object = match context_value {
serde_json::Value::Object(object) => object,
_ => serde_json::Map::new(),
};
if let std::option::Option::Some(encoding) = self.encoding {
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
}
if let std::option::Option::Some(data_slice) = self.data_slice {
object.insert("dataSlice".to_owned(), data_slice.to_json_value());
}
return serde_json::Value::Object(object);
}
}
/// Filter accepted by `getLargestAccounts`.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaLargestAccountsFilter {
/// Return only circulating accounts.
Circulating,
/// Return only non-circulating accounts.
NonCirculating,
}
impl SolanaLargestAccountsFilter {
fn as_str(self) -> &'static str {
return match self {
Self::Circulating => "circulating",
Self::NonCirculating => "nonCirculating",
};
}
}
/// Optional configuration for `getLargestAccounts`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaLargestAccountsConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
filter: std::option::Option<crate::SolanaLargestAccountsFilter>,
sort_results: std::option::Option<bool>,
}
impl SolanaLargestAccountsConfig {
/// Creates a largest-accounts configuration.
#[must_use]
pub const fn new(
commitment: std::option::Option<crate::SolanaCommitment>,
filter: std::option::Option<crate::SolanaLargestAccountsFilter>,
sort_results: std::option::Option<bool>,
) -> Self {
return Self { commitment, filter, sort_results };
}
/// Returns the optional commitment.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional circulating-account filter.
#[must_use]
pub const fn filter(&self) -> std::option::Option<crate::SolanaLargestAccountsFilter> {
return self.filter;
}
/// Returns the optional server-side result-sorting request.
#[must_use]
pub const fn sort_results(&self) -> std::option::Option<bool> {
return self.sort_results;
}
fn is_empty(&self) -> bool {
return self.commitment.is_none() && self.filter.is_none() && self.sort_results.is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(filter) = self.filter {
object.insert("filter".to_owned(), serde_json::Value::String(filter.as_str().to_owned()));
}
if let std::option::Option::Some(sort_results) = self.sort_results {
object.insert("sortResults".to_owned(), serde_json::Value::Bool(sort_results));
}
return serde_json::Value::Object(object);
}
}
/// Bytes used by a `memcmp` program-account filter.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaMemcmpBytes {
/// Base58-encoded bytes.
Base58(std::string::String),
/// Base64-encoded bytes.
Base64(std::string::String),
/// Raw byte array.
Bytes(std::vec::Vec<u8>),
}
/// One `memcmp` filter applied to account data.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaMemcmpFilter {
offset: usize,
bytes: crate::SolanaMemcmpBytes,
}
impl SolanaMemcmpFilter {
/// Creates a `memcmp` filter without locally decoding encoded string data.
#[must_use]
pub fn new(offset: usize, bytes: crate::SolanaMemcmpBytes) -> Self {
return Self { offset, bytes };
}
/// Returns the account-data byte offset.
#[must_use]
pub const fn offset(&self) -> usize {
return self.offset;
}
/// Returns the encoded or raw bytes.
#[must_use]
pub const fn bytes(&self) -> &crate::SolanaMemcmpBytes {
return &self.bytes;
}
fn to_json_value(&self) -> serde_json::Value {
return match &self.bytes {
crate::SolanaMemcmpBytes::Base58(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "base58"}),
crate::SolanaMemcmpBytes::Base64(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "base64"}),
crate::SolanaMemcmpBytes::Bytes(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "bytes"}),
};
}
}
/// Filter accepted by the current `getProgramAccounts` implementation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaProgramAccountFilter {
/// Require an exact account data size.
DataSize(u64),
/// Compare bytes at one account-data offset.
Memcmp(crate::SolanaMemcmpFilter),
/// Require a valid SPL Token account-state layout according to the RPC implementation.
TokenAccountState,
}
impl SolanaProgramAccountFilter {
fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::DataSize(size) => serde_json::json!({"dataSize": size}),
Self::Memcmp(filter) => serde_json::json!({"memcmp": filter.to_json_value()}),
Self::TokenAccountState => serde_json::Value::String("tokenAccountState".to_owned()),
};
}
}
/// Configuration for `getProgramAccounts` built from the shared account config plus program filters.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaProgramAccountsConfig {
account_config: crate::SolanaAccountInfoConfig,
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
with_context: std::option::Option<bool>,
sort_results: std::option::Option<bool>,
}
impl SolanaProgramAccountsConfig {
/// Creates a program-accounts configuration.
#[must_use]
pub fn new(
account_config: crate::SolanaAccountInfoConfig,
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
with_context: std::option::Option<bool>,
sort_results: std::option::Option<bool>,
) -> Self {
return Self { account_config, filters, with_context, sort_results };
}
/// Returns the shared account configuration.
#[must_use]
pub const fn account_config(&self) -> &crate::SolanaAccountInfoConfig {
return &self.account_config;
}
/// Returns the ordered program-account filters.
#[must_use]
pub fn filters(&self) -> &[crate::SolanaProgramAccountFilter] {
return self.filters.as_slice();
}
/// Returns the optional context-wrapper request.
#[must_use]
pub const fn with_context(&self) -> std::option::Option<bool> {
return self.with_context;
}
/// Returns the optional server-side sorting request.
#[must_use]
pub const fn sort_results(&self) -> std::option::Option<bool> {
return self.sort_results;
}
fn is_empty(&self) -> bool {
return self.account_config.is_empty() && self.filters.is_empty() && self.with_context.is_none() && self.sort_results.is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let account_value = self.account_config.to_json_value();
let mut object = match account_value {
serde_json::Value::Object(object) => object,
_ => serde_json::Map::new(),
};
if !self.filters.is_empty() {
let values = self.filters.iter().map(crate::SolanaProgramAccountFilter::to_json_value).collect::<std::vec::Vec<_>>();
object.insert("filters".to_owned(), serde_json::Value::Array(values));
}
if let std::option::Option::Some(with_context) = self.with_context {
object.insert("withContext".to_owned(), serde_json::Value::Bool(with_context));
}
if let std::option::Option::Some(sort_results) = self.sort_results {
object.insert("sortResults".to_owned(), serde_json::Value::Bool(sort_results));
}
return serde_json::Value::Object(object);
}
}
/// Parsed account payload returned by the RPC node for `jsonParsed` account data.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaParsedAccountData {
program: std::string::String,
parsed: serde_json::Value,
space: u64,
}
impl SolanaParsedAccountData {
/// Returns the parser/program label reported by the RPC node.
#[must_use]
pub fn program(&self) -> &str {
return self.program.as_str();
}
/// Returns the parsed JSON payload without converting it to a Program/SPL domain model.
#[must_use]
pub const fn parsed(&self) -> &serde_json::Value {
return &self.parsed;
}
/// Returns the account-data space reported inside the parsed payload.
#[must_use]
pub const fn space(&self) -> u64 {
return self.space;
}
}
/// Wire-preserving account data returned by Solana HTTP account methods.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaAccountData {
/// Legacy single-string binary form retained for backwards compatibility.
LegacyBinary(std::string::String),
/// Encoded tuple `[data, encoding]`.
Encoded {
/// Encoded account bytes.
data: std::string::String,
/// Encoding label returned by the RPC node.
encoding: crate::SolanaAccountEncoding,
},
/// Parsed JSON object returned by the RPC node.
JsonParsed(crate::SolanaParsedAccountData),
}
impl SolanaAccountData {
fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireAccountData>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return match wire {
WireAccountData::LegacyBinary(value) => std::result::Result::Ok(Self::LegacyBinary(value)),
WireAccountData::JsonParsed(value) => {
std::result::Result::Ok(Self::JsonParsed(crate::SolanaParsedAccountData { program: value.program, parsed: value.parsed, space: value.space }))
},
WireAccountData::Encoded((data, encoding)) => {
let parsed = crate::SolanaAccountEncoding::from_wire(encoding.as_str());
let encoding = match parsed {
std::option::Option::Some(encoding) => encoding,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "account data tuple uses an unknown encoding")
.with_context("rpc_method", method),
);
},
};
if encoding == crate::SolanaAccountEncoding::Binary || encoding == crate::SolanaAccountEncoding::JsonParsed {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "account data tuple uses an invalid tuple encoding")
.with_context("rpc_method", method),
);
}
std::result::Result::Ok(Self::Encoded { data, encoding })
},
};
}
}
/// Typed transport-level Solana account without Program/SPL decoding.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaAccount {
lamports: u64,
data: crate::SolanaAccountData,
owner: ksp_core_lib::Pubkey,
executable: bool,
rent_epoch: u64,
space: std::option::Option<u64>,
}
impl SolanaAccount {
/// Returns the account balance in lamports.
#[must_use]
pub const fn lamports(&self) -> u64 {
return self.lamports;
}
/// Returns the wire-preserving account data.
#[must_use]
pub const fn data(&self) -> &crate::SolanaAccountData {
return &self.data;
}
/// Returns the account owner program public key.
#[must_use]
pub const fn owner(&self) -> &ksp_core_lib::Pubkey {
return &self.owner;
}
/// Returns whether the account is executable.
#[must_use]
pub const fn executable(&self) -> bool {
return self.executable;
}
/// Returns the rent epoch reported by the RPC node.
#[must_use]
pub const fn rent_epoch(&self) -> u64 {
return self.rent_epoch;
}
/// Returns the optional account data-space field.
#[must_use]
pub const fn space(&self) -> std::option::Option<u64> {
return self.space;
}
/// Decodes one account DTO from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireAccount>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let owner = crate::parse_wire_pubkey(method, "owner", wire.owner.as_str());
let owner = match owner {
std::result::Result::Ok(owner) => owner,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let data = crate::SolanaAccountData::decode_wire(method, wire.data);
let data = match data {
std::result::Result::Ok(data) => data,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self {
lamports: wire.lamports,
data,
owner,
executable: wire.executable,
rent_epoch: wire.rent_epoch,
space: wire.space,
});
}
}
/// One public key plus its account returned by account-list RPC methods.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaKeyedAccount {
pubkey: ksp_core_lib::Pubkey,
account: crate::SolanaAccount,
}
impl SolanaKeyedAccount {
/// Returns the account public key.
#[must_use]
pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.pubkey;
}
/// Returns the account payload.
#[must_use]
pub const fn account(&self) -> &crate::SolanaAccount {
return &self.account;
}
/// Decodes one keyed account from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireKeyedAccount>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pubkey = crate::parse_wire_pubkey(method, "pubkey", wire.pubkey.as_str());
let pubkey = match pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let account = crate::SolanaAccount::decode_wire(method, wire.account);
let account = match account {
std::result::Result::Ok(account) => account,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { pubkey, account });
}
}
/// Address and lamport balance returned by `getLargestAccounts`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaAccountBalance {
address: ksp_core_lib::Pubkey,
lamports: u64,
}
impl SolanaAccountBalance {
/// Returns the account address.
#[must_use]
pub const fn address(&self) -> &ksp_core_lib::Pubkey {
return &self.address;
}
/// Returns the balance in lamports.
#[must_use]
pub const fn lamports(&self) -> u64 {
return self.lamports;
}
/// Decodes one account-balance entry from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireAccountBalance>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let address = crate::parse_wire_pubkey(method, "address", wire.address.as_str());
return match address {
std::result::Result::Ok(address) => std::result::Result::Ok(Self { address, lamports: wire.lamports }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Result union returned by `getProgramAccounts` with or without an RPC context.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaProgramAccountsResult {
/// Bare account list returned when `withContext` is false or absent.
Accounts(std::vec::Vec<crate::SolanaKeyedAccount>),
/// Contextual account list returned when `withContext` is true.
Context(crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>),
}
const MAX_MULTIPLE_ACCOUNTS: usize = 100;
const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4;
const MAX_MEMCMP_BYTES: usize = 128;
impl crate::HttpTransportPool {
/// Executes typed `getAccountInfo` through the common KSP HTTP transport path.
pub async fn get_account_info(
&self,
role: &crate::HttpRoleName,
account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::option::Option<crate::SolanaAccount>>> {
let method_result = account_descriptor("getAccountInfo");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec![serde_json::Value::String(account.to_string())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_account_info_response("getAccountInfo", value);
}
/// Executes typed `getLargestAccounts` through the common KSP HTTP transport path.
pub async fn get_largest_accounts(
&self,
role: &crate::HttpRoleName,
config: std::option::Option<&crate::SolanaLargestAccountsConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaAccountBalance>>> {
let method_result = account_descriptor("getLargestAccounts");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec::Vec::new();
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_largest_accounts_response("getLargestAccounts", value);
}
/// Executes typed `getMinimumBalanceForRentExemption` through the common KSP HTTP transport path.
pub async fn get_minimum_balance_for_rent_exemption(
&self,
role: &crate::HttpRoleName,
data_len: usize,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<u64> {
let method_result = account_descriptor("getMinimumBalanceForRentExemption");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let data_len_value = serde_json::to_value(data_len);
let data_len_value = match data_len_value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_ENCODE_FAILED, "rent-exemption data length could not be encoded")
.with_context("rpc_method", "getMinimumBalanceForRentExemption")
.with_source(error),
);
},
};
let mut params = std::vec![data_len_value];
if let std::option::Option::Some(config) = config
&& config.commitment().is_some()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decoded = crate::decode_wire_json::<u64>("getMinimumBalanceForRentExemption", value);
return match decoded {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getMultipleAccounts` through the common KSP HTTP transport path.
pub async fn get_multiple_accounts(
&self,
role: &crate::HttpRoleName,
accounts: &[ksp_core_lib::Pubkey],
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<std::option::Option<crate::SolanaAccount>>>> {
if accounts.len() > MAX_MULTIPLE_ACCOUNTS {
return invalid_account_parameters("getMultipleAccounts", "getMultipleAccounts accepts at most 100 public keys", "account_count", accounts.len());
}
let method_result = account_descriptor("getMultipleAccounts");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let addresses = accounts.iter().map(std::string::ToString::to_string).map(serde_json::Value::String).collect::<std::vec::Vec<_>>();
let mut params = std::vec![serde_json::Value::Array(addresses)];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_multiple_accounts_response("getMultipleAccounts", value, accounts.len());
}
/// Executes typed `getProgramAccounts` through the common KSP HTTP transport path.
pub async fn get_program_accounts(
&self,
role: &crate::HttpRoleName,
program_id: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaProgramAccountsConfig>,
) -> ksp_core_lib::Result<crate::SolanaProgramAccountsResult> {
if let std::option::Option::Some(config) = config {
let validation = validate_program_account_filters(config.filters());
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
}
let method_result = account_descriptor("getProgramAccounts");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec![serde_json::Value::String(program_id.to_string())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_program_accounts_response("getProgramAccounts", value);
}
}
fn account_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
let descriptor = crate::find_http_rpc_method(method);
return match descriptor {
std::option::Option::Some(descriptor)
if descriptor.category() == crate::HttpRpcCategory::Accounts && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
{
std::result::Result::Ok(descriptor)
},
_ => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Accounts descriptor is missing from the audited 0.2.2 registry")
.with_context("rpc_method", method),
),
};
}
fn invalid_account_parameters<T>(method: &str, message: &str, field: &'static str, value: usize) -> ksp_core_lib::Result<T> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, message)
.with_context("rpc_method", method)
.with_context(field, value.to_string()),
);
}
fn validate_program_account_filters(filters: &[crate::SolanaProgramAccountFilter]) -> ksp_core_lib::Result<()> {
if filters.len() > MAX_PROGRAM_ACCOUNT_FILTERS {
return invalid_account_parameters(
"getProgramAccounts",
"getProgramAccounts accepts at most 4 filters on the targeted Agave runtime",
"filter_count",
filters.len(),
);
}
for filter in filters {
if let crate::SolanaProgramAccountFilter::Memcmp(memcmp) = filter
&& let crate::SolanaMemcmpBytes::Bytes(bytes) = memcmp.bytes()
&& bytes.len() > MAX_MEMCMP_BYTES
{
return invalid_account_parameters(
"getProgramAccounts",
"raw getProgramAccounts memcmp data accepts at most 128 bytes",
"memcmp_byte_count",
bytes.len(),
);
}
}
return std::result::Result::Ok(());
}
fn decode_account_info_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::option::Option<crate::SolanaAccount>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::option::Option<serde_json::Value>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let account = match wire.value {
std::option::Option::Some(value) => {
let account = crate::SolanaAccount::decode_wire(method, value);
match account {
std::result::Result::Ok(account) => std::option::Option::Some(account),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
},
std::option::Option::None => std::option::Option::None,
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, account));
}
fn decode_largest_accounts_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaAccountBalance>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<serde_json::Value>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut values = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
let decoded = crate::SolanaAccountBalance::decode_wire(method, value);
match decoded {
std::result::Result::Ok(decoded) => values.push(decoded),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, values));
}
fn decode_multiple_accounts_response(
method: &str,
value: serde_json::Value,
expected_count: usize,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<std::option::Option<crate::SolanaAccount>>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<std::option::Option<serde_json::Value>>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut values = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
match value {
std::option::Option::Some(value) => {
let decoded = crate::SolanaAccount::decode_wire(method, value);
match decoded {
std::result::Result::Ok(decoded) => values.push(std::option::Option::Some(decoded)),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
},
std::option::Option::None => values.push(std::option::Option::None),
}
}
if values.len() != expected_count {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "getMultipleAccounts result count does not match the requested account count")
.with_context("rpc_method", method)
.with_context("expected_count", expected_count.to_string())
.with_context("actual_count", values.len().to_string()),
);
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, values));
}
fn decode_program_accounts_response(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaProgramAccountsResult> {
let decoded = crate::decode_wire_json::<WireProgramAccountsResult>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return match wire {
WireProgramAccountsResult::Accounts(values) => {
let decoded = decode_keyed_accounts(method, values);
match decoded {
std::result::Result::Ok(values) => std::result::Result::Ok(crate::SolanaProgramAccountsResult::Accounts(values)),
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
WireProgramAccountsResult::Context(wire) => {
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let values = decode_keyed_accounts(method, wire.value);
match values {
std::result::Result::Ok(values) => {
std::result::Result::Ok(crate::SolanaProgramAccountsResult::Context(crate::SolanaRpcResponse::new(context, values)))
},
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
};
}
fn decode_keyed_accounts(method: &str, values: std::vec::Vec<serde_json::Value>) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaKeyedAccount>> {
let mut decoded_values = std::vec::Vec::with_capacity(values.len());
for value in values {
let decoded = crate::SolanaKeyedAccount::decode_wire(method, value);
match decoded {
std::result::Result::Ok(decoded) => decoded_values.push(decoded),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(decoded_values);
}
#[derive(serde::Deserialize)]
struct WireRpcResponse<T> {
context: serde_json::Value,
value: T,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireProgramAccountsResult {
Context(WireRpcResponse<std::vec::Vec<serde_json::Value>>),
Accounts(std::vec::Vec<serde_json::Value>),
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireAccountData {
LegacyBinary(std::string::String),
JsonParsed(WireParsedAccountData),
Encoded((std::string::String, std::string::String)),
}
#[derive(serde::Deserialize)]
struct WireParsedAccountData {
program: std::string::String,
parsed: serde_json::Value,
space: u64,
}
#[derive(serde::Deserialize)]
struct WireAccount {
lamports: u64,
data: serde_json::Value,
owner: std::string::String,
executable: bool,
#[serde(rename = "rentEpoch")]
rent_epoch: u64,
#[serde(default)]
space: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
struct WireKeyedAccount {
pubkey: std::string::String,
account: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct WireAccountBalance {
address: std::string::String,
lamports: u64,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_accounts.rs"]
mod tests;

View File

@@ -1,30 +1,7 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_canary.rs
// version: 1
// version: 2
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaCommitment {
/// Query the most recent processed bank.
Processed,
/// Query a bank confirmed by cluster vote.
Confirmed,
/// Query a finalized bank.
Finalized,
}
impl SolanaCommitment {
/// Returns the Solana JSON-RPC commitment string.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Processed => "processed",
Self::Confirmed => "confirmed",
Self::Finalized => "finalized",
};
}
}
/// Optional typed configuration for `getBalance`.
/// Optional typed configuration for `getBalance` retained for the `0.2.1` public canary contract.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GetBalanceConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
@@ -55,14 +32,7 @@ impl GetBalanceConfig {
}
fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(min_context_slot) = self.min_context_slot {
object.insert("minContextSlot".to_owned(), serde_json::Value::Number(min_context_slot.into()));
}
return serde_json::Value::Object(object);
return crate::SolanaContextConfig::new(self.commitment, self.min_context_slot).to_json_value();
}
}
@@ -108,30 +78,6 @@ impl SolanaNodeVersion {
}
}
/// Typed Solana RPC context used by the initial account canary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaRpcContext {
slot: u64,
api_version: std::option::Option<std::string::String>,
}
impl SolanaRpcContext {
/// Returns the context slot reported by the RPC node.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the optional RPC API version reported by the node.
#[must_use]
pub fn api_version(&self) -> std::option::Option<&str> {
return match self.api_version.as_ref() {
std::option::Option::Some(value) => std::option::Option::Some(value.as_str()),
std::option::Option::None => std::option::Option::None,
};
}
}
/// Typed lamport balance returned by the `getBalance` canary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GetBalanceResult {
@@ -245,10 +191,12 @@ impl crate::HttpTransportPool {
std::result::Result::Ok(decoded) => decoded,
std::result::Result::Err(error) => return invalid_canary_decode("getBalance", error),
};
return std::result::Result::Ok(crate::GetBalanceResult {
context: crate::SolanaRpcContext { slot: decoded.context.slot, api_version: decoded.context.api_version },
value: decoded.value,
});
let context = crate::SolanaRpcContext::decode_wire("getBalance", decoded.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::GetBalanceResult { context, value: decoded.value });
}
}
@@ -260,16 +208,9 @@ struct WireNodeVersion {
feature_set: std::option::Option<u32>,
}
#[derive(serde::Deserialize)]
struct WireRpcContext {
slot: u64,
#[serde(rename = "apiVersion", default)]
api_version: std::option::Option<std::string::String>,
}
#[derive(serde::Deserialize)]
struct WireBalanceResult {
context: WireRpcContext,
context: serde_json::Value,
value: u64,
}

View File

@@ -0,0 +1,943 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
// version: 4
const MAX_GET_SLOT_LEADERS: u64 = 5_000;
/// Contact information returned for one cluster node.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaClusterNode {
pubkey: ksp_core_lib::Pubkey,
feature_set: std::option::Option<u32>,
gossip: std::option::Option<std::string::String>,
pubsub: std::option::Option<std::string::String>,
rpc: std::option::Option<std::string::String>,
serve_repair: std::option::Option<std::string::String>,
shred_version: std::option::Option<u16>,
tpu: std::option::Option<std::string::String>,
tpu_forwards: std::option::Option<std::string::String>,
tpu_forwards_quic: std::option::Option<std::string::String>,
tpu_quic: std::option::Option<std::string::String>,
tpu_vote: std::option::Option<std::string::String>,
tvu: std::option::Option<std::string::String>,
version: std::option::Option<std::string::String>,
client_id: std::option::Option<std::string::String>,
}
impl SolanaClusterNode {
/// Returns the node identity public key.
#[must_use]
pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.pubkey;
}
/// Returns the optional feature-set identifier.
#[must_use]
pub const fn feature_set(&self) -> std::option::Option<u32> {
return self.feature_set;
}
/// Returns the optional gossip endpoint.
#[must_use]
pub fn gossip(&self) -> std::option::Option<&str> {
return self.gossip.as_deref();
}
/// Returns the optional PubSub endpoint.
#[must_use]
pub fn pubsub(&self) -> std::option::Option<&str> {
return self.pubsub.as_deref();
}
/// Returns the optional JSON-RPC endpoint.
#[must_use]
pub fn rpc(&self) -> std::option::Option<&str> {
return self.rpc.as_deref();
}
/// Returns the optional repair endpoint.
#[must_use]
pub fn serve_repair(&self) -> std::option::Option<&str> {
return self.serve_repair.as_deref();
}
/// Returns the optional shred version.
#[must_use]
pub const fn shred_version(&self) -> std::option::Option<u16> {
return self.shred_version;
}
/// Returns the optional TPU endpoint.
#[must_use]
pub fn tpu(&self) -> std::option::Option<&str> {
return self.tpu.as_deref();
}
/// Returns the optional TPU forwards endpoint.
#[must_use]
pub fn tpu_forwards(&self) -> std::option::Option<&str> {
return self.tpu_forwards.as_deref();
}
/// Returns the optional TPU forwards QUIC endpoint.
#[must_use]
pub fn tpu_forwards_quic(&self) -> std::option::Option<&str> {
return self.tpu_forwards_quic.as_deref();
}
/// Returns the optional TPU QUIC endpoint.
#[must_use]
pub fn tpu_quic(&self) -> std::option::Option<&str> {
return self.tpu_quic.as_deref();
}
/// Returns the optional TPU vote endpoint.
#[must_use]
pub fn tpu_vote(&self) -> std::option::Option<&str> {
return self.tpu_vote.as_deref();
}
/// Returns the optional TVU endpoint.
#[must_use]
pub fn tvu(&self) -> std::option::Option<&str> {
return self.tvu.as_deref();
}
/// Returns the optional software-version string.
#[must_use]
pub fn version(&self) -> std::option::Option<&str> {
return self.version.as_deref();
}
/// Returns the optional Agave client identifier extension.
#[must_use]
pub fn client_id(&self) -> std::option::Option<&str> {
return self.client_id.as_deref();
}
/// Decodes one cluster-node contact record from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireClusterNode>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pubkey = crate::parse_wire_pubkey(method, "pubkey", wire.pubkey.as_str());
let pubkey = match pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self {
pubkey,
feature_set: wire.feature_set,
gossip: wire.gossip,
pubsub: wire.pubsub,
rpc: wire.rpc,
serve_repair: wire.serve_repair,
shred_version: wire.shred_version,
tpu: wire.tpu,
tpu_forwards: wire.tpu_forwards,
tpu_forwards_quic: wire.tpu_forwards_quic,
tpu_quic: wire.tpu_quic,
tpu_vote: wire.tpu_vote,
tvu: wire.tvu,
version: wire.version,
client_id: wire.client_id,
});
}
}
/// Epoch information returned by `getEpochInfo`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaEpochInfo {
absolute_slot: u64,
block_height: u64,
epoch: u64,
slot_index: u64,
slots_in_epoch: u64,
transaction_count: std::option::Option<u64>,
}
impl SolanaEpochInfo {
/// Returns the absolute slot.
#[must_use]
pub const fn absolute_slot(&self) -> u64 {
return self.absolute_slot;
}
/// Returns the block height.
#[must_use]
pub const fn block_height(&self) -> u64 {
return self.block_height;
}
/// Returns the epoch number.
#[must_use]
pub const fn epoch(&self) -> u64 {
return self.epoch;
}
/// Returns the slot index within the epoch.
#[must_use]
pub const fn slot_index(&self) -> u64 {
return self.slot_index;
}
/// Returns the number of slots in the epoch.
#[must_use]
pub const fn slots_in_epoch(&self) -> u64 {
return self.slots_in_epoch;
}
/// Returns the nullable transaction count.
#[must_use]
pub const fn transaction_count(&self) -> std::option::Option<u64> {
return self.transaction_count;
}
/// Decodes epoch information from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireEpochInfo>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
absolute_slot: wire.absolute_slot,
block_height: wire.block_height,
epoch: wire.epoch,
slot_index: wire.slot_index,
slots_in_epoch: wire.slots_in_epoch,
transaction_count: wire.transaction_count,
}),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Epoch schedule returned by `getEpochSchedule`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaEpochSchedule {
first_normal_epoch: u64,
first_normal_slot: u64,
leader_schedule_slot_offset: u64,
slots_per_epoch: u64,
warmup: bool,
}
impl SolanaEpochSchedule {
/// Returns the first normal epoch.
#[must_use]
pub const fn first_normal_epoch(&self) -> u64 {
return self.first_normal_epoch;
}
/// Returns the first normal slot.
#[must_use]
pub const fn first_normal_slot(&self) -> u64 {
return self.first_normal_slot;
}
/// Returns the leader-schedule slot offset.
#[must_use]
pub const fn leader_schedule_slot_offset(&self) -> u64 {
return self.leader_schedule_slot_offset;
}
/// Returns the number of slots per epoch.
#[must_use]
pub const fn slots_per_epoch(&self) -> u64 {
return self.slots_per_epoch;
}
/// Returns whether epoch warmup is enabled.
#[must_use]
pub const fn warmup(&self) -> bool {
return self.warmup;
}
/// Decodes an epoch schedule from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireEpochSchedule>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
first_normal_epoch: wire.first_normal_epoch,
first_normal_slot: wire.first_normal_slot,
leader_schedule_slot_offset: wire.leader_schedule_slot_offset,
slots_per_epoch: wire.slots_per_epoch,
warmup: wire.warmup,
}),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Highest full and optional incremental snapshot slots returned by `getHighestSnapshotSlot`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaSnapshotSlotInfo {
full: u64,
incremental: std::option::Option<u64>,
}
impl SolanaSnapshotSlotInfo {
/// Returns the highest full snapshot slot.
#[must_use]
pub const fn full(&self) -> u64 {
return self.full;
}
/// Returns the optional highest incremental snapshot slot.
#[must_use]
pub const fn incremental(&self) -> std::option::Option<u64> {
return self.incremental;
}
/// Decodes snapshot-slot information from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireSnapshotSlotInfo>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self { full: wire.full, incremental: wire.incremental }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Optional configuration accepted by `getLeaderSchedule`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaLeaderScheduleConfig {
identity: std::option::Option<ksp_core_lib::Pubkey>,
commitment: std::option::Option<crate::SolanaCommitment>,
}
impl SolanaLeaderScheduleConfig {
/// Creates a leader-schedule configuration.
#[must_use]
pub const fn new(identity: std::option::Option<ksp_core_lib::Pubkey>, commitment: std::option::Option<crate::SolanaCommitment>) -> Self {
return Self { identity, commitment };
}
/// Returns the optional validator identity filter.
#[must_use]
pub const fn identity(&self) -> std::option::Option<&ksp_core_lib::Pubkey> {
return self.identity.as_ref();
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
pub(crate) const fn is_empty(&self) -> bool {
return self.identity.is_none() && self.commitment.is_none();
}
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(identity) = self.identity.as_ref() {
object.insert("identity".to_owned(), serde_json::Value::String(identity.to_string()));
}
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
return serde_json::Value::Object(object);
}
}
/// Typed parameter overload for `getLeaderSchedule`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaLeaderScheduleRequest {
/// Query the current epoch, optionally with a config object.
CurrentEpoch(std::option::Option<crate::SolanaLeaderScheduleConfig>),
/// Query the epoch containing one slot, optionally with a config object.
Slot {
/// Slot whose epoch should be queried.
slot: u64,
/// Optional leader-schedule config sent as the second positional parameter.
config: std::option::Option<crate::SolanaLeaderScheduleConfig>,
},
}
impl Default for SolanaLeaderScheduleRequest {
fn default() -> Self {
return Self::CurrentEpoch(std::option::Option::None);
}
}
impl SolanaLeaderScheduleRequest {
/// Serializes the typed overload to the exact positional JSON-RPC params.
#[must_use]
pub(crate) fn to_json_params(&self) -> std::vec::Vec<serde_json::Value> {
return match self {
Self::CurrentEpoch(std::option::Option::None) => std::vec::Vec::new(),
Self::CurrentEpoch(std::option::Option::Some(config)) if config.is_empty() => std::vec::Vec::new(),
Self::CurrentEpoch(std::option::Option::Some(config)) => std::vec![config.to_json_value()],
Self::Slot { slot, config: std::option::Option::None } => std::vec![serde_json::json!(slot)],
Self::Slot { slot, config: std::option::Option::Some(config) } if config.is_empty() => std::vec![serde_json::json!(slot)],
Self::Slot { slot, config: std::option::Option::Some(config) } => std::vec![serde_json::json!(slot), config.to_json_value()],
};
}
}
/// Leader schedule mapping validator identities to relative epoch slot indices.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaLeaderSchedule {
entries: std::collections::BTreeMap<ksp_core_lib::Pubkey, std::vec::Vec<usize>>,
}
impl SolanaLeaderSchedule {
/// Returns the complete leader schedule map.
#[must_use]
pub const fn entries(&self) -> &std::collections::BTreeMap<ksp_core_lib::Pubkey, std::vec::Vec<usize>> {
return &self.entries;
}
/// Decodes a leader schedule map from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<std::collections::BTreeMap<std::string::String, std::vec::Vec<usize>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut entries = std::collections::BTreeMap::new();
for (identity, slots) in wire {
let pubkey = crate::parse_wire_pubkey(method, "leader_identity", identity.as_str());
let pubkey = match pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
entries.insert(pubkey, slots);
}
return std::result::Result::Ok(Self { entries });
}
}
/// Configuration accepted by `getVoteAccounts`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaVoteAccountsConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
vote_pubkey: std::option::Option<ksp_core_lib::Pubkey>,
keep_unstaked_delinquents: std::option::Option<bool>,
delinquent_slot_distance: std::option::Option<u64>,
}
impl SolanaVoteAccountsConfig {
/// Creates a vote-accounts configuration.
#[must_use]
pub const fn new(
commitment: std::option::Option<crate::SolanaCommitment>,
vote_pubkey: std::option::Option<ksp_core_lib::Pubkey>,
keep_unstaked_delinquents: std::option::Option<bool>,
delinquent_slot_distance: std::option::Option<u64>,
) -> Self {
return Self { commitment, vote_pubkey, keep_unstaked_delinquents, delinquent_slot_distance };
}
/// Returns the optional commitment.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional vote-account public key filter.
#[must_use]
pub const fn vote_pubkey(&self) -> std::option::Option<&ksp_core_lib::Pubkey> {
return self.vote_pubkey.as_ref();
}
/// Returns whether unstaked delinquent validators should be kept.
#[must_use]
pub const fn keep_unstaked_delinquents(&self) -> std::option::Option<bool> {
return self.keep_unstaked_delinquents;
}
/// Returns the optional delinquent slot distance.
#[must_use]
pub const fn delinquent_slot_distance(&self) -> std::option::Option<u64> {
return self.delinquent_slot_distance;
}
pub(crate) const fn is_empty(&self) -> bool {
return self.commitment.is_none() && self.vote_pubkey.is_none() && self.keep_unstaked_delinquents.is_none() && self.delinquent_slot_distance.is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(vote_pubkey) = self.vote_pubkey.as_ref() {
object.insert("votePubkey".to_owned(), serde_json::Value::String(vote_pubkey.to_string()));
}
if let std::option::Option::Some(value) = self.keep_unstaked_delinquents {
object.insert("keepUnstakedDelinquents".to_owned(), serde_json::Value::Bool(value));
}
if let std::option::Option::Some(value) = self.delinquent_slot_distance {
object.insert("delinquentSlotDistance".to_owned(), serde_json::Value::Number(value.into()));
}
return serde_json::Value::Object(object);
}
}
/// One epoch-credit history entry returned by `getVoteAccounts`.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct SolanaEpochCredits {
epoch: u64,
credits: u64,
previous_credits: u64,
}
impl SolanaEpochCredits {
/// Returns the epoch number.
#[must_use]
pub const fn epoch(&self) -> u64 {
return self.epoch;
}
/// Returns cumulative credits at the end of the epoch.
#[must_use]
pub const fn credits(&self) -> u64 {
return self.credits;
}
/// Returns cumulative credits before the epoch.
#[must_use]
pub const fn previous_credits(&self) -> u64 {
return self.previous_credits;
}
}
/// One validator vote-account record returned by `getVoteAccounts`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaVoteAccountInfo {
vote_pubkey: ksp_core_lib::Pubkey,
node_pubkey: ksp_core_lib::Pubkey,
activated_stake: u64,
commission: u8,
inflation_rewards_commission_bps: std::option::Option<u16>,
epoch_vote_account: bool,
epoch_credits: std::vec::Vec<crate::SolanaEpochCredits>,
last_vote: u64,
root_slot: u64,
}
impl SolanaVoteAccountInfo {
/// Returns the vote account public key.
#[must_use]
pub const fn vote_pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.vote_pubkey;
}
/// Returns the validator identity public key.
#[must_use]
pub const fn node_pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.node_pubkey;
}
/// Returns the activated stake in lamports.
#[must_use]
pub const fn activated_stake(&self) -> u64 {
return self.activated_stake;
}
/// Returns the legacy/effective percentage commission field.
#[must_use]
pub const fn commission(&self) -> u8 {
return self.commission;
}
/// Returns the optional raw inflation-rewards commission in basis points.
#[must_use]
pub const fn inflation_rewards_commission_bps(&self) -> std::option::Option<u16> {
return self.inflation_rewards_commission_bps;
}
/// Returns whether the vote account is staked for the current epoch.
#[must_use]
pub const fn epoch_vote_account(&self) -> bool {
return self.epoch_vote_account;
}
/// Returns the bounded RPC epoch-credit history.
#[must_use]
pub fn epoch_credits(&self) -> &[crate::SolanaEpochCredits] {
return self.epoch_credits.as_slice();
}
/// Returns the latest voted slot or zero when no vote exists.
#[must_use]
pub const fn last_vote(&self) -> u64 {
return self.last_vote;
}
/// Returns the current root slot or zero when no root exists.
#[must_use]
pub const fn root_slot(&self) -> u64 {
return self.root_slot;
}
/// Decodes one vote-account record from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireVoteAccountInfo>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let vote_pubkey = crate::parse_wire_pubkey(method, "votePubkey", wire.vote_pubkey.as_str());
let vote_pubkey = match vote_pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let node_pubkey = crate::parse_wire_pubkey(method, "nodePubkey", wire.node_pubkey.as_str());
let node_pubkey = match node_pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut epoch_credits = std::vec::Vec::with_capacity(wire.epoch_credits.len());
for entry in wire.epoch_credits {
epoch_credits.push(crate::SolanaEpochCredits { epoch: entry[0], credits: entry[1], previous_credits: entry[2] });
}
return std::result::Result::Ok(Self {
vote_pubkey,
node_pubkey,
activated_stake: wire.activated_stake,
commission: wire.commission,
inflation_rewards_commission_bps: wire.inflation_rewards_commission_bps,
epoch_vote_account: wire.epoch_vote_account,
epoch_credits,
last_vote: wire.last_vote,
root_slot: wire.root_slot,
});
}
}
/// Current and delinquent validator vote-account groups returned by `getVoteAccounts`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaVoteAccountStatus {
current: std::vec::Vec<crate::SolanaVoteAccountInfo>,
delinquent: std::vec::Vec<crate::SolanaVoteAccountInfo>,
}
impl SolanaVoteAccountStatus {
/// Returns current vote accounts.
#[must_use]
pub fn current(&self) -> &[crate::SolanaVoteAccountInfo] {
return self.current.as_slice();
}
/// Returns delinquent vote accounts.
#[must_use]
pub fn delinquent(&self) -> &[crate::SolanaVoteAccountInfo] {
return self.delinquent.as_slice();
}
/// Decodes the complete vote-account status response from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireVoteAccountStatus>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut current = std::vec::Vec::with_capacity(wire.current.len());
for value in wire.current {
let decoded = crate::SolanaVoteAccountInfo::decode_wire(method, value);
match decoded {
std::result::Result::Ok(info) => current.push(info),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
let mut delinquent = std::vec::Vec::with_capacity(wire.delinquent.len());
for value in wire.delinquent {
let decoded = crate::SolanaVoteAccountInfo::decode_wire(method, value);
match decoded {
std::result::Result::Ok(info) => delinquent.push(info),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(Self { current, delinquent });
}
}
#[derive(serde::Deserialize)]
struct WireIdentity {
identity: std::string::String,
}
impl crate::HttpTransportPool {
/// Executes typed `getClusterNodes` through the common KSP HTTP transport path.
pub async fn get_cluster_nodes(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaClusterNode>> {
let value = self.execute_cluster_rpc("getClusterNodes", role, std::vec::Vec::new()).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decoded = crate::decode_wire_json::<std::vec::Vec<serde_json::Value>>("getClusterNodes", value);
let values = match decoded {
std::result::Result::Ok(values) => values,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut nodes = std::vec::Vec::with_capacity(values.len());
for value in values {
let node = crate::SolanaClusterNode::decode_wire("getClusterNodes", value);
match node {
std::result::Result::Ok(node) => nodes.push(node),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(nodes);
}
/// Executes typed `getEpochInfo` through the common KSP HTTP transport path.
pub async fn get_epoch_info(
&self,
role: &crate::HttpRoleName,
config: std::option::Option<&crate::SolanaContextConfig>,
) -> ksp_core_lib::Result<crate::SolanaEpochInfo> {
let mut params = std::vec::Vec::new();
push_context_config(&mut params, config);
let value = self.execute_cluster_rpc("getEpochInfo", role, params).await;
return match value {
std::result::Result::Ok(value) => crate::SolanaEpochInfo::decode_wire("getEpochInfo", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getEpochSchedule` through the common KSP HTTP transport path.
pub async fn get_epoch_schedule(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaEpochSchedule> {
let value = self.execute_cluster_rpc("getEpochSchedule", role, std::vec::Vec::new()).await;
return match value {
std::result::Result::Ok(value) => crate::SolanaEpochSchedule::decode_wire("getEpochSchedule", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getHighestSnapshotSlot` through the common KSP HTTP transport path.
pub async fn get_highest_snapshot_slot(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaSnapshotSlotInfo> {
let value = self.execute_cluster_rpc("getHighestSnapshotSlot", role, std::vec::Vec::new()).await;
return match value {
std::result::Result::Ok(value) => crate::SolanaSnapshotSlotInfo::decode_wire("getHighestSnapshotSlot", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getIdentity` through the common KSP HTTP transport path.
pub async fn get_identity(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
let value = self.execute_cluster_rpc("getIdentity", role, std::vec::Vec::new()).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decoded = crate::decode_wire_json::<WireIdentity>("getIdentity", value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::parse_wire_pubkey("getIdentity", "identity", wire.identity.as_str());
}
/// Executes typed `getMaxRetransmitSlot` through the common KSP HTTP transport path.
pub async fn get_max_retransmit_slot(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<u64> {
return self.get_cluster_simple_slot("getMaxRetransmitSlot", role).await;
}
/// Executes typed `getMaxShredInsertSlot` through the common KSP HTTP transport path.
pub async fn get_max_shred_insert_slot(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<u64> {
return self.get_cluster_simple_slot("getMaxShredInsertSlot", role).await;
}
/// Executes typed `getLeaderSchedule` through the common KSP HTTP transport path.
pub async fn get_leader_schedule(
&self,
role: &crate::HttpRoleName,
request: &crate::SolanaLeaderScheduleRequest,
) -> ksp_core_lib::Result<std::option::Option<crate::SolanaLeaderSchedule>> {
let value = self.execute_cluster_rpc("getLeaderSchedule", role, request.to_json_params()).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if value.is_null() {
return std::result::Result::Ok(std::option::Option::None);
}
let schedule = crate::SolanaLeaderSchedule::decode_wire("getLeaderSchedule", value);
return match schedule {
std::result::Result::Ok(schedule) => std::result::Result::Ok(std::option::Option::Some(schedule)),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getSlot` through the common KSP HTTP transport path.
pub async fn get_slot(&self, role: &crate::HttpRoleName, config: std::option::Option<&crate::SolanaContextConfig>) -> ksp_core_lib::Result<u64> {
let mut params = std::vec::Vec::new();
push_context_config(&mut params, config);
let value = self.execute_cluster_rpc("getSlot", role, params).await;
return match value {
std::result::Result::Ok(value) => crate::decode_wire_json::<u64>("getSlot", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getSlotLeader` through the common KSP HTTP transport path.
pub async fn get_slot_leader(
&self,
role: &crate::HttpRoleName,
config: std::option::Option<&crate::SolanaContextConfig>,
) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
let mut params = std::vec::Vec::new();
push_context_config(&mut params, config);
let value = self.execute_cluster_rpc("getSlotLeader", role, params).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decoded = crate::decode_wire_json::<std::string::String>("getSlotLeader", value);
let leader = match decoded {
std::result::Result::Ok(leader) => leader,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::parse_wire_pubkey("getSlotLeader", "leader", leader.as_str());
}
/// Executes typed `getSlotLeaders` through the common KSP HTTP transport path.
pub async fn get_slot_leaders(&self, role: &crate::HttpRoleName, start_slot: u64, limit: u64) -> ksp_core_lib::Result<std::vec::Vec<ksp_core_lib::Pubkey>> {
if limit == 0 || limit > MAX_GET_SLOT_LEADERS {
return invalid_cluster_parameters("getSlotLeaders", "getSlotLeaders limit must be between 1 and 5000", "limit", limit);
}
let params = std::vec![serde_json::json!(start_slot), serde_json::json!(limit)];
let value = self.execute_cluster_rpc("getSlotLeaders", role, params).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_pubkey_list("getSlotLeaders", "leader", value);
}
/// Executes typed `getVoteAccounts` through the common KSP HTTP transport path.
pub async fn get_vote_accounts(
&self,
role: &crate::HttpRoleName,
config: std::option::Option<&crate::SolanaVoteAccountsConfig>,
) -> ksp_core_lib::Result<crate::SolanaVoteAccountStatus> {
let mut params = std::vec::Vec::new();
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let value = self.execute_cluster_rpc("getVoteAccounts", role, params).await;
return match value {
std::result::Result::Ok(value) => crate::SolanaVoteAccountStatus::decode_wire("getVoteAccounts", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
async fn get_cluster_simple_slot(&self, method_name: &'static str, role: &crate::HttpRoleName) -> ksp_core_lib::Result<u64> {
let value = self.execute_cluster_rpc(method_name, role, std::vec::Vec::new()).await;
return match value {
std::result::Result::Ok(value) => crate::decode_wire_json::<u64>(method_name, value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
async fn execute_cluster_rpc(
&self,
method_name: &'static str,
role: &crate::HttpRoleName,
params: std::vec::Vec<serde_json::Value>,
) -> ksp_core_lib::Result<serde_json::Value> {
let method = cluster_descriptor(method_name);
let method = match method {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return self.execute_standard_rpc(role, method, params).await;
}
}
fn push_context_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaContextConfig>) {
if let std::option::Option::Some(config) = config
&& (config.commitment().is_some() || config.min_context_slot().is_some())
{
params.push((*config).to_json_value());
}
return;
}
fn decode_pubkey_list(method: &str, field: &'static str, value: serde_json::Value) -> ksp_core_lib::Result<std::vec::Vec<ksp_core_lib::Pubkey>> {
let decoded = crate::decode_wire_json::<std::vec::Vec<std::string::String>>(method, value);
let values = match decoded {
std::result::Result::Ok(values) => values,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut pubkeys = std::vec::Vec::with_capacity(values.len());
for value in values {
let pubkey = crate::parse_wire_pubkey(method, field, value.as_str());
match pubkey {
std::result::Result::Ok(pubkey) => pubkeys.push(pubkey),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(pubkeys);
}
fn invalid_cluster_parameters<T>(method: &str, message: &str, field: &'static str, value: u64) -> ksp_core_lib::Result<T> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, message)
.with_context("rpc_method", method)
.with_context(field, value.to_string()),
);
}
fn cluster_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
let descriptor = crate::find_http_rpc_method(method);
return match descriptor {
std::option::Option::Some(descriptor)
if descriptor.category() == crate::HttpRpcCategory::Cluster && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
{
std::result::Result::Ok(descriptor)
},
_ => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Cluster descriptor is missing from the audited 0.2.2 registry")
.with_context("rpc_method", method),
),
};
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireClusterNode {
pubkey: std::string::String,
#[serde(default)]
feature_set: std::option::Option<u32>,
#[serde(default)]
gossip: std::option::Option<std::string::String>,
#[serde(default)]
pubsub: std::option::Option<std::string::String>,
#[serde(default)]
rpc: std::option::Option<std::string::String>,
#[serde(default)]
serve_repair: std::option::Option<std::string::String>,
#[serde(default)]
shred_version: std::option::Option<u16>,
#[serde(default)]
tpu: std::option::Option<std::string::String>,
#[serde(default)]
tpu_forwards: std::option::Option<std::string::String>,
#[serde(default)]
tpu_forwards_quic: std::option::Option<std::string::String>,
#[serde(default)]
tpu_quic: std::option::Option<std::string::String>,
#[serde(default)]
tpu_vote: std::option::Option<std::string::String>,
#[serde(default)]
tvu: std::option::Option<std::string::String>,
#[serde(default)]
version: std::option::Option<std::string::String>,
#[serde(default)]
client_id: std::option::Option<std::string::String>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireEpochInfo {
absolute_slot: u64,
block_height: u64,
epoch: u64,
slot_index: u64,
slots_in_epoch: u64,
transaction_count: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireEpochSchedule {
first_normal_epoch: u64,
first_normal_slot: u64,
leader_schedule_slot_offset: u64,
slots_per_epoch: u64,
warmup: bool,
}
#[derive(serde::Deserialize)]
struct WireSnapshotSlotInfo {
full: u64,
incremental: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireVoteAccountInfo {
vote_pubkey: std::string::String,
node_pubkey: std::string::String,
activated_stake: u64,
commission: u8,
#[serde(default)]
inflation_rewards_commission_bps: std::option::Option<u16>,
epoch_vote_account: bool,
epoch_credits: std::vec::Vec<[u64; 3]>,
last_vote: u64,
root_slot: u64,
}
#[derive(serde::Deserialize)]
struct WireVoteAccountStatus {
current: std::vec::Vec<serde_json::Value>,
delinquent: std::vec::Vec<serde_json::Value>,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_cluster.rs"]
mod tests;

View File

@@ -0,0 +1,193 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
// version: 4
/// Commitment level accepted by typed Solana HTTP RPC adapters.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaCommitment {
/// Query the most recent processed bank.
Processed,
/// Query a bank confirmed by cluster vote.
Confirmed,
/// Query a finalized bank.
Finalized,
}
impl SolanaCommitment {
/// Returns the Solana JSON-RPC commitment string.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Processed => "processed",
Self::Confirmed => "confirmed",
Self::Finalized => "finalized",
};
}
}
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaCommitmentConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
}
impl SolanaCommitmentConfig {
/// Creates an explicit commitment-only configuration.
#[must_use]
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>) -> Self {
return Self { commitment };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
return serde_json::Value::Object(object);
}
}
/// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaContextConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
min_context_slot: std::option::Option<u64>,
}
impl SolanaContextConfig {
/// Creates an explicit context-aware RPC configuration.
#[must_use]
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, min_context_slot: std::option::Option<u64>) -> Self {
return Self { commitment, min_context_slot };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional minimum context slot.
#[must_use]
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
return self.min_context_slot;
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(min_context_slot) = self.min_context_slot {
object.insert("minContextSlot".to_owned(), serde_json::Value::Number(min_context_slot.into()));
}
return serde_json::Value::Object(object);
}
}
/// Typed Solana RPC context shared by contextual HTTP responses.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaRpcContext {
slot: u64,
api_version: std::option::Option<std::string::String>,
}
impl SolanaRpcContext {
/// Returns the context slot reported by the RPC node.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the optional RPC API version reported by the node.
#[must_use]
pub fn api_version(&self) -> std::option::Option<&str> {
return match self.api_version.as_ref() {
std::option::Option::Some(value) => std::option::Option::Some(value.as_str()),
std::option::Option::None => std::option::Option::None,
};
}
/// Decodes one RPC context from a parsed JSON value for typed RPC adapters.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = decode_wire_json::<WireRpcContext>(method, value);
let context = match decoded {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { slot: context.slot, api_version: context.api_version });
}
}
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaRpcResponse<T> {
context: crate::SolanaRpcContext,
value: T,
}
impl<T> SolanaRpcResponse<T> {
/// Returns the Solana response context.
#[must_use]
pub const fn context(&self) -> &crate::SolanaRpcContext {
return &self.context;
}
/// Returns the typed response value.
#[must_use]
pub const fn value(&self) -> &T {
return &self.value;
}
/// Creates a contextual response after wire decoding and validation.
#[must_use]
pub(crate) const fn new(context: crate::SolanaRpcContext, value: T) -> Self {
return Self { context, value };
}
}
/// Decodes one private serde wire type and maps shape failures to the shared Transport error domain.
pub(crate) fn decode_wire_json<T: serde::de::DeserializeOwned>(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<T> {
let decoded = serde_json::from_value::<T>(value);
return match decoded {
std::result::Result::Ok(decoded) => std::result::Result::Ok(decoded),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response has an invalid wire shape")
.with_context("rpc_method", method)
.with_source(error),
),
};
}
/// Parses a base58 public key from one wire field without echoing its value into diagnostics.
pub(crate) fn parse_wire_pubkey(method: &str, field: &str, value: &str) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
let parsed = value.parse::<ksp_core_lib::Pubkey>();
return match parsed {
std::result::Result::Ok(pubkey) => std::result::Result::Ok(pubkey),
std::result::Result::Err(_) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response contains an invalid public key")
.with_context("rpc_method", method)
.with_context("field", field),
),
};
}
#[derive(serde::Deserialize)]
struct WireRpcContext {
slot: u64,
#[serde(rename = "apiVersion", default)]
api_version: std::option::Option<std::string::String>,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_common.rs"]
mod tests;

View File

@@ -0,0 +1,354 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
// version: 3
/// Exclusive selector accepted by token-account list RPC methods.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaTokenAccountSelector {
/// Select token accounts for one mint.
Mint(ksp_core_lib::Pubkey),
/// Select token accounts owned by one token program.
ProgramId(ksp_core_lib::Pubkey),
}
impl SolanaTokenAccountSelector {
/// Serializes the exclusive selector to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::Mint(pubkey) => serde_json::json!({"mint": pubkey.to_string()}),
Self::ProgramId(pubkey) => serde_json::json!({"programId": pubkey.to_string()}),
};
}
}
/// Token amount returned by Solana HTTP token RPC methods.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaTokenAmount {
amount: std::string::String,
decimals: u8,
ui_amount: std::option::Option<f64>,
ui_amount_string: std::string::String,
}
impl SolanaTokenAmount {
/// Returns the integer token amount as an exact decimal string.
#[must_use]
pub fn amount(&self) -> &str {
return self.amount.as_str();
}
/// Returns the mint decimal precision.
#[must_use]
pub const fn decimals(&self) -> u8 {
return self.decimals;
}
/// Returns the nullable floating-point UI amount exactly as provided by RPC.
#[must_use]
pub const fn ui_amount(&self) -> std::option::Option<f64> {
return self.ui_amount;
}
/// Returns the exact UI amount string provided by RPC.
#[must_use]
pub fn ui_amount_string(&self) -> &str {
return self.ui_amount_string.as_str();
}
/// Decodes one token amount from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireTokenAmount>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
amount: wire.amount,
decimals: wire.decimals,
ui_amount: wire.ui_amount,
ui_amount_string: wire.ui_amount_string,
}),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Token-account balance entry returned by `getTokenLargestAccounts`.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaTokenAccountBalance {
address: ksp_core_lib::Pubkey,
amount: crate::SolanaTokenAmount,
}
impl SolanaTokenAccountBalance {
/// Returns the token account address.
#[must_use]
pub const fn address(&self) -> &ksp_core_lib::Pubkey {
return &self.address;
}
/// Returns the token amount fields.
#[must_use]
pub const fn amount(&self) -> &crate::SolanaTokenAmount {
return &self.amount;
}
/// Decodes one token-account balance entry from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireTokenAccountBalance>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let address = crate::parse_wire_pubkey(method, "address", wire.address.as_str());
let address = match address {
std::result::Result::Ok(address) => address,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let amount = crate::SolanaTokenAmount {
amount: wire.amount,
decimals: wire.decimals,
ui_amount: wire.ui_amount,
ui_amount_string: wire.ui_amount_string,
};
return std::result::Result::Ok(Self { address, amount });
}
}
#[derive(serde::Deserialize)]
struct WireTokenAmount {
amount: std::string::String,
decimals: u8,
#[serde(rename = "uiAmount")]
ui_amount: std::option::Option<f64>,
#[serde(rename = "uiAmountString")]
ui_amount_string: std::string::String,
}
#[derive(serde::Deserialize)]
struct WireTokenAccountBalance {
address: std::string::String,
amount: std::string::String,
decimals: u8,
#[serde(rename = "uiAmount")]
ui_amount: std::option::Option<f64>,
#[serde(rename = "uiAmountString")]
ui_amount_string: std::string::String,
}
#[derive(serde::Deserialize)]
struct WireRpcResponse<T> {
context: serde_json::Value,
value: T,
}
impl crate::HttpTransportPool {
/// Executes typed `getTokenAccountBalance` through the common KSP HTTP transport path.
pub async fn get_token_account_balance(
&self,
role: &crate::HttpRoleName,
token_account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
let method_result = token_descriptor("getTokenAccountBalance");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec![serde_json::Value::String(token_account.to_string())];
push_commitment_config(&mut params, config);
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_token_amount_response("getTokenAccountBalance", value);
}
/// Executes typed `getTokenAccountsByDelegate` through the common KSP HTTP transport path.
pub async fn get_token_accounts_by_delegate(
&self,
role: &crate::HttpRoleName,
delegate: &ksp_core_lib::Pubkey,
selector: &crate::SolanaTokenAccountSelector,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
return self.get_token_accounts_list("getTokenAccountsByDelegate", role, delegate, selector, config).await;
}
/// Executes typed `getTokenAccountsByOwner` through the common KSP HTTP transport path.
pub async fn get_token_accounts_by_owner(
&self,
role: &crate::HttpRoleName,
owner: &ksp_core_lib::Pubkey,
selector: &crate::SolanaTokenAccountSelector,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
return self.get_token_accounts_list("getTokenAccountsByOwner", role, owner, selector, config).await;
}
/// Executes typed `getTokenLargestAccounts` through the common KSP HTTP transport path.
pub async fn get_token_largest_accounts(
&self,
role: &crate::HttpRoleName,
mint: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaTokenAccountBalance>>> {
let method_result = token_descriptor("getTokenLargestAccounts");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec![serde_json::Value::String(mint.to_string())];
push_commitment_config(&mut params, config);
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_token_account_balances_response("getTokenLargestAccounts", value);
}
/// Executes typed `getTokenSupply` through the common KSP HTTP transport path.
pub async fn get_token_supply(
&self,
role: &crate::HttpRoleName,
mint: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
let method_result = token_descriptor("getTokenSupply");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec![serde_json::Value::String(mint.to_string())];
push_commitment_config(&mut params, config);
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_token_amount_response("getTokenSupply", value);
}
async fn get_token_accounts_list(
&self,
method_name: &'static str,
role: &crate::HttpRoleName,
address: &ksp_core_lib::Pubkey,
selector: &crate::SolanaTokenAccountSelector,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
let method_result = token_descriptor(method_name);
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec![serde_json::Value::String(address.to_string()), selector.to_json_value()];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_keyed_accounts_response(method_name, value);
}
}
fn token_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
let descriptor = crate::find_http_rpc_method(method);
return match descriptor {
std::option::Option::Some(descriptor)
if descriptor.category() == crate::HttpRpcCategory::Tokens && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
{
std::result::Result::Ok(descriptor)
},
_ => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Tokens descriptor is missing from the audited 0.2.2 registry")
.with_context("rpc_method", method),
),
};
}
fn push_commitment_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaCommitmentConfig>) {
if let std::option::Option::Some(config) = config
&& config.commitment().is_some()
{
params.push(config.to_json_value());
}
return;
}
fn decode_token_amount_response(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<serde_json::Value>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let amount = crate::SolanaTokenAmount::decode_wire(method, wire.value);
let amount = match amount {
std::result::Result::Ok(amount) => amount,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, amount));
}
fn decode_keyed_accounts_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<serde_json::Value>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut accounts = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
let account = crate::SolanaKeyedAccount::decode_wire(method, value);
match account {
std::result::Result::Ok(account) => accounts.push(account),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, accounts));
}
fn decode_token_account_balances_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaTokenAccountBalance>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<serde_json::Value>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut accounts = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
let account = crate::SolanaTokenAccountBalance::decode_wire(method, value);
match account {
std::result::Result::Ok(account) => accounts.push(account),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, accounts));
}
#[cfg(test)]
#[path = "../unit_tests/rpc_tokens.rs"]
mod tests;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 5
// version: 15
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -171,3 +171,179 @@ fn public_typed_canary_contracts_are_available_from_crate_root() {
assert_eq!(config.min_context_slot(), std::option::Option::Some(42));
assert_eq!(ksp_onchain_transport_lib::SolanaNodeHealth::Healthy, ksp_onchain_transport_lib::SolanaNodeHealth::Healthy);
}
#[test]
fn public_pre_002_shared_rpc_types_are_constructible_from_crate_root() {
let context = ksp_onchain_transport_lib::SolanaContextConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::Some(123),
);
assert_eq!(context.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized));
assert_eq!(context.min_context_slot(), std::option::Option::Some(123));
let account = ksp_onchain_transport_lib::SolanaAccountInfoConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::JsonParsed),
std::option::Option::None,
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
std::option::Option::None,
);
assert_eq!(account.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::JsonParsed));
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let selector = ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(pubkey);
assert!(matches!(selector, ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(_)));
let vote = ksp_onchain_transport_lib::SolanaVoteAccountsConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::Some(pubkey),
std::option::Option::Some(true),
std::option::Option::Some(128),
);
assert_eq!(vote.vote_pubkey(), std::option::Option::Some(&pubkey));
}
#[test]
fn public_pre_003_account_wrappers_are_available_from_crate_root() {
let _get_account_info = ksp_onchain_transport_lib::HttpTransportPool::get_account_info;
let _get_largest_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_largest_accounts;
let _get_minimum_balance_for_rent_exemption = ksp_onchain_transport_lib::HttpTransportPool::get_minimum_balance_for_rent_exemption;
let _get_multiple_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_multiple_accounts;
let _get_program_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_program_accounts;
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_RPC_PARAMETERS.domain(), "onchain_transport");
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_RPC_PARAMETERS.code(), "invalid_rpc_parameters");
}
#[test]
fn public_pre_004_token_wrappers_are_available_from_crate_root() {
let _get_token_account_balance = ksp_onchain_transport_lib::HttpTransportPool::get_token_account_balance;
let _get_token_accounts_by_delegate = ksp_onchain_transport_lib::HttpTransportPool::get_token_accounts_by_delegate;
let _get_token_accounts_by_owner = ksp_onchain_transport_lib::HttpTransportPool::get_token_accounts_by_owner;
let _get_token_largest_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_token_largest_accounts;
let _get_token_supply = ksp_onchain_transport_lib::HttpTransportPool::get_token_supply;
let mint = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let selector = ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(mint);
assert!(matches!(selector, ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(_)));
}
#[test]
fn public_pre_005_simple_cluster_wrappers_are_available_from_crate_root() {
let _get_cluster_nodes = ksp_onchain_transport_lib::HttpTransportPool::get_cluster_nodes;
let _get_epoch_info = ksp_onchain_transport_lib::HttpTransportPool::get_epoch_info;
let _get_epoch_schedule = ksp_onchain_transport_lib::HttpTransportPool::get_epoch_schedule;
let _get_highest_snapshot_slot = ksp_onchain_transport_lib::HttpTransportPool::get_highest_snapshot_slot;
let _get_identity = ksp_onchain_transport_lib::HttpTransportPool::get_identity;
let _get_max_retransmit_slot = ksp_onchain_transport_lib::HttpTransportPool::get_max_retransmit_slot;
let _get_max_shred_insert_slot = ksp_onchain_transport_lib::HttpTransportPool::get_max_shred_insert_slot;
}
#[test]
fn public_pre_006_remaining_cluster_wrappers_are_available_from_crate_root() {
let _get_leader_schedule = ksp_onchain_transport_lib::HttpTransportPool::get_leader_schedule;
let _get_slot = ksp_onchain_transport_lib::HttpTransportPool::get_slot;
let _get_slot_leader = ksp_onchain_transport_lib::HttpTransportPool::get_slot_leader;
let _get_slot_leaders = ksp_onchain_transport_lib::HttpTransportPool::get_slot_leaders;
let _get_vote_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_vote_accounts;
let request = ksp_onchain_transport_lib::SolanaLeaderScheduleRequest::default();
assert!(matches!(request, ksp_onchain_transport_lib::SolanaLeaderScheduleRequest::CurrentEpoch(std::option::Option::None)));
}
#[test]
fn public_transaction_pre_002_shared_types_are_constructible_from_crate_root() {
let get_transaction = ksp_onchain_transport_lib::SolanaGetTransactionConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::JsonParsed),
std::option::Option::Some(0),
);
assert_eq!(get_transaction.max_supported_transaction_version(), std::option::Option::Some(0));
let send = ksp_onchain_transport_lib::SolanaSendTransactionConfig::new(
std::option::Option::Some(false),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64),
std::option::Option::Some(3),
std::option::Option::None,
);
assert_eq!(send.max_retries(), std::option::Option::Some(3));
let pagination = ksp_onchain_transport_lib::SolanaSignaturesForAddressConfig::new(
std::option::Option::Some("before".to_owned()),
std::option::Option::None,
std::option::Option::Some(10),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::None,
);
assert_eq!(pagination.limit(), std::option::Option::Some(10));
assert!(ksp_onchain_transport_lib::SolanaWireField::<u64>::Omitted.is_omitted());
}
#[test]
fn public_transaction_pre_003_read_wrappers_are_available_from_crate_root() {
let _get_fee_for_message = ksp_onchain_transport_lib::HttpTransportPool::get_fee_for_message;
let _get_latest_blockhash = ksp_onchain_transport_lib::HttpTransportPool::get_latest_blockhash;
let _get_transaction_count = ksp_onchain_transport_lib::HttpTransportPool::get_transaction_count;
let _is_blockhash_valid = ksp_onchain_transport_lib::HttpTransportPool::is_blockhash_valid;
}
#[test]
fn public_transaction_pre_004_additional_read_wrappers_are_available_from_crate_root() {
let _get_recent_prioritization_fees = ksp_onchain_transport_lib::HttpTransportPool::get_recent_prioritization_fees;
let _get_signatures_for_address = ksp_onchain_transport_lib::HttpTransportPool::get_signatures_for_address;
let _get_signature_statuses = ksp_onchain_transport_lib::HttpTransportPool::get_signature_statuses;
}
#[allow(deprecated)]
#[test]
fn public_transaction_pre_005_get_transaction_complete_request_forms_are_available_from_crate_root() {
let _get_transaction = ksp_onchain_transport_lib::HttpTransportPool::get_transaction;
let _get_transaction_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_transaction_legacy;
let config = ksp_onchain_transport_lib::SolanaGetTransactionConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::JsonParsed),
std::option::Option::Some(0),
);
assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
assert_eq!(config.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::JsonParsed));
assert_eq!(config.max_supported_transaction_version(), std::option::Option::Some(0));
}
#[test]
fn public_transaction_pre_006_write_wrappers_and_complete_configs_are_available_from_crate_root() {
let _request_airdrop = ksp_onchain_transport_lib::HttpTransportPool::request_airdrop;
let _send_transaction = ksp_onchain_transport_lib::HttpTransportPool::send_transaction;
let airdrop = ksp_onchain_transport_lib::SolanaRequestAirdropConfig::new(
std::option::Option::Some("recent-blockhash".to_owned()),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
);
assert_eq!(airdrop.recent_blockhash(), std::option::Option::Some("recent-blockhash"));
let send = ksp_onchain_transport_lib::SolanaSendTransactionConfig::new(
std::option::Option::Some(false),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64),
std::option::Option::Some(5),
std::option::Option::Some(431_000_000),
);
assert_eq!(send.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64));
assert_eq!(send.max_retries(), std::option::Option::Some(5));
assert_eq!(send.min_context_slot(), std::option::Option::Some(431_000_000));
}
#[test]
fn public_transaction_pre_007_simulation_wrapper_and_complete_config_are_available_from_crate_root() {
let _simulate_transaction = ksp_onchain_transport_lib::HttpTransportPool::simulate_transaction;
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let accounts = ksp_onchain_transport_lib::SolanaSimulationAccountsConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64Zstd),
std::vec![address],
);
let config = ksp_onchain_transport_lib::SolanaSimulateTransactionConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64),
std::option::Option::Some(false),
std::option::Option::Some(true),
std::option::Option::Some(431_000_000),
std::option::Option::Some(true),
std::option::Option::Some(accounts),
);
assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed));
assert_eq!(config.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64));
assert_eq!(config.sig_verify(), std::option::Option::Some(true));
assert_eq!(
config.accounts().and_then(ksp_onchain_transport_lib::SolanaSimulationAccountsConfig::encoding),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64Zstd),
);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 1
// version: 13
//! Release-level completeness canaries for the `0.2.1` HTTP foundation contract.
@@ -46,3 +46,387 @@ fn release_foundation_canaries_and_historical_statuses_are_exact() {
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NotApplicable);
}
}
#[test]
fn release_pre_003_accounts_subset_is_exact_without_advancing_other_v0_2_2_families() {
let mut account_names = std::vec::Vec::<&str>::new();
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
if descriptor.coverage_release() == ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2
&& descriptor.category() == ksp_onchain_transport_lib::HttpRpcCategory::Accounts
{
account_names.push(descriptor.method());
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
}
account_names.sort_unstable();
assert_eq!(
account_names,
std::vec!["getAccountInfo", "getLargestAccounts", "getMinimumBalanceForRentExemption", "getMultipleAccounts", "getProgramAccounts",],
);
}
#[test]
fn release_pre_004_tokens_subset_is_exact_and_retry_safe() {
let mut token_names = std::vec::Vec::<&str>::new();
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
if descriptor.coverage_release() == ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2
&& descriptor.category() == ksp_onchain_transport_lib::HttpRpcCategory::Tokens
{
token_names.push(descriptor.method());
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
}
token_names.sort_unstable();
assert_eq!(
token_names,
std::vec!["getTokenAccountBalance", "getTokenAccountsByDelegate", "getTokenAccountsByOwner", "getTokenLargestAccounts", "getTokenSupply",],
);
}
#[test]
fn release_pre_005_simple_cluster_subset_is_exact_and_retry_safe() {
let expected = std::vec![
"getClusterNodes",
"getEpochInfo",
"getEpochSchedule",
"getHighestSnapshotSlot",
"getIdentity",
"getMaxRetransmitSlot",
"getMaxShredInsertSlot",
];
let deferred = std::vec!["getLeaderSchedule", "getSlot", "getSlotLeader", "getSlotLeaders", "getVoteAccounts"];
for method_name in &expected {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.005 cluster descriptor must exist");
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Cluster);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
for method_name in &deferred {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.006 cluster descriptor must remain registered");
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2);
}
assert_eq!(expected.len(), 7);
assert_eq!(deferred.len(), 5);
}
#[test]
fn release_pre_006_completes_cluster_subset_exactly_and_retry_safe() {
let mut expected = std::vec![
"getClusterNodes",
"getEpochInfo",
"getEpochSchedule",
"getHighestSnapshotSlot",
"getIdentity",
"getLeaderSchedule",
"getMaxRetransmitSlot",
"getMaxShredInsertSlot",
"getSlot",
"getSlotLeader",
"getSlotLeaders",
"getVoteAccounts",
];
let mut actual = std::vec::Vec::new();
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
if descriptor.coverage_release() == ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2
&& descriptor.category() == ksp_onchain_transport_lib::HttpRpcCategory::Cluster
{
actual.push(descriptor.method());
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
}
actual.sort_unstable();
expected.sort_unstable();
assert_eq!(actual, expected);
assert_eq!(actual.len(), 12);
}
#[test]
fn release_pre_007_v0_2_2_exact_set_is_complete_without_advancing_future_releases() {
let mut expected = std::vec![
"getAccountInfo",
"getClusterNodes",
"getEpochInfo",
"getEpochSchedule",
"getHighestSnapshotSlot",
"getIdentity",
"getLargestAccounts",
"getLeaderSchedule",
"getMaxRetransmitSlot",
"getMaxShredInsertSlot",
"getMinimumBalanceForRentExemption",
"getMultipleAccounts",
"getProgramAccounts",
"getSlot",
"getSlotLeader",
"getSlotLeaders",
"getTokenAccountBalance",
"getTokenAccountsByDelegate",
"getTokenAccountsByOwner",
"getTokenLargestAccounts",
"getTokenSupply",
"getVoteAccounts",
];
let mut actual = std::vec::Vec::new();
let mut future_transactions = 0_usize;
let mut future_blocks_economics = 0_usize;
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
match descriptor.coverage_release() {
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2 => {
actual.push(descriptor.method());
assert_eq!(descriptor.documentation_status(), ksp_onchain_transport_lib::RpcDocumentationStatus::Stable);
assert_eq!(descriptor.runtime_status(), ksp_onchain_transport_lib::RpcRuntimeStatus::Supported);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
},
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3 => future_transactions += 1,
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_4 => future_blocks_economics += 1,
_ => {},
}
}
actual.sort_unstable();
expected.sort_unstable();
assert_eq!(actual, expected);
assert_eq!(actual.len(), 22);
assert_eq!(future_transactions, 11);
assert_eq!(future_blocks_economics, 15);
}
#[test]
fn release_v0_2_3_descriptor_set_and_safety_classes_are_exact() {
let mut expected = std::vec![
"getFeeForMessage",
"getLatestBlockhash",
"getRecentPrioritizationFees",
"getSignaturesForAddress",
"getSignatureStatuses",
"getTransaction",
"getTransactionCount",
"isBlockhashValid",
"requestAirdrop",
"sendTransaction",
"simulateTransaction",
];
let mut actual = std::vec::Vec::new();
let mut reads = 0_usize;
let mut writes = 0_usize;
let mut simulations = 0_usize;
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
if descriptor.coverage_release() != ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3 {
continue;
}
actual.push(descriptor.method());
match descriptor.operation_kind() {
ksp_onchain_transport_lib::RpcOperationKind::Read => {
reads += 1;
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
},
ksp_onchain_transport_lib::RpcOperationKind::WriteSubmission => {
writes += 1;
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NeverAfterDispatch);
},
ksp_onchain_transport_lib::RpcOperationKind::Simulation => {
simulations += 1;
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
},
}
}
actual.sort_unstable();
expected.sort_unstable();
assert_eq!(actual, expected);
assert_eq!(reads, 8);
assert_eq!(writes, 2);
assert_eq!(simulations, 1);
}
#[test]
fn release_pre_003_transaction_read_subset_is_exact_and_retry_safe() {
let mut expected = std::vec!["getFeeForMessage", "getLatestBlockhash", "getTransactionCount", "isBlockhashValid"];
let mut actual = std::vec::Vec::new();
for method_name in &expected {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.003 Transaction descriptor must exist");
actual.push(descriptor.method());
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
actual.sort_unstable();
expected.sort_unstable();
assert_eq!(actual, expected);
assert_eq!(actual.len(), 4);
}
#[test]
fn release_pre_004_transaction_read_subset_is_exact_and_retry_safe() {
let mut expected = std::vec![
"getFeeForMessage",
"getLatestBlockhash",
"getRecentPrioritizationFees",
"getSignaturesForAddress",
"getSignatureStatuses",
"getTransactionCount",
"isBlockhashValid",
];
let deferred = std::vec!["getTransaction", "requestAirdrop", "sendTransaction", "simulateTransaction"];
let mut actual = std::vec::Vec::new();
for method_name in &expected {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.004 Transaction descriptor must exist");
actual.push(descriptor.method());
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
for method_name in &deferred {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("deferred Transaction descriptor must remain registered");
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
}
actual.sort_unstable();
expected.sort_unstable();
assert_eq!(actual, expected);
assert_eq!(actual.len(), 7);
assert_eq!(deferred.len(), 4);
}
#[test]
fn release_pre_005_transaction_read_subset_adds_complete_get_transaction_without_advancing_writes_or_simulation() {
let mut expected = std::vec![
"getFeeForMessage",
"getLatestBlockhash",
"getRecentPrioritizationFees",
"getSignaturesForAddress",
"getSignatureStatuses",
"getTransaction",
"getTransactionCount",
"isBlockhashValid",
];
let deferred = std::vec!["requestAirdrop", "sendTransaction", "simulateTransaction"];
let mut actual = std::vec::Vec::new();
for method_name in &expected {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.005 Transaction descriptor must exist");
actual.push(descriptor.method());
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
let get_transaction = ksp_onchain_transport_lib::find_http_rpc_method("getTransaction").expect("getTransaction descriptor must exist");
assert!(get_transaction.request_form_status().has_deprecated_legacy());
for method_name in &deferred {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("deferred Transaction descriptor must remain registered");
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
}
actual.sort_unstable();
expected.sort_unstable();
assert_eq!(actual, expected);
assert_eq!(actual.len(), 8);
assert_eq!(deferred.len(), 3);
}
#[test]
fn release_pre_006_transaction_subset_adds_both_write_submissions_without_advancing_simulation() {
let reads = std::vec![
"getFeeForMessage",
"getLatestBlockhash",
"getRecentPrioritizationFees",
"getSignaturesForAddress",
"getSignatureStatuses",
"getTransaction",
"getTransactionCount",
"isBlockhashValid",
];
let writes = std::vec!["requestAirdrop", "sendTransaction"];
for method_name in &reads {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.006 read descriptor must exist");
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
for method_name in &writes {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.006 write descriptor must exist");
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::WriteSubmission);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NeverAfterDispatch);
}
let simulation = ksp_onchain_transport_lib::find_http_rpc_method("simulateTransaction").expect("simulation descriptor must remain registered");
assert_eq!(simulation.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Simulation);
assert_eq!(reads.len(), 8);
assert_eq!(writes.len(), 2);
}
#[test]
fn release_pre_007_completes_all_v0_2_3_transaction_wrappers_with_exact_safety_classes() {
let reads = std::vec![
"getFeeForMessage",
"getLatestBlockhash",
"getRecentPrioritizationFees",
"getSignaturesForAddress",
"getSignatureStatuses",
"getTransaction",
"getTransactionCount",
"isBlockhashValid",
];
let writes = std::vec!["requestAirdrop", "sendTransaction"];
let simulations = std::vec!["simulateTransaction"];
for method_name in &reads {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.007 read descriptor must exist");
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
for method_name in &writes {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.007 write descriptor must exist");
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::WriteSubmission);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NeverAfterDispatch);
}
for method_name in &simulations {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.007 simulation descriptor must exist");
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Simulation);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
assert_eq!(reads.len(), 8);
assert_eq!(writes.len(), 2);
assert_eq!(simulations.len(), 1);
assert_eq!(reads.len() + writes.len() + simulations.len(), 11);
}
#[test]
fn release_pre_008_ksp_transport_007_retro_audit_covers_all_typed_current_methods() {
let mut audited = std::vec::Vec::<&str>::new();
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
if matches!(
descriptor.coverage_release(),
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_1
| ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2
| ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3
) {
audited.push(descriptor.method());
assert_eq!(descriptor.runtime_status(), ksp_onchain_transport_lib::RpcRuntimeStatus::Supported);
match descriptor.operation_kind() {
ksp_onchain_transport_lib::RpcOperationKind::Read | ksp_onchain_transport_lib::RpcOperationKind::Simulation => {
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
},
ksp_onchain_transport_lib::RpcOperationKind::WriteSubmission => {
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NeverAfterDispatch);
},
}
}
}
audited.sort_unstable();
assert_eq!(audited.len(), 37);
assert_eq!(audited.first(), std::option::Option::Some(&"getAccountInfo"));
assert_eq!(audited.last(), std::option::Option::Some(&"simulateTransaction"));
let get_transaction = ksp_onchain_transport_lib::find_http_rpc_method("getTransaction").expect("getTransaction descriptor must exist");
assert!(get_transaction.request_form_status().has_deprecated_legacy());
}

View File

@@ -0,0 +1,120 @@
// file: crates/ksp-onchain-transport-lib/tests/transport_devnet_smoke.rs
// version: 4
//! Opt-in live Devnet smoke for representative pure Transport Accounts/Tokens/Cluster/Transactions reads.
fn devnet_pool() -> ksp_core_lib::Result<ksp_onchain_transport_lib::HttpTransportPool> {
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
let role_settings = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
role,
true,
std::vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()],
0,
ksp_onchain_transport_lib::HttpRoleLimits::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
),
);
let endpoint_url_result = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://api.devnet.solana.com");
let endpoint_url = match endpoint_url_result {
std::result::Result::Ok(endpoint_url) => endpoint_url,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
"solana_devnet_public",
true,
ksp_onchain_transport_lib::HttpProviderName::new("solana-public"),
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
endpoint_url,
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(20),
std::option::Option::Some(4),
std::vec![role_settings],
);
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
std::vec![endpoint],
ksp_onchain_transport_lib::HttpRetrySettings::new(1, std::time::Duration::from_millis(250), std::time::Duration::from_secs(2)),
);
return ksp_onchain_transport_lib::HttpTransportPool::new(settings);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "opt-in live Solana Devnet smoke; performs external network requests"]
async fn programmatic_devnet_transport_reaches_accounts_tokens_cluster_and_transaction_read_wrappers() {
let pool = devnet_pool().expect("programmatic Devnet Transport settings must construct a pool");
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
let account_config = ksp_onchain_transport_lib::SolanaAccountInfoConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64),
std::option::Option::None,
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::None,
);
let account = pool
.get_account_info(&role, &ksp_core_lib::PRGIDPK_SOLANA_SYSTEM, std::option::Option::Some(&account_config))
.await
.expect("Devnet getAccountInfo smoke must succeed");
assert!(account.context().slot() > 0);
assert!(account.value().is_some());
// Follow the current official Devnet example shape with an ordinary owner, the canonical SPL Token program selector,
// and an explicit finalized/jsonParsed config. The owner need not retain any token account; an empty list remains valid.
let token_owner = "A1TMhSGzQxMr1TboBKtgixKz1sS6REASMxPo1qsyTSJd"
.parse::<ksp_core_lib::Pubkey>()
.expect("official Devnet token-owner example must remain a valid Pubkey literal");
let token_program = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
.parse::<ksp_core_lib::Pubkey>()
.expect("canonical SPL Token program id must remain a valid Pubkey literal");
let token_selector = ksp_onchain_transport_lib::SolanaTokenAccountSelector::ProgramId(token_program);
let token_config = ksp_onchain_transport_lib::SolanaAccountInfoConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::JsonParsed),
std::option::Option::None,
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::None,
);
let token_accounts = pool
.get_token_accounts_by_owner(&role, &token_owner, &token_selector, std::option::Option::Some(&token_config))
.await
.expect("Devnet getTokenAccountsByOwner smoke must succeed with the documented finalized/jsonParsed request shape");
assert!(token_accounts.context().slot() > 0);
let epoch = pool
.get_epoch_info(
&role,
std::option::Option::Some(&ksp_onchain_transport_lib::SolanaContextConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::None,
)),
)
.await
.expect("Devnet getEpochInfo smoke must succeed");
assert!(epoch.absolute_slot() > 0);
let vote_accounts = pool.get_vote_accounts(&role, std::option::Option::None).await.expect("Devnet getVoteAccounts smoke must succeed");
assert!(!vote_accounts.current().is_empty() || !vote_accounts.delinquent().is_empty());
let transaction_context = ksp_onchain_transport_lib::SolanaContextConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::None,
);
let latest_blockhash = pool
.get_latest_blockhash(&role, std::option::Option::Some(&transaction_context))
.await
.expect("Devnet getLatestBlockhash smoke must succeed");
assert!(!latest_blockhash.value().blockhash().is_empty());
assert!(latest_blockhash.value().last_valid_block_height() > 0);
let blockhash_valid = pool
.is_blockhash_valid(&role, latest_blockhash.value().blockhash(), std::option::Option::Some(&transaction_context))
.await
.expect("Devnet isBlockhashValid smoke must succeed");
assert_eq!(blockhash_valid.value(), &true);
let transaction_count = pool
.get_transaction_count(&role, std::option::Option::Some(&transaction_context))
.await
.expect("Devnet getTransactionCount smoke must succeed");
assert!(transaction_count > 0);
}

View File

@@ -0,0 +1,446 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs
// version: 3
#[test]
fn account_config_serializes_all_common_fields() {
let config = crate::SolanaAccountInfoConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
std::option::Option::Some(crate::SolanaDataSliceConfig::new(8, 32)),
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(99),
);
assert_eq!(
config.to_json_value(),
serde_json::json!({"encoding":"base64","dataSlice":{"offset":8,"length":32},"commitment":"finalized","minContextSlot":99})
);
}
#[test]
fn program_accounts_config_preserves_filter_variants_and_flags() {
let filters = std::vec![
crate::SolanaProgramAccountFilter::DataSize(165),
crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(4, crate::SolanaMemcmpBytes::Base64("AQID".to_owned()))),
crate::SolanaProgramAccountFilter::TokenAccountState,
];
let config = crate::SolanaProgramAccountsConfig::new(
crate::SolanaAccountInfoConfig::default(),
filters,
std::option::Option::Some(true),
std::option::Option::Some(true),
);
assert_eq!(
config.to_json_value(),
serde_json::json!({
"filters":[{"dataSize":165},{"memcmp":{"offset":4,"bytes":"AQID","encoding":"base64"}},"tokenAccountState"],
"withContext":true,
"sortResults":true
})
);
}
#[test]
fn account_wire_fixture_preserves_legacy_encoded_and_json_parsed_data() {
let values: std::vec::Vec<serde_json::Value> =
serde_json::from_str(include_str!("../fixtures/http/account_data.variants.json")).expect("fixture must decode");
let legacy = crate::SolanaAccount::decode_wire("fixture", values[0].clone()).expect("legacy account must decode");
assert!(matches!(legacy.data(), crate::SolanaAccountData::LegacyBinary(_)));
assert_eq!(legacy.space(), std::option::Option::None);
let encoded = crate::SolanaAccount::decode_wire("fixture", values[1].clone()).expect("encoded account must decode");
assert!(matches!(encoded.data(), crate::SolanaAccountData::Encoded { encoding: crate::SolanaAccountEncoding::Base64Zstd, .. }));
let parsed = crate::SolanaAccount::decode_wire("fixture", values[2].clone()).expect("parsed account must decode");
assert!(matches!(parsed.data(), crate::SolanaAccountData::JsonParsed(_)), "jsonParsed fixture must retain parsed data");
if let crate::SolanaAccountData::JsonParsed(value) = parsed.data() {
assert_eq!(value.program(), "spl-token");
assert_eq!(value.space(), 165);
assert_eq!(value.parsed()["type"], serde_json::json!("account"));
}
}
#[test]
fn staged_largest_and_keyed_account_helpers_match_wire_shapes() {
let config = crate::SolanaLargestAccountsConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(crate::SolanaLargestAccountsFilter::NonCirculating),
std::option::Option::Some(true),
);
assert_eq!(config.to_json_value(), serde_json::json!({"commitment":"finalized","filter":"nonCirculating","sortResults":true}));
let keyed = crate::SolanaKeyedAccount::decode_wire(
"fixture",
serde_json::json!({
"pubkey":"11111111111111111111111111111111",
"account":{
"lamports":42,
"data":["", "base64"],
"owner":"11111111111111111111111111111111",
"executable":false,
"rentEpoch":0,
"space":0
}
}),
)
.expect("keyed account must decode");
assert_eq!(keyed.pubkey().to_string(), "11111111111111111111111111111111");
assert_eq!(keyed.account().lamports(), 42);
let balance = crate::SolanaAccountBalance::decode_wire("fixture", serde_json::json!({"address":"11111111111111111111111111111111","lamports":99}))
.expect("account balance must decode");
assert_eq!(balance.address().to_string(), "11111111111111111111111111111111");
assert_eq!(balance.lamports(), 99);
}
fn pool_for_url(url: &str) -> crate::HttpTransportPool {
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![crate::HttpRequestKind::wildcard()],
10,
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
);
let endpoint = crate::HttpEndpointSettings::new(
"fixture",
true,
crate::HttpProviderName::new("fixture"),
crate::HttpClusterName::new("local"),
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(1),
std::option::Option::Some(1),
std::vec![role],
);
let settings = crate::HttpTransportSettings::new(
std::vec![endpoint],
crate::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)),
);
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
}
fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle<std::string::String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
let address = listener.local_addr().expect("fixture listener address must resolve");
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
let request = read_request(&mut stream);
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body);
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
return request;
});
return (format!("http://{address}"), handle);
}
fn read_request(stream: &mut std::net::TcpStream) -> std::string::String {
let mut bytes = std::vec::Vec::new();
let mut buffer = [0_u8; 1024];
loop {
let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read");
if count == 0 {
break;
}
bytes.extend_from_slice(&buffer[..count]);
if request_complete(bytes.as_slice()) {
break;
}
}
return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8");
}
fn request_complete(bytes: &[u8]) -> bool {
let text = match std::str::from_utf8(bytes) {
std::result::Result::Ok(text) => text,
std::result::Result::Err(_) => return false,
};
let header_end = match text.find("\r\n\r\n") {
std::option::Option::Some(value) => value,
std::option::Option::None => return false,
};
let mut content_length = 0_usize;
for line in text[..header_end].lines() {
let (name, value) = match line.split_once(':') {
std::option::Option::Some(parts) => parts,
std::option::Option::None => continue,
};
if name.eq_ignore_ascii_case("content-length") {
content_length = value.trim().parse::<usize>().expect("content length must parse");
}
}
return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length);
}
fn request_body(request: &str) -> serde_json::Value {
let body = request.split("\r\n\r\n").nth(1).expect("fixture request body must exist");
return serde_json::from_str(body).expect("fixture request body must be JSON");
}
fn fixture_pubkey(value: &str) -> ksp_core_lib::Pubkey {
return value.parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_account_info_serializes_config_and_preserves_account_wire() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.success.json"));
let pool = pool_for_url(url.as_str());
let account = fixture_pubkey("11111111111111111111111111111111");
let config = crate::SolanaAccountInfoConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
std::option::Option::Some(crate::SolanaDataSliceConfig::new(2, 4)),
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(400_000_000),
);
let response = pool
.get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::Some(&config))
.await
.expect("getAccountInfo fixture must succeed");
assert_eq!(response.context().slot(), 410_000_001);
let returned = response.value().as_ref().expect("fixture account must be present");
assert_eq!(returned.lamports(), 2_039_280);
assert_eq!(returned.owner(), &account);
assert_eq!(returned.space(), std::option::Option::Some(4));
assert!(matches!(returned.data(), crate::SolanaAccountData::Encoded { encoding: crate::SolanaAccountEncoding::Base64, .. }));
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getAccountInfo"));
assert_eq!(body["params"][0], serde_json::json!(account.to_string()));
assert_eq!(body["params"][1]["encoding"], serde_json::json!("base64"));
assert_eq!(body["params"][1]["dataSlice"], serde_json::json!({"offset":2,"length":4}));
assert_eq!(body["params"][1]["commitment"], serde_json::json!("finalized"));
assert_eq!(body["params"][1]["minContextSlot"], serde_json::json!(400_000_000_u64));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_account_info_preserves_missing_account_as_none() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.null.json"));
let pool = pool_for_url(url.as_str());
let account = fixture_pubkey("11111111111111111111111111111111");
let response = pool
.get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::None)
.await
.expect("missing getAccountInfo fixture must succeed");
assert_eq!(response.value(), &std::option::Option::None);
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"].as_array().map(std::vec::Vec::len), std::option::Option::Some(1));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_account_info_rejects_invalid_owner_without_exposing_value() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.invalid_owner.json"));
let pool = pool_for_url(url.as_str());
let account = fixture_pubkey("11111111111111111111111111111111");
let result = pool.get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::None).await;
let error = result.expect_err("invalid account owner must fail typed decoding");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
assert!(!format!("{error:?}").contains("not-a-pubkey"));
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_largest_accounts_serializes_extended_config_and_decodes_order() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_largest_accounts.success.json"));
let pool = pool_for_url(url.as_str());
let config = crate::SolanaLargestAccountsConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(crate::SolanaLargestAccountsFilter::Circulating),
std::option::Option::Some(true),
);
let response = pool
.get_largest_accounts(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config))
.await
.expect("getLargestAccounts fixture must succeed");
assert_eq!(response.value().len(), 2);
assert_eq!(response.value()[0].lamports(), 999_999_999);
assert_eq!(response.value()[1].lamports(), 888_888_888);
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getLargestAccounts"));
assert_eq!(body["params"][0], serde_json::json!({"commitment":"finalized","filter":"circulating","sortResults":true}));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_largest_accounts_rejects_invalid_address() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_largest_accounts.invalid_address.json"));
let pool = pool_for_url(url.as_str());
let result = pool.get_largest_accounts(&crate::HttpRoleName::new("default"), std::option::Option::None).await;
let error = result.expect_err("invalid largest-account address must fail typed decoding");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
assert!(!format!("{error:?}").contains("invalid-address"));
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_minimum_balance_for_rent_exemption_serializes_length_and_commitment() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_minimum_balance_for_rent_exemption.success.json"));
let pool = pool_for_url(url.as_str());
let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Processed));
let result = pool
.get_minimum_balance_for_rent_exemption(&crate::HttpRoleName::new("default"), 50, std::option::Option::Some(&config))
.await
.expect("rent-exemption fixture must succeed");
assert_eq!(result, 890_880);
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getMinimumBalanceForRentExemption"));
assert_eq!(body["params"], serde_json::json!([50,{"commitment":"processed"}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_minimum_balance_for_rent_exemption_preserves_rpc_application_error() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_minimum_balance_for_rent_exemption.error.json"));
let pool = pool_for_url(url.as_str());
let result = pool.get_minimum_balance_for_rent_exemption(&crate::HttpRoleName::new("default"), usize::MAX, std::option::Option::None).await;
let error = result.expect_err("remote invalid parameter must remain an RPC application error");
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_multiple_accounts_preserves_order_nulls_and_config() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_multiple_accounts.success.json"));
let pool = pool_for_url(url.as_str());
let accounts = std::vec![
fixture_pubkey("11111111111111111111111111111111"),
fixture_pubkey("ComputeBudget111111111111111111111111111111"),
fixture_pubkey("Stake11111111111111111111111111111111111111"),
];
let config = crate::SolanaAccountInfoConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
std::option::Option::None,
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
std::option::Option::Some(400_000_001),
);
let response = pool
.get_multiple_accounts(&crate::HttpRoleName::new("default"), accounts.as_slice(), std::option::Option::Some(&config))
.await
.expect("getMultipleAccounts fixture must succeed");
assert_eq!(response.value().len(), 3);
assert_eq!(response.value()[0].as_ref().map(crate::SolanaAccount::lamports), std::option::Option::Some(10));
assert!(response.value()[1].is_none());
assert_eq!(response.value()[2].as_ref().map(crate::SolanaAccount::lamports), std::option::Option::Some(20));
assert!(matches!(response.value()[2].as_ref().map(crate::SolanaAccount::data), std::option::Option::Some(crate::SolanaAccountData::JsonParsed(_))));
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getMultipleAccounts"));
assert_eq!(body["params"][0], serde_json::json!(accounts.iter().map(std::string::ToString::to_string).collect::<std::vec::Vec<_>>()));
assert_eq!(body["params"][1]["encoding"], serde_json::json!("jsonParsed"));
assert_eq!(body["params"][1]["commitment"], serde_json::json!("confirmed"));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_multiple_accounts_rejects_more_than_100_before_io() {
let pool = pool_for_url("http://127.0.0.1:9");
let account = fixture_pubkey("11111111111111111111111111111111");
let accounts = std::vec![account; 101];
let result = pool.get_multiple_accounts(&crate::HttpRoleName::new("default"), accounts.as_slice(), std::option::Option::None).await;
let error = result.expect_err("more than 100 accounts must be rejected locally");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[0].key(), "rpc_method");
assert_eq!(error.context()[1].key(), "account_count");
assert_eq!(error.context()[1].value(), "101");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_program_accounts_serializes_filters_and_decodes_bare_result() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_program_accounts.bare.success.json"));
let pool = pool_for_url(url.as_str());
let program_id = fixture_pubkey("11111111111111111111111111111111");
let filters = std::vec![
crate::SolanaProgramAccountFilter::DataSize(3),
crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Base64("AQID".to_owned()))),
crate::SolanaProgramAccountFilter::TokenAccountState,
];
let account_config = crate::SolanaAccountInfoConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
std::option::Option::Some(crate::SolanaDataSliceConfig::new(0, 3)),
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(400_000_002),
);
let config = crate::SolanaProgramAccountsConfig::new(account_config, filters, std::option::Option::Some(false), std::option::Option::Some(true));
let result = pool
.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&config))
.await
.expect("bare getProgramAccounts fixture must succeed");
assert!(matches!(&result, crate::SolanaProgramAccountsResult::Accounts(_)), "bare fixture must preserve the bare account-list result");
if let crate::SolanaProgramAccountsResult::Accounts(accounts) = result {
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].account().lamports(), 42);
}
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getProgramAccounts"));
assert_eq!(
body["params"][1]["filters"],
serde_json::json!([{"dataSize":3},{"memcmp":{"offset":0,"bytes":"AQID","encoding":"base64"}},"tokenAccountState"]),
);
assert_eq!(body["params"][1]["withContext"], serde_json::json!(false));
assert_eq!(body["params"][1]["sortResults"], serde_json::json!(true));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_program_accounts_preserves_contextual_result() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_program_accounts.context.success.json"));
let pool = pool_for_url(url.as_str());
let program_id = fixture_pubkey("11111111111111111111111111111111");
let config = crate::SolanaProgramAccountsConfig::new(
crate::SolanaAccountInfoConfig::default(),
std::vec::Vec::new(),
std::option::Option::Some(true),
std::option::Option::None,
);
let result = pool
.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&config))
.await
.expect("contextual getProgramAccounts fixture must succeed");
assert!(matches!(&result, crate::SolanaProgramAccountsResult::Context(_)), "contextual fixture must preserve RpcResponse wrapper");
if let crate::SolanaProgramAccountsResult::Context(response) = result {
assert_eq!(response.context().slot(), 410_000_007);
assert_eq!(response.value().len(), 1);
}
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"][1]["withContext"], serde_json::json!(true));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_program_accounts_rejects_filter_cardinality_and_oversized_raw_memcmp_before_io() {
let pool = pool_for_url("http://127.0.0.1:9");
let program_id = fixture_pubkey("11111111111111111111111111111111");
let too_many = crate::SolanaProgramAccountsConfig::new(
crate::SolanaAccountInfoConfig::default(),
std::vec![
crate::SolanaProgramAccountFilter::DataSize(1),
crate::SolanaProgramAccountFilter::DataSize(2),
crate::SolanaProgramAccountFilter::DataSize(3),
crate::SolanaProgramAccountFilter::DataSize(4),
crate::SolanaProgramAccountFilter::DataSize(5),
],
std::option::Option::None,
std::option::Option::None,
);
let result = pool.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&too_many)).await;
let error = result.expect_err("more than four program-account filters must be rejected locally");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].value(), "5");
let oversized = crate::SolanaProgramAccountsConfig::new(
crate::SolanaAccountInfoConfig::default(),
std::vec![crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Bytes(std::vec![0_u8; 129]),))],
std::option::Option::None,
std::option::Option::None,
);
let result = pool.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&oversized)).await;
let error = result.expect_err("raw memcmp data above 128 bytes must be rejected locally");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].value(), "129");
}
#[test]
fn typed_get_multiple_accounts_rejects_response_count_mismatch() {
let value = serde_json::json!({
"context":{"apiVersion":"4.2.1","slot":410000008},
"value":[]
});
let result = super::decode_multiple_accounts_response("getMultipleAccounts", value, 1);
let error = result.expect_err("response count mismatch must fail typed decoding");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
assert_eq!(error.context()[1].key(), "expected_count");
assert_eq!(error.context()[1].value(), "1");
assert_eq!(error.context()[2].key(), "actual_count");
assert_eq!(error.context()[2].value(), "0");
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
// version: 1
// version: 2
fn pool_for_url(url: &str) -> crate::HttpTransportPool {
let role = crate::HttpEndpointRoleSettings::new(

View File

@@ -0,0 +1,487 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs
// version: 4
#[test]
fn cluster_node_fixture_preserves_v4_client_id_and_optional_fields() {
let value: serde_json::Value = serde_json::from_str(include_str!("../fixtures/http/cluster_node.v4_2_1.json")).expect("fixture must decode");
let node = crate::SolanaClusterNode::decode_wire("getClusterNodes", value).expect("cluster node must decode");
assert_eq!(node.client_id(), std::option::Option::Some("Agave"));
assert_eq!(node.rpc(), std::option::Option::Some("127.0.0.1:8899"));
assert_eq!(node.pubsub(), std::option::Option::None);
}
#[test]
fn vote_account_fixture_preserves_optional_basis_point_commission_and_epoch_credit_triples() {
let value: serde_json::Value = serde_json::from_str(include_str!("../fixtures/http/vote_account.v4_2_1.json")).expect("fixture must decode");
let info = crate::SolanaVoteAccountInfo::decode_wire("getVoteAccounts", value).expect("vote account must decode");
assert_eq!(info.commission(), 8);
assert_eq!(info.inflation_rewards_commission_bps(), std::option::Option::Some(750));
assert_eq!(info.epoch_credits().len(), 2);
assert_eq!(info.epoch_credits()[0].epoch(), 700);
assert_eq!(info.epoch_credits()[0].previous_credits(), 90);
}
#[test]
fn vote_account_wire_accepts_nodes_predating_basis_point_commission_field() {
let value = serde_json::json!({
"votePubkey":"11111111111111111111111111111111",
"nodePubkey":"11111111111111111111111111111111",
"activatedStake":1,
"commission":5,
"epochVoteAccount":true,
"epochCredits":[],
"lastVote":2,
"rootSlot":1
});
let info = crate::SolanaVoteAccountInfo::decode_wire("getVoteAccounts", value).expect("legacy node shape must decode");
assert_eq!(info.inflation_rewards_commission_bps(), std::option::Option::None);
}
#[test]
fn leader_schedule_request_encodes_current_epoch_and_slot_overloads_without_ambiguous_arrays() {
let identity = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let config = crate::SolanaLeaderScheduleConfig::new(std::option::Option::Some(identity), std::option::Option::Some(crate::SolanaCommitment::Finalized));
assert_eq!(
crate::SolanaLeaderScheduleRequest::CurrentEpoch(std::option::Option::Some(config.clone())).to_json_params(),
std::vec![serde_json::json!({"identity":"11111111111111111111111111111111","commitment":"finalized"})]
);
assert_eq!(
crate::SolanaLeaderScheduleRequest::Slot { slot: 123, config: std::option::Option::Some(config) }.to_json_params(),
std::vec![serde_json::json!(123), serde_json::json!({"identity":"11111111111111111111111111111111","commitment":"finalized"})]
);
}
#[test]
fn epoch_snapshot_and_leader_helpers_preserve_wire_shapes() {
let epoch = crate::SolanaEpochInfo::decode_wire(
"getEpochInfo",
serde_json::json!({"absoluteSlot":10,"blockHeight":9,"epoch":2,"slotIndex":3,"slotsInEpoch":32,"transactionCount":null}),
)
.expect("epoch info must decode");
assert_eq!(epoch.transaction_count(), std::option::Option::None);
let schedule = crate::SolanaEpochSchedule::decode_wire(
"getEpochSchedule",
serde_json::json!({"firstNormalEpoch":1,"firstNormalSlot":32,"leaderScheduleSlotOffset":32,"slotsPerEpoch":64,"warmup":false}),
)
.expect("epoch schedule must decode");
assert_eq!(schedule.slots_per_epoch(), 64);
let snapshot = crate::SolanaSnapshotSlotInfo::decode_wire("getHighestSnapshotSlot", serde_json::json!({"full":100,"incremental":null}))
.expect("snapshot info must decode");
assert_eq!(snapshot.full(), 100);
assert_eq!(snapshot.incremental(), std::option::Option::None);
let leader = crate::SolanaLeaderSchedule::decode_wire("getLeaderSchedule", serde_json::json!({"11111111111111111111111111111111":[0,2,4]}))
.expect("leader schedule must decode");
assert_eq!(leader.entries().len(), 1);
}
#[test]
fn vote_status_and_config_helpers_preserve_wire_shapes() {
let vote_pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let config = crate::SolanaVoteAccountsConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(vote_pubkey),
std::option::Option::Some(true),
std::option::Option::Some(128),
);
assert_eq!(
config.to_json_value(),
serde_json::json!({"commitment":"finalized","votePubkey":"11111111111111111111111111111111","keepUnstakedDelinquents":true,"delinquentSlotDistance":128})
);
let status = crate::SolanaVoteAccountStatus::decode_wire(
"getVoteAccounts",
serde_json::json!({
"current":[{
"votePubkey":"11111111111111111111111111111111",
"nodePubkey":"11111111111111111111111111111111",
"activatedStake":1,
"commission":5,
"epochVoteAccount":true,
"epochCredits":[],
"lastVote":2,
"rootSlot":1
}],
"delinquent":[]
}),
)
.expect("vote account status must decode");
assert_eq!(status.current().len(), 1);
assert!(status.delinquent().is_empty());
}
fn pool_for_url(url: &str) -> crate::HttpTransportPool {
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![crate::HttpRequestKind::wildcard()],
10,
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
);
let endpoint = crate::HttpEndpointSettings::new(
"fixture",
true,
crate::HttpProviderName::new("fixture"),
crate::HttpClusterName::new("local"),
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(1),
std::option::Option::Some(1),
std::vec![role],
);
let settings = crate::HttpTransportSettings::new(
std::vec![endpoint],
crate::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)),
);
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
}
fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle<std::string::String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
let address = listener.local_addr().expect("fixture listener address must resolve");
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
let request = read_request(&mut stream);
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body);
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
return request;
});
return (format!("http://{address}"), handle);
}
fn read_request(stream: &mut std::net::TcpStream) -> std::string::String {
let mut bytes = std::vec::Vec::new();
let mut buffer = [0_u8; 1024];
loop {
let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read");
if count == 0 {
break;
}
bytes.extend_from_slice(&buffer[..count]);
if request_complete(bytes.as_slice()) {
break;
}
}
return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8");
}
fn request_complete(bytes: &[u8]) -> bool {
let text = match std::str::from_utf8(bytes) {
std::result::Result::Ok(text) => text,
std::result::Result::Err(_) => return false,
};
let header_end = match text.find("\r\n\r\n") {
std::option::Option::Some(value) => value,
std::option::Option::None => return false,
};
let mut content_length = 0_usize;
for line in text[..header_end].lines() {
let (name, value) = match line.split_once(':') {
std::option::Option::Some(parts) => parts,
std::option::Option::None => continue,
};
if name.eq_ignore_ascii_case("content-length") {
content_length = value.trim().parse::<usize>().expect("content length must parse");
}
}
return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length);
}
fn request_body(request: &str) -> serde_json::Value {
let body = request.split("\r\n\r\n").nth(1).expect("fixture request body must exist");
return serde_json::from_str(body).expect("fixture request body must be JSON");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_cluster_nodes_preserves_optional_v4_fields_and_omissions() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_cluster_nodes.success.json"));
let pool = pool_for_url(url.as_str());
let nodes = pool.get_cluster_nodes(&crate::HttpRoleName::new("default")).await.expect("cluster nodes fixture must succeed");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].client_id(), std::option::Option::Some("Agave"));
assert_eq!(nodes[0].serve_repair(), std::option::Option::Some("127.0.0.1:8004"));
assert_eq!(nodes[1].rpc(), std::option::Option::None);
assert_eq!(nodes[1].client_id(), std::option::Option::None);
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getClusterNodes"));
assert_eq!(body["params"], serde_json::json!([]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_cluster_nodes_rejects_invalid_node_pubkey() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_cluster_nodes.invalid_pubkey.json"));
let pool = pool_for_url(url.as_str());
let result = pool.get_cluster_nodes(&crate::HttpRoleName::new("default")).await;
let error = result.expect_err("invalid cluster node pubkey must reject typed response");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_epoch_info_serializes_context_config_and_preserves_nullable_transaction_count() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_epoch_info.success.json"));
let pool = pool_for_url(url.as_str());
let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(429_000_000));
let info = pool
.get_epoch_info(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config))
.await
.expect("epoch info fixture must succeed");
assert_eq!(info.absolute_slot(), 430_000_001);
assert_eq!(info.transaction_count(), std::option::Option::None);
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["params"], serde_json::json!([{"commitment":"finalized","minContextSlot":429000000}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_epoch_info_omits_explicitly_empty_config() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_epoch_info.success.json"));
let pool = pool_for_url(url.as_str());
let config = crate::SolanaContextConfig::default();
let info = pool
.get_epoch_info(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config))
.await
.expect("epoch info fixture must succeed");
assert_eq!(info.epoch(), 995);
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_epoch_schedule_preserves_fixed_wire_shape() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_epoch_schedule.success.json"));
let pool = pool_for_url(url.as_str());
let schedule = pool.get_epoch_schedule(&crate::HttpRoleName::new("default")).await.expect("epoch schedule fixture must succeed");
assert_eq!(schedule.slots_per_epoch(), 432_000);
assert!(!schedule.warmup());
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["method"], serde_json::json!("getEpochSchedule"));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_highest_snapshot_slot_preserves_nullable_incremental_slot() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_highest_snapshot_slot.success.json"));
let pool = pool_for_url(url.as_str());
let snapshot = pool.get_highest_snapshot_slot(&crate::HttpRoleName::new("default")).await.expect("snapshot fixture must succeed");
assert_eq!(snapshot.full(), 429_990_000);
assert_eq!(snapshot.incremental(), std::option::Option::None);
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_highest_snapshot_slot_preserves_no_snapshot_rpc_error() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_highest_snapshot_slot.error.json"));
let pool = pool_for_url(url.as_str());
let result = pool.get_highest_snapshot_slot(&crate::HttpRoleName::new("default")).await;
let error = result.expect_err("no snapshot must remain an RPC application error");
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_identity_decodes_pubkey_object() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_identity.success.json"));
let pool = pool_for_url(url.as_str());
let identity = pool.get_identity(&crate::HttpRoleName::new("default")).await.expect("identity fixture must succeed");
assert_eq!(identity.to_string(), "ComputeBudget111111111111111111111111111111");
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["method"], serde_json::json!("getIdentity"));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_identity_rejects_invalid_wire_pubkey() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_identity.invalid_pubkey.json"));
let pool = pool_for_url(url.as_str());
let result = pool.get_identity(&crate::HttpRoleName::new("default")).await;
let error = result.expect_err("invalid identity pubkey must reject typed response");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_max_cluster_slots_decode_u64_without_params() {
let (retransmit_url, retransmit_handle) = serve_once(include_str!("../fixtures/http/get_max_retransmit_slot.success.json"));
let retransmit_pool = pool_for_url(retransmit_url.as_str());
let retransmit = retransmit_pool.get_max_retransmit_slot(&crate::HttpRoleName::new("default")).await.expect("max retransmit slot fixture must succeed");
assert_eq!(retransmit, 430_000_010);
let retransmit_request = retransmit_handle.join().expect("fixture server must join");
let retransmit_body = request_body(retransmit_request.as_str());
assert_eq!(retransmit_body["method"], serde_json::json!("getMaxRetransmitSlot"));
assert_eq!(retransmit_body["params"], serde_json::json!([]));
let (shred_url, shred_handle) = serve_once(include_str!("../fixtures/http/get_max_shred_insert_slot.success.json"));
let shred_pool = pool_for_url(shred_url.as_str());
let shred = shred_pool.get_max_shred_insert_slot(&crate::HttpRoleName::new("default")).await.expect("max shred insert slot fixture must succeed");
assert_eq!(shred, 430_000_011);
let shred_request = shred_handle.join().expect("fixture server must join");
let shred_body = request_body(shred_request.as_str());
assert_eq!(shred_body["method"], serde_json::json!("getMaxShredInsertSlot"));
assert_eq!(shred_body["params"], serde_json::json!([]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_leader_schedule_serializes_slot_config_and_decodes_map() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_leader_schedule.success.json"));
let pool = pool_for_url(url.as_str());
let identity = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let config = crate::SolanaLeaderScheduleConfig::new(std::option::Option::Some(identity), std::option::Option::Some(crate::SolanaCommitment::Finalized));
let request = crate::SolanaLeaderScheduleRequest::Slot { slot: 430_000_000, config: std::option::Option::Some(config) };
let schedule = pool
.get_leader_schedule(&crate::HttpRoleName::new("default"), &request)
.await
.expect("leader schedule fixture must succeed")
.expect("leader schedule fixture must not be null");
assert_eq!(schedule.entries().get(&identity), std::option::Option::Some(&std::vec![0, 2, 4]));
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getLeaderSchedule"));
assert_eq!(body["params"], serde_json::json!([430000000,{"identity":"11111111111111111111111111111111","commitment":"finalized"}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_leader_schedule_preserves_null_result() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_leader_schedule.null.json"));
let pool = pool_for_url(url.as_str());
let schedule = pool
.get_leader_schedule(&crate::HttpRoleName::new("default"), &crate::SolanaLeaderScheduleRequest::default())
.await
.expect("nullable leader schedule fixture must succeed");
assert_eq!(schedule, std::option::Option::None);
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_slot_serializes_context_config_and_omits_empty_config() {
let (configured_url, configured_handle) = serve_once(include_str!("../fixtures/http/get_slot.success.json"));
let configured_pool = pool_for_url(configured_url.as_str());
let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(429_999_999));
let slot = configured_pool.get_slot(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config)).await.expect("slot fixture must succeed");
assert_eq!(slot, 430_000_020);
let request = configured_handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([{"commitment":"confirmed","minContextSlot":429999999}]));
let (empty_url, empty_handle) = serve_once(include_str!("../fixtures/http/get_slot.success.json"));
let empty_pool = pool_for_url(empty_url.as_str());
let empty = crate::SolanaContextConfig::default();
let slot = empty_pool
.get_slot(&crate::HttpRoleName::new("default"), std::option::Option::Some(&empty))
.await
.expect("slot fixture with empty config must succeed");
assert_eq!(slot, 430_000_020);
let request = empty_handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_slot_leader_serializes_context_config_and_decodes_pubkey() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_slot_leader.success.json"));
let pool = pool_for_url(url.as_str());
let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(430_000_000));
let leader = pool
.get_slot_leader(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config))
.await
.expect("slot leader fixture must succeed");
assert_eq!(leader.to_string(), "ComputeBudget111111111111111111111111111111");
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([{"commitment":"finalized","minContextSlot":430000000}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_slot_leader_rejects_invalid_wire_pubkey() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_slot_leader.invalid_pubkey.json"));
let pool = pool_for_url(url.as_str());
let result = pool.get_slot_leader(&crate::HttpRoleName::new("default"), std::option::Option::None).await;
let error = result.expect_err("invalid slot leader must reject typed response");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_slot_leaders_preserves_order_and_serializes_range() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_slot_leaders.success.json"));
let pool = pool_for_url(url.as_str());
let leaders = pool.get_slot_leaders(&crate::HttpRoleName::new("default"), 430_000_020, 2).await.expect("slot leaders fixture must succeed");
assert_eq!(leaders.len(), 2);
assert_eq!(leaders[0].to_string(), "11111111111111111111111111111111");
assert_eq!(leaders[1].to_string(), "ComputeBudget111111111111111111111111111111");
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getSlotLeaders"));
assert_eq!(body["params"], serde_json::json!([430000020, 2]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_slot_leaders_rejects_out_of_range_limit_before_io() {
let pool = pool_for_url("http://127.0.0.1:9");
let zero = pool.get_slot_leaders(&crate::HttpRoleName::new("default"), 1, 0).await;
let zero_error = zero.expect_err("zero slot-leader limit must reject before I/O");
assert_eq!(zero_error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
let above = pool.get_slot_leaders(&crate::HttpRoleName::new("default"), 1, 5_001).await;
let above_error = above.expect_err("slot-leader limit above 5000 must reject before I/O");
assert_eq!(above_error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_slot_leaders_rejects_invalid_wire_pubkey() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_slot_leaders.invalid_pubkey.json"));
let pool = pool_for_url(url.as_str());
let result = pool.get_slot_leaders(&crate::HttpRoleName::new("default"), 1, 2).await;
let error = result.expect_err("invalid slot leader list item must reject typed response");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_vote_accounts_serializes_full_config_and_preserves_optional_bps() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_vote_accounts.success.json"));
let pool = pool_for_url(url.as_str());
let vote_pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let config = crate::SolanaVoteAccountsConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(vote_pubkey),
std::option::Option::Some(true),
std::option::Option::Some(128),
);
let status = pool
.get_vote_accounts(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config))
.await
.expect("vote accounts fixture must succeed");
assert_eq!(status.current().len(), 1);
assert_eq!(status.delinquent().len(), 1);
assert_eq!(status.current()[0].inflation_rewards_commission_bps(), std::option::Option::Some(750));
assert_eq!(status.current()[0].epoch_credits().len(), 2);
assert_eq!(status.delinquent()[0].inflation_rewards_commission_bps(), std::option::Option::None);
let request = handle.join().expect("fixture server must join");
assert_eq!(
request_body(request.as_str())["params"],
serde_json::json!([{"commitment":"finalized","votePubkey":"11111111111111111111111111111111","keepUnstakedDelinquents":true,"delinquentSlotDistance":128}])
);
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_vote_accounts_omits_explicitly_empty_config() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_vote_accounts.success.json"));
let pool = pool_for_url(url.as_str());
let config = crate::SolanaVoteAccountsConfig::default();
let status = pool
.get_vote_accounts(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config))
.await
.expect("vote accounts fixture with empty config must succeed");
assert_eq!(status.current().len(), 1);
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_vote_accounts_rejects_invalid_wire_pubkey() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_vote_accounts.invalid_pubkey.json"));
let pool = pool_for_url(url.as_str());
let result = pool.get_vote_accounts(&crate::HttpRoleName::new("default"), std::option::Option::None).await;
let error = result.expect_err("invalid vote-account pubkey must reject typed response");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
handle.join().expect("fixture server must join");
}

View File

@@ -0,0 +1,21 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_common.rs
// version: 2
#[test]
fn shared_context_configs_preserve_commitment_and_min_context_slot() {
let commitment = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
assert_eq!(commitment.to_json_value(), serde_json::json!({"commitment":"finalized"}));
let context = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(42));
assert_eq!(context.to_json_value(), serde_json::json!({"commitment":"confirmed","minContextSlot":42}));
}
#[test]
fn rpc_context_preserves_nullable_api_version() {
let with_version = crate::SolanaRpcContext::decode_wire("fixture", serde_json::json!({"slot":10,"apiVersion":"4.2.1"})).expect("context must decode");
assert_eq!(with_version.slot(), 10);
assert_eq!(with_version.api_version(), std::option::Option::Some("4.2.1"));
let response = crate::SolanaRpcResponse::new(with_version, 7_u64);
assert_eq!(response.value(), &7_u64);
let without_version = crate::SolanaRpcContext::decode_wire("fixture", serde_json::json!({"slot":11})).expect("context must decode");
assert_eq!(without_version.api_version(), std::option::Option::None);
}

View File

@@ -0,0 +1,257 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.rs
// version: 3
#[test]
fn token_selector_is_exclusive_by_construction() {
let mint = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
assert_eq!(crate::SolanaTokenAccountSelector::Mint(mint).to_json_value(), serde_json::json!({"mint":"11111111111111111111111111111111"}));
assert_eq!(crate::SolanaTokenAccountSelector::ProgramId(mint).to_json_value(), serde_json::json!({"programId":"11111111111111111111111111111111"}));
}
#[test]
fn token_amount_fixture_preserves_nullable_ui_amount() {
let value: serde_json::Value = serde_json::from_str(include_str!("../fixtures/http/token_amount.null_ui.json")).expect("fixture must decode");
let amount = crate::SolanaTokenAmount::decode_wire("fixture", value).expect("token amount must decode");
assert_eq!(amount.amount(), "18446744073709551615");
assert_eq!(amount.decimals(), 9);
assert_eq!(amount.ui_amount(), std::option::Option::None);
assert_eq!(amount.ui_amount_string(), "18446744073.709551615");
}
#[test]
fn staged_token_account_balance_helper_preserves_address_and_amount() {
let value = serde_json::json!({
"address":"11111111111111111111111111111111",
"amount":"10",
"decimals":2,
"uiAmount":0.1,
"uiAmountString":"0.1"
});
let balance = crate::SolanaTokenAccountBalance::decode_wire("fixture", value).expect("token account balance must decode");
assert_eq!(balance.address().to_string(), "11111111111111111111111111111111");
assert_eq!(balance.amount().amount(), "10");
}
fn pool_for_url(url: &str) -> crate::HttpTransportPool {
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![crate::HttpRequestKind::wildcard()],
10,
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
);
let endpoint = crate::HttpEndpointSettings::new(
"fixture",
true,
crate::HttpProviderName::new("fixture"),
crate::HttpClusterName::new("local"),
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(1),
std::option::Option::Some(1),
std::vec![role],
);
let settings = crate::HttpTransportSettings::new(
std::vec![endpoint],
crate::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)),
);
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
}
fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle<std::string::String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
let address = listener.local_addr().expect("fixture listener address must resolve");
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
let request = read_request(&mut stream);
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body);
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
return request;
});
return (format!("http://{address}"), handle);
}
fn read_request(stream: &mut std::net::TcpStream) -> std::string::String {
let mut bytes = std::vec::Vec::new();
let mut buffer = [0_u8; 1024];
loop {
let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read");
if count == 0 {
break;
}
bytes.extend_from_slice(&buffer[..count]);
if request_complete(bytes.as_slice()) {
break;
}
}
return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8");
}
fn request_complete(bytes: &[u8]) -> bool {
let text = match std::str::from_utf8(bytes) {
std::result::Result::Ok(text) => text,
std::result::Result::Err(_) => return false,
};
let header_end = match text.find("\r\n\r\n") {
std::option::Option::Some(value) => value,
std::option::Option::None => return false,
};
let mut content_length = 0_usize;
for line in text[..header_end].lines() {
let (name, value) = match line.split_once(':') {
std::option::Option::Some(parts) => parts,
std::option::Option::None => continue,
};
if name.eq_ignore_ascii_case("content-length") {
content_length = value.trim().parse::<usize>().expect("content length must parse");
}
}
return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length);
}
fn request_body(request: &str) -> serde_json::Value {
let body = request.split("\r\n\r\n").nth(1).expect("fixture request body must exist");
return serde_json::from_str(body).expect("fixture request body must be JSON");
}
fn fixture_pubkey(value: &str) -> ksp_core_lib::Pubkey {
return value.parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_account_balance_serializes_commitment_and_preserves_nullable_ui_amount() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_account_balance.success.json"));
let pool = pool_for_url(url.as_str());
let account = fixture_pubkey("11111111111111111111111111111111");
let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
let response = pool
.get_token_account_balance(&crate::HttpRoleName::new("default"), &account, std::option::Option::Some(&config))
.await
.expect("token balance fixture must succeed");
assert_eq!(response.context().slot(), 420_000_001);
assert_eq!(response.value().amount(), "18446744073709551615");
assert_eq!(response.value().ui_amount(), std::option::Option::None);
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getTokenAccountBalance"));
assert_eq!(body["params"], serde_json::json!([account.to_string(),{"commitment":"finalized"}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_account_balance_preserves_rpc_application_error() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_account_balance.error.json"));
let pool = pool_for_url(url.as_str());
let account = fixture_pubkey("11111111111111111111111111111111");
let result = pool.get_token_account_balance(&crate::HttpRoleName::new("default"), &account, std::option::Option::None).await;
let error = result.expect_err("remote invalid token account must remain an RPC application error");
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_accounts_by_delegate_serializes_program_selector_and_config() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_accounts_by_delegate.success.json"));
let pool = pool_for_url(url.as_str());
let delegate = fixture_pubkey("11111111111111111111111111111111");
let program_id = fixture_pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
let selector = crate::SolanaTokenAccountSelector::ProgramId(program_id);
let config = crate::SolanaAccountInfoConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
std::option::Option::None,
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(420_000_000),
);
let response = pool
.get_token_accounts_by_delegate(&crate::HttpRoleName::new("default"), &delegate, &selector, std::option::Option::Some(&config))
.await
.expect("delegate token accounts fixture must succeed");
assert_eq!(response.context().slot(), 420_000_002);
assert_eq!(response.value().len(), 1);
assert!(matches!(response.value()[0].account().data(), crate::SolanaAccountData::JsonParsed(_)));
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getTokenAccountsByDelegate"));
assert_eq!(body["params"][0], serde_json::json!(delegate.to_string()));
assert_eq!(body["params"][1], serde_json::json!({"programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"}));
assert_eq!(body["params"][2], serde_json::json!({"encoding":"jsonParsed","commitment":"finalized","minContextSlot":420000000}));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_accounts_by_owner_serializes_mint_selector_and_omits_empty_config() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_accounts_by_owner.success.json"));
let pool = pool_for_url(url.as_str());
let owner = fixture_pubkey("11111111111111111111111111111111");
let mint = fixture_pubkey("ComputeBudget111111111111111111111111111111");
let selector = crate::SolanaTokenAccountSelector::Mint(mint);
let config = crate::SolanaAccountInfoConfig::default();
let response = pool
.get_token_accounts_by_owner(&crate::HttpRoleName::new("default"), &owner, &selector, std::option::Option::Some(&config))
.await
.expect("owner token accounts fixture must succeed");
assert_eq!(response.context().api_version(), std::option::Option::None);
assert_eq!(response.value().len(), 1);
assert_eq!(response.value()[0].pubkey().to_string(), "Stake11111111111111111111111111111111111111");
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getTokenAccountsByOwner"));
assert_eq!(body["params"], serde_json::json!([owner.to_string(),{"mint":"ComputeBudget111111111111111111111111111111"}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_accounts_by_owner_rejects_invalid_account_pubkey_response() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_accounts_by_owner.invalid_pubkey.json"));
let pool = pool_for_url(url.as_str());
let owner = fixture_pubkey("11111111111111111111111111111111");
let mint = fixture_pubkey("ComputeBudget111111111111111111111111111111");
let selector = crate::SolanaTokenAccountSelector::Mint(mint);
let result = pool.get_token_accounts_by_owner(&crate::HttpRoleName::new("default"), &owner, &selector, std::option::Option::None).await;
let error = result.expect_err("invalid account pubkey must reject the typed response");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_largest_accounts_decodes_order_and_commitment() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_largest_accounts.success.json"));
let pool = pool_for_url(url.as_str());
let mint = fixture_pubkey("11111111111111111111111111111111");
let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed));
let response = pool
.get_token_largest_accounts(&crate::HttpRoleName::new("default"), &mint, std::option::Option::Some(&config))
.await
.expect("largest token accounts fixture must succeed");
assert_eq!(response.value().len(), 2);
assert_eq!(response.value()[0].amount().amount(), "9000");
assert_eq!(response.value()[1].amount().amount(), "8000");
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([mint.to_string(),{"commitment":"confirmed"}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_largest_accounts_rejects_invalid_address() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_largest_accounts.invalid_address.json"));
let pool = pool_for_url(url.as_str());
let mint = fixture_pubkey("11111111111111111111111111111111");
let result = pool.get_token_largest_accounts(&crate::HttpRoleName::new("default"), &mint, std::option::Option::None).await;
let error = result.expect_err("invalid token-account address must reject the typed response");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_supply_preserves_exact_amount_and_omits_absent_config() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_supply.success.json"));
let pool = pool_for_url(url.as_str());
let mint = fixture_pubkey("11111111111111111111111111111111");
let response = pool
.get_token_supply(&crate::HttpRoleName::new("default"), &mint, std::option::Option::None)
.await
.expect("token supply fixture must succeed");
assert_eq!(response.context().slot(), 420_000_007);
assert_eq!(response.value().amount(), "1000000000000000000000000");
assert_eq!(response.value().ui_amount_string(), "1000000000000000000");
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getTokenSupply"));
assert_eq!(body["params"], serde_json::json!([mint.to_string()]));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
<!-- file: deltas/0.2.2/pre.001-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.001-fix.001` — réaudit Agave `v4.2.1` et complétude des DTOs Cluster
## Base requise
Livraison précédente :
```text
0.2.2-pre.001
workspace.package.version = "0.2.2-pre.1"
```
Le fichier `docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md` fourni pour correction porte déjà `version: 2`; le présent correctif
l'incrémente à `version: 3` conformément à `GEN-FILE-004`.
Le delta historique :
```text
deltas/0.2.2/pre.001.md
```
reste inchangé. Conformément à `KSP-REL-003` et `VER-DELTA-007`, l'audit incomplet de `pre.001` n'est pas réécrit silencieusement : ce fix
trace explicitement sa correction avant `pre.002`.
## Objectif
Corriger la source Agave complémentaire utilisée par le plan `0.2.2` et réauditer les contrats wire concernés avant l'implémentation des DTOs.
`pre.001` avait pris Agave `v3.1.8` comme source complémentaire parce que les liens `Source` actuellement exposés par les pages RPC de
`solana.com` pointent encore vers ce snapshot. Ce constat reste utile pour expliquer la documentation publiée, mais il ne suffit pas à qualifier
`v3.1.8` de source Agave actuelle.
Le présent fix recoupe donc les pages Solana avec le tag Agave plus récent :
```text
v4.2.1
```
et utilise directement ses sources primaires pour les structures/configurations RPC qui intéressent `0.2.2`.
## Sources réauditées
Documentation RPC :
```text
https://solana.com/docs/rpc/http
https://solana.com/docs/rpc/json-structures
```
Source primaire Agave `v4.2.1` :
```text
https://github.com/anza-xyz/agave/blob/v4.2.1/rpc/src/rpc.rs
https://github.com/anza-xyz/agave/blob/v4.2.1/rpc-client-types/src/config.rs
https://github.com/anza-xyz/agave/blob/v4.2.1/rpc-client-types/src/filter.rs
https://github.com/anza-xyz/agave/blob/v4.2.1/rpc-client-types/src/request.rs
https://github.com/anza-xyz/agave/blob/v4.2.1/rpc-client-types/src/response.rs
```
Le `Cargo.toml` racine du tag `v4.2.1` déclare lui-même `workspace.package.version = "4.2.1"`.
## Résultat du réaudit
### Invariants confirmés
Le passage de la référence complémentaire `v3.1.8` à `v4.2.1` ne remet pas en cause le périmètre `0.2.2` ni les principales limites déjà
retenues :
```text
MAX_MULTIPLE_ACCOUNTS = 100
MAX_GET_PROGRAM_ACCOUNT_FILTERS = 4
MAX_GET_SLOT_LEADERS = 5000
MAX_RPC_VOTE_ACCOUNT_INFO_EPOCH_CREDITS_HISTORY = 5
```
Les filtres Program Accounts restent :
```text
DataSize
Memcmp
TokenAccountState
```
et `Memcmp` conserve les formes `base58`, `base64` et octets bruts, avec 128 octets décodés maximum et rejet de l'ancien libellé `binary`.
Les configurations Accounts, Token selector, Leader Schedule et Vote Accounts retenues dans le plan restent également compatibles avec
`v4.2.1`.
### Évolution 1 — `getClusterNodes`
Agave `v4.2.1` expose dans `RpcContactInfo` le champ supplémentaire :
```text
clientId Option<String>
```
La page RPC Solana courante ne le liste pas encore. KSP doit néanmoins pouvoir accepter et préserver ce champ lorsqu'un noeud/provider le
retourne, sans le rendre obligatoire pour les réponses qui ne le contiennent pas.
Le DTO Cluster prévu par le plan est donc complété avec `clientId: Option<String>`.
### Évolution 2 — `getVoteAccounts`
Agave `v4.2.1` expose dans `RpcVoteAccountInfo` :
```text
inflationRewardsCommissionBps Option<u16>
```
La source précise que ce champ est absent/`None` pour les noeuds antérieurs à son introduction. KSP doit le préserver comme optionnel et ne doit
pas le déduire artificiellement du champ historique `commission: u8`.
Le DTO Vote Account prévu par le plan est donc complété avec `inflationRewardsCommissionBps: Option<u16>`.
### Tests à préserver dans la suite
La stratégie de tests du plan exige désormais explicitement :
- `getClusterNodes` avec présence et absence de `clientId` ;
- `getVoteAccounts` avec présence et absence de `inflationRewardsCommissionBps` ;
- conservation des tests déjà prévus sur les autres champs optionnels/nullables et sur les limites `100 / 4 / 5000 / 5`.
## Périmètre inchangé
Le correctif ne modifie pas :
- les 22 méthodes affectées à `0.2.2` ;
- la partition globale `4 / 22 / 11 / 15` ;
- le gate de sizing positif de `0.2.2` ;
- l'architecture `wrapper -> descriptor -> execute_standard_rpc -> pool -> reqwest -> JSON-RPC -> decode` ;
- Config ;
- les dépendances Cargo ;
- les sources Rust ;
- les canaris `0.2.1` ;
- le planning `pre.002` à `pre.007`.
Aucun nouveau besoin `base64`, `bs58`, SPL, `solana-client` ou SDK RPC haut niveau n'est introduit par ce réaudit.
## Fichier modifié
```text
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
```
## Fichier ajouté
```text
deltas/0.2.2/pre.001-fix.001.md
```
## Fichiers supprimés
Aucun.
## Fichiers volontairement inchangés
```text
Cargo.toml
ROADMAP.md
CHANGELOG.md
deltas/0.2.2/pre.001.md
docs/000-README.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
crates/ksp-onchain-transport-lib/**
crates/ksp-config-lib/**
config/**
```
Le plan `009` reste le fichier canonique ; aucun fichier parallèle `*.corrected.md` n'est ajouté au dépôt.
## Version Cargo
Ce correctif est exclusivement documentaire et ne touche aucun fichier consommé par le code, le build, le runtime, la configuration exécutable
ou une migration. Conformément à `VER-ID-008`, `workspace.package.version` reste :
```text
0.2.2-pre.1
```
L'identifiant de livraison est :
```text
0.2.2-pre.001-fix.001
```
## Validations exécutées
- relecture de `RULES.md`, `docs/rules/VERSION_WORKFLOW.md`, `docs/rules/FILE_CONTRACTS.md`, `docs/rules/RULES_GENERAL.md`,
`docs/rules/RULES_KSP.md` et `docs/rules/PROMPT_STRUCTURE.md` ;
- contrôle des règles `GEN-FILE-004`, `VER-ID-003`, `VER-ID-008`, `VER-DELTA-003`, `VER-DELTA-007`, `VER-ARCHIVE-002`,
`VER-ARCHIVE-004` et `KSP-REL-003` applicables à ce correctif ;
- comparaison de la source Agave `v3.1.8` utilisée par `pre.001` avec Agave `v4.2.1` sur les structures/configs RPC nécessaires à
Accounts/Tokens/Cluster ;
- confirmation dans Agave `v4.2.1` des limites `100 / 4 / 5000 / 5` et des variantes Program Account filters ;
- confirmation dans Agave `v4.2.1` de `RpcContactInfo.client_id: Option<String>` ;
- confirmation dans Agave `v4.2.1` de `RpcVoteAccountInfo.inflation_rewards_commission_bps: Option<u16>` ;
- contrôle que le plan canonique référence désormais `v4.2.1` pour les cinq sources Agave complémentaires ;
- contrôle que les seules mentions restantes de `v3.1.8` expliquent le snapshot encore lié par le site Solana ;
- contrôle que `clientId` et `inflationRewardsCommissionBps` sont présents dans les DTOs prévus et dans la stratégie de tests ;
- contrôle qu'aucune autre famille de fichier n'est modifiée par ce fix.
## Validations non exécutées
Aucune validation Cargo n'est requise spécifiquement pour ce fix documentaire : aucune source Rust, dépendance, feature, configuration runtime ou
manifest Cargo n'est modifié.
Les validations Rust prévues pour `0.2.2-pre.002` restent inchangées et devront être réellement exécutées après ses modifications de code.
## Questions ouvertes
Aucune question bloquante nouvelle.
Le plan garde comme règle de mise en oeuvre que les champs optionnels/versionnés observés dans Agave doivent être représentés sans rendre leur
présence obligatoire chez tous les providers.
## Suite
Après application/commit de `0.2.2-pre.001-fix.001`, reprendre `0.2.2-pre.002` sur le plan `009` corrigé, en incluant dès la conception des DTOs
Cluster/Vote les deux champs optionnels confirmés par Agave `v4.2.1`.

180
deltas/0.2.2/pre.001.md Normal file
View File

@@ -0,0 +1,180 @@
<!-- file: deltas/0.2.2/pre.001.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.001` — réaudit HTTP Accounts/Tokens/Cluster, DTOs et sizing
## Base requise
Release stable attendue :
```text
v0.2.1
```
L'archive KSP fournie porte `workspace.package.version = "0.2.1"`. Elle ne contient pas `.git`; le tag `v0.2.1` n'est donc pas revérifiable
localement depuis le zip.
## Objectif
Exécuter la première tranche obligatoire de `0.2.2` sans implémentation fonctionnelle lourde : relire les contrats KSP, réauditer la surface
HTTP Solana officielle actuelle, confirmer la matrice des 22 méthodes Accounts/Tokens/Cluster, cadrer les DTOs wire, évaluer les dépendances et
trancher le gate de sizing.
## Version Cargo
Conformément à `VER-ID-009`, la prerelease non-fix synchronise le signal technique :
```text
0.2.1 -> 0.2.2-pre.1
```
Aucune source Rust, dépendance Cargo ou configuration runtime n'est modifiée dans cette tranche.
## Résultats principaux
- index HTTP Solana courant réaudité : **52 méthodes** ;
- navigation Deprecated officielle : **14 méthodes historiques**, inchangées ;
- familles de la release : Accounts `6 = 1 acquis + 5 ciblés`, Tokens `5 = 5 ciblés`, Cluster `15 = 3 acquis + 12 ciblés` ;
- aucune méthode ajoutée/supprimée/déplacée détectée depuis l'audit `0.2.1` du 2026-08-17 ;
- les 22 méthodes ciblées restent des méthodes courantes non marquées Deprecated/Unstable et les descriptors KSP restent `Read + RetrySafe` ;
- `getMultipleAccounts` conserve la limite documentée de 100 adresses ;
- `getSlotLeaders` conserve la limite documentée `1..=5000` ;
- `getLargestAccounts` et `getProgramAccounts` documentent toujours `sortResults` ;
- `getProgramAccounts` conserve sa réponse bare par défaut et contextualisée avec `withContext=true` ;
- la source primaire Agave confirme trois filtres Program Accounts (`dataSize`, `memcmp`, `tokenAccountState`), 4 filtres max et 128 octets décodés max pour `memcmp` ;
- le wire Account requiert une union pour `data`, y compris le fallback `jsonParsed -> [base64]`, et `space` reste nullable ;
- `TokenAmount.uiAmount` reste nullable ;
- `getHighestSnapshotSlot` conserve `incremental: null` et la source officielle Agave confirme une erreur `NoSnapshot` en absence de snapshot ;
- `getLeaderSchedule` conserve son overload slot/config particulier ;
- `getVoteAccounts` conserve `current/delinquent` et les epoch credits triples; la source Agave courante borne cet historique RPC à 5 entrées par validator.
## Gate de sizing
Question obligatoire :
```text
Les 22 wrappers typés + DTOs partagés + tests + documentation peuvent-ils être clôturés proprement dans cette session ?
```
Réponse :
```text
OUI.
```
Aucun split de release n'est nécessaire. Le plan conserve `0.2.2` à 22 méthodes et répartit le travail sur DTOs communs, Accounts, Tokens,
Cluster simple, Cluster schedule/slot/vote puis clôture. Si une tranche réelle dépasse le budget KSP, elle sera scindée par une prerelease
supplémentaire sans déplacer de méthode hors `0.2.2`.
## Décisions prises
- conserver exactement la partition `4 / 22 / 11 / 15` de `0.2.1``0.2.4` ;
- ne pas reconstruire un transport par famille ; tous les wrappers passent par descriptor + `execute_standard_rpc` ;
- mutualiser `SolanaCommitment` et `SolanaRpcContext` sans casser les réexports `0.2.1` ;
- représenter Account data comme une forme wire discriminée, sans décodage Program/SPL ;
- conserver `jsonParsed.parsed` comme `serde_json::Value` dans Transport ;
- utiliser `ksp_core_lib::Pubkey` dans les DTOs publics, avec wire structs privés puis conversion explicite ;
- représenter le selector Token comme une union exclusive `mint` / `programId` ;
- représenter le résultat `getProgramAccounts` comme bare ou contextualisé selon `withContext` ;
- conserver les endpoints de `getClusterNodes` comme chaînes wire optionnelles au contrat public ;
- conserver les `null` documentés (`Account`, `space`, `uiAmount`, `transactionCount`, snapshot incremental, leader schedule) ;
- ne pas modifier Config : le contrat `std.transport` existant suffit ;
- ne pas ajouter `base64`, `bs58`, SPL, `solana-client` ou SDK RPC haut niveau à `pre.001`; les formes encodées `memcmp` peuvent rester des chaînes wire tant quaucun décodage local nest requis.
## Prévision de travail
```text
pre.001 audit officiel + matrice + DTOs + dépendances + sizing
pre.002 primitives/configs/results partagés + fixtures de base
pre.003 5 Accounts + tests
pre.004 5 Tokens + tests
pre.005 Cluster simple (7 méthodes) + tests
pre.006 Cluster schedule/slot/vote (5 méthodes) + tests
pre.007 canaries + smoke opt-in si utile + docs + prompt 0.2.3 + préparation stable
```
Une prerelease supplémentaire reste autorisée si le budget réel d'une tranche l'exige.
## Fichiers ajoutés
```text
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
deltas/0.2.2/pre.001.md
```
## Fichiers modifiés
```text
Cargo.toml
ROADMAP.md
docs/000-README.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
```
## Fichiers supprimés
Aucun.
## Fichiers volontairement inchangés
```text
CHANGELOG.md
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
crates/ksp-onchain-transport-lib/src/**
crates/ksp-onchain-transport-lib/unit_tests/**
crates/ksp-onchain-transport-lib/tests/**
crates/ksp-config-lib/src/transport.rs
config/std.transport.json
config/schemas/std.transport.schema.json
```
Les deltas `0.2.1` restent historiques et ne sont pas réécrits.
## Validations exécutées
- relecture des sources KSP obligatoires du prompt `0.2.2` ;
- inspection de l'archive stable KSP fournie et de la foundation Transport `0.2.1` ;
- confirmation locale que le registry assigne déjà exactement les 22 méthodes ciblées à `HttpRpcCoverageRelease::V0_2_2` ;
- confirmation locale que ces 22 descriptors sont `Stable / Supported / Stable`, `Read`, `RetrySafe` ;
- consultation de l'index HTTP Solana officiel courant et des 22 pages de méthodes ciblées ;
- consultation de la navigation Deprecated officielle courante ;
- consultation de `Solana RPC JSON Structures` pour Account data et TokenAmount ;
- consultation des sources Agave `v3.1.8` liées par les pages officielles pour les ambiguïtés Account, filters/memcmp, cardinalités RPC, largest accounts, snapshot, leader schedule,
cluster node et vote accounts ;
- contrôle documentaire de la matrice `52 current / 14 historical / 4+22+11+15` ;
- contrôle de l'absence de nouvelle dépendance ou de besoin Config dans le design de `pre.001`.
## Validations non exécutées
Le sandbox courant ne fournit pas le binaire `cargo` (`command -v cargo` ne retourne aucun chemin). Les validations Cargo suivantes n'ont donc pas
été exécutées ici et doivent être rejouées sur le checkout de développement avant commit :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Aucun `cargo tree` n'est nécessaire dans cette tranche car aucune dépendance ni feature n'a changé. Les quatre vues `cargo tree` du prompt restent
obligatoires dès qu'une prerelease change une dépendance/feature et à la clôture.
L'archive KSP fournie ne contient pas `.git`; le commit attendu après application et validations suit `VER-GIT-001` :
```text
v0.2.2-pre.001
```
## Questions ouvertes
Aucune question bloquante pour `pre.002`.
Les détails exacts de nommage Rust des DTOs restent ajustables pendant leur implémentation, mais les invariants wire et frontières fixés par le
plan `009` ne doivent pas être relâchés pour simplifier artificiellement les wrappers.
## Suite
`0.2.2-pre.002` : introduire les primitives/configs/results partagés, mutualiser Context/Commitment, fixer les conversions wire et installer les
fixtures déterministes communes avant les wrappers Accounts.

Some files were not shown because too many files have changed in this diff Show More