15 Commits

Author SHA1 Message Date
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
85 changed files with 8442 additions and 121 deletions

View File

@@ -1,10 +1,14 @@
<!-- file: CHANGELOG.md --> <!-- file: CHANGELOG.md -->
<!-- version: 5 --> <!-- version: 6 -->
# Changelog KSP # 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/`. 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.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 — 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`. `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 # file: Cargo.toml
# version: 109 # version: 119
[workspace] [workspace]
resolver = "3" 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"] 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] [workspace.package]
version = "0.2.1" version = "0.2.2"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md --> <!-- file: ROADMAP.md -->
<!-- version: 34 --> <!-- version: 37 -->
# Roadmap KSP # Roadmap KSP
@@ -46,7 +46,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
### Releases fonctionnelles décidées/pressenties ### 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. - [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. - [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.
- [ ] `0.2.3` — Compléter les 11 méthodes HTTP Transactions, y compris write/submission technique avec politique no-resend ambigu. - [ ] `0.2.3` — Compléter les 11 méthodes HTTP Transactions, y compris write/submission technique avec politique no-resend ambigu.
- [ ] `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.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.5` — Introduire `ksp-wallet-lib`, le format `.kspwallet`, la gestion sûre des secrets et une architecture d'import/export extensible ; exclure `WalletPolicy`.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/README.md --> <!-- file: crates/ksp-onchain-transport-lib/README.md -->
<!-- version: 1 --> <!-- version: 5 -->
# `ksp-onchain-transport-lib` # `ksp-onchain-transport-lib`
@@ -69,16 +69,16 @@ Le registre porte notamment :
- remplacement historique éventuel ; - remplacement historique éventuel ;
- release de couverture typée KSP. - release de couverture typée KSP.
Les quatre wrappers typés de la foundation sont : La surface typée stable après `0.2.2` contient **26 méthodes** :
```text ```text
getBalance 0.2.1 foundation : 4
getGenesisHash 0.2.2 Accounts : 5
getHealth 0.2.2 Tokens : 5
getVersion 0.2.2 Cluster : 12
``` ```
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 affectés à `HttpRpcCoverageRelease::V0_2_2`. Les 26 méthodes Transactions/Blocks/Economics encore affectées à `0.2.3``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**.
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables. Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
@@ -118,18 +118,24 @@ 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. 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 ```text
Config -> std.transport/devnet_public -> HttpTransportPool Transport pur : settings programmatiques -> HttpTransportPool
-> getHealth/getGenesisHash/getVersion/getBalance -> getAccountInfo/getTokenAccountsByOwner/getEpochInfo/getVoteAccounts
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 ## Documentation
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw et inspection runtime ; - [`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) — plan et matrice HTTP ; - [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — foundation HTTP stable ;
- [`../../docs/validation/003-V0_2_1_ONCHAIN_HTTP.md`](../../docs/validation/003-V0_2_1_ONCHAIN_HTTP.md) — matrice de clôture ; - [`../../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` ;
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP. - [`../../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 --> <!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
<!-- version: 1 --> <!-- version: 5 -->
# Utilisation de `ksp-onchain-transport-lib` # Utilisation de `ksp-onchain-transport-lib`
@@ -86,17 +86,17 @@ let balance = pool
.await; .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 ```rust
SolanaNodeHealth let account = pool
SolanaGenesisHash .get_account_info(&role, &ksp_core_lib::PRGIDPK_SOLANA_SYSTEM, None)
SolanaNodeVersion .await;
GetBalanceResult let epoch = pool.get_epoch_info(&role, None).await;
SolanaRpcContext 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 surface stable `0.2.2` contient 26 wrappers typés au total : 4 foundation + 5 Accounts + 5 Tokens + 12 Cluster. Les DTOs Transport conservent les `null`, options et formes wire : données Account encodées/`jsonParsed`, `TokenAmount.uiAmount`, contexte RPC, nodes, epoch, leader schedule et vote accounts. Aucun décodage Program/SPL métier n'est effectué ici.
## 4. Exécution JSON-RPC standard générique ## 4. Exécution JSON-RPC standard générique
@@ -153,12 +153,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. 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` :
```bash
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
```
Il appelle `getAccountInfo`, `getTokenAccountsByOwner`, `getEpochInfo` et `getVoteAccounts`. 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 : le smoke valide ainsi la route Token sans dépendre de la persistance d'un mint ou d'un token account Devnet particulier.
Le smoke historique de **composition Config -> Transport** reste également disponible :
```bash ```bash
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture 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 @@
{"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":-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":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":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":{"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 @@
{"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 // file: crates/ksp-onchain-transport-lib/src/error.rs
// version: 1 // version: 2
/// Error code used when HTTP transport runtime settings are invalid. /// 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"); 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"); 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. /// 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"); 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 // file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 6 // version: 11
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
@@ -9,8 +9,8 @@
//! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains //! 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 -> //! 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 //! 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 //! 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
//! JSON-RPC requests while the remaining audited methods stay staged by subsequent `0.2.x` releases. //! requests through the shared transport path while the `0.2.3` and `0.2.4` audited families remain staged.
mod client; mod client;
mod constants; mod constants;
@@ -19,8 +19,12 @@ mod executor;
mod json_rpc; mod json_rpc;
mod pool; mod pool;
mod resilience; mod resilience;
mod rpc_accounts;
mod rpc_canary; mod rpc_canary;
mod rpc_cluster;
mod rpc_common;
mod rpc_method; mod rpc_method;
mod rpc_tokens;
mod settings; mod settings;
pub(crate) use self::constants::TRACING_TARGET; pub(crate) use self::constants::TRACING_TARGET;
@@ -41,6 +45,8 @@ pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED;
pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED; pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED;
/// Error code used when a decoded response cannot satisfy the expected KSP transport contract. /// Error code used when a decoded response cannot satisfy the expected KSP transport contract.
pub use self::error::ERROR_CODE_INVALID_RESPONSE; 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. /// Error code used when HTTP transport runtime settings are invalid.
pub use self::error::ERROR_CODE_INVALID_SETTINGS; pub use self::error::ERROR_CODE_INVALID_SETTINGS;
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON. /// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
@@ -87,20 +93,82 @@ pub use self::resilience::HttpRetryCause;
pub use self::resilience::HttpRetryDecision; pub use self::resilience::HttpRetryDecision;
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method. /// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
pub use self::resilience::evaluate_transport_retry; 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. /// Optional typed configuration for the `getBalance` canary.
pub use self::rpc_canary::GetBalanceConfig; pub use self::rpc_canary::GetBalanceConfig;
/// Typed lamport balance returned by the `getBalance` canary. /// Typed lamport balance returned by the `getBalance` canary.
pub use self::rpc_canary::GetBalanceResult; 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. /// Typed genesis hash returned by the `getGenesisHash` canary.
pub use self::rpc_canary::SolanaGenesisHash; pub use self::rpc_canary::SolanaGenesisHash;
/// Typed healthy result returned by the `getHealth` canary. /// Typed healthy result returned by the `getHealth` canary.
pub use self::rpc_canary::SolanaNodeHealth; pub use self::rpc_canary::SolanaNodeHealth;
/// Typed software-version response returned by the `getVersion` canary. /// Typed software-version response returned by the `getVersion` canary.
pub use self::rpc_canary::SolanaNodeVersion; pub use self::rpc_canary::SolanaNodeVersion;
/// Typed Solana RPC context used by the initial account canary. /// Contact information returned for one cluster node.
pub use self::rpc_canary::SolanaRpcContext; 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. /// Functional category used by the audited Solana HTTP JSON-RPC registry.
pub use self::rpc_method::HttpRpcCategory; pub use self::rpc_method::HttpRpcCategory;
/// Release that owns typed KSP coverage for one audited HTTP RPC method. /// Release that owns typed KSP coverage for one audited HTTP RPC method.
@@ -123,6 +191,12 @@ pub use self::rpc_method::current_http_rpc_methods;
pub use self::rpc_method::find_http_rpc_method; pub use self::rpc_method::find_http_rpc_method;
/// Returns historically documented deprecated HTTP RPC descriptors retained for compliance history. /// Returns historically documented deprecated HTTP RPC descriptors retained for compliance history.
pub use self::rpc_method::historical_http_rpc_methods; 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;
/// Open cluster or network descriptor used by HTTP endpoint settings. /// Open cluster or network descriptor used by HTTP endpoint settings.
pub use self::settings::HttpClusterName; pub use self::settings::HttpClusterName;
/// Runtime settings for one role declared by an HTTP endpoint. /// 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 // 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. /// Optional typed configuration for `getBalance` retained for the `0.2.1` public canary contract.
#[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`.
#[derive(Clone, Debug, Default, Eq, PartialEq)] #[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GetBalanceConfig { pub struct GetBalanceConfig {
commitment: std::option::Option<crate::SolanaCommitment>, commitment: std::option::Option<crate::SolanaCommitment>,
@@ -55,14 +32,7 @@ impl GetBalanceConfig {
} }
fn to_json_value(&self) -> serde_json::Value { fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new(); return crate::SolanaContextConfig::new(self.commitment, self.min_context_slot).to_json_value();
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);
} }
} }
@@ -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. /// Typed lamport balance returned by the `getBalance` canary.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct GetBalanceResult { pub struct GetBalanceResult {
@@ -245,10 +191,12 @@ impl crate::HttpTransportPool {
std::result::Result::Ok(decoded) => decoded, std::result::Result::Ok(decoded) => decoded,
std::result::Result::Err(error) => return invalid_canary_decode("getBalance", error), std::result::Result::Err(error) => return invalid_canary_decode("getBalance", error),
}; };
return std::result::Result::Ok(crate::GetBalanceResult { let context = crate::SolanaRpcContext::decode_wire("getBalance", decoded.context);
context: crate::SolanaRpcContext { slot: decoded.context.slot, api_version: decoded.context.api_version }, let context = match context {
value: decoded.value, 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>, 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)] #[derive(serde::Deserialize)]
struct WireBalanceResult { struct WireBalanceResult {
context: WireRpcContext, context: serde_json::Value,
value: u64, 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;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs // file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 5 // version: 9
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -171,3 +171,75 @@ fn public_typed_canary_contracts_are_available_from_crate_root() {
assert_eq!(config.min_context_slot(), std::option::Option::Some(42)); 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); 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)));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs // file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 1 // version: 6
//! Release-level completeness canaries for the `0.2.1` HTTP foundation contract. //! Release-level completeness canaries for the `0.2.1` HTTP foundation contract.
@@ -46,3 +46,151 @@ fn release_foundation_canaries_and_historical_statuses_are_exact() {
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NotApplicable); 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);
}

View File

@@ -0,0 +1,97 @@
// file: crates/ksp-onchain-transport-lib/tests/transport_devnet_smoke.rs
// version: 3
//! Opt-in live Devnet smoke for the pure Transport Accounts/Tokens/Cluster path.
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_and_cluster_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());
}

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 // file: crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
// version: 1 // version: 2
fn pool_for_url(url: &str) -> crate::HttpTransportPool { fn pool_for_url(url: &str) -> crate::HttpTransportPool {
let role = crate::HttpEndpointRoleSettings::new( 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()]));
}

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.

View File

@@ -0,0 +1,213 @@
<!-- file: deltas/0.2.2/pre.002-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.002-fix.001` — nettoyage des helpers RPC préparatoires et Clippy
## Base requise
```text
livraison : 0.2.2-pre.002
workspace.package.version = "0.2.2-pre.2"
```
Le correctif est ouvert après la validation locale communiquée le 2026-08-18. Les tests de `pre.002` passent, mais `cargo check` et Clippy
révèlent que plusieurs helpers privés de sérialisation/décodage ont été compilés trop tôt alors qu'aucun wrapper `0.2.2` ne les consomme encore.
## Objectif
Corriger exclusivement les warnings de qualité de `pre.002`, sans commencer les wrappers Accounts de `pre.003`, sans modifier les DTOs publics
et sans masquer les lints avec `#[allow(dead_code)]`.
Les catégories observées sont :
```text
dead_code helpers/wire structs préparatoires Accounts/Tokens/Cluster/Common
clippy::assertions_on_constants assert!(false, ...) dans un test Account
clippy::wrong_self_convention 2 serializers de configs Copy prenant &self
```
## Version Cargo
Le correctif modifie du Rust de production/test. Conformément à `VER-ID-007` et `VER-ID-010` :
```text
0.2.2-pre.2 -> 0.2.2-pre.2.fix.1
```
Aucune dépendance ni feature Cargo n'est ajoutée ou retirée.
## Correction du staging privé
`pre.002` doit établir les DTOs/configs publics et les fixtures communes, mais les wrappers qui consommeront réellement les helpers privés sont
répartis sur `pre.003` à `pre.006`.
Les serializers/décodeurs privés et leurs structs wire encore utilisés uniquement par les tests sont donc compilés sous :
```rust
#[cfg(test)]
```
jusqu'à la prerelease qui les active réellement dans un wrapper de production.
Cette correction :
- conserve tous les types, constructeurs, getters, enums et réexports publics introduits par `pre.002` ;
- conserve les fixtures Account/Token/Cluster/Vote ;
- ne rend aucun helper privé artificiellement public ;
- n'ajoute aucun `#[allow(dead_code)]` ;
- évite de porter du code privé mort dans la bibliothèque normale entre deux tranches ;
- impose aux prereleases suivantes de retirer `#[cfg(test)]` uniquement sur les helpers qu'elles commencent effectivement à consommer.
Les deux helpers `is_empty()` qui n'ont aucun consommateur ni test utile à ce stade sont supprimés et seront réintroduits seulement si un wrapper
les nécessite réellement.
## Correction Clippy
### Configs `Copy`
Les serializers internes de :
```text
SolanaCommitmentConfig
SolanaContextConfig
```
prennent désormais `self` par valeur, ce qui correspond à leur nature `Copy` et supprime `clippy::wrong_self_convention` sans modifier la forme JSON.
`SolanaContextConfig::to_json_value()` reste production-live parce que le canari `getBalance` de `0.2.1` le consomme déjà.
### Assertion constante
Le test `account_wire_fixture_preserves_legacy_encoded_and_json_parsed_data` remplace le `match` avec `assert!(false, ...)` par :
1. une assertion `matches!` sur `JsonParsed` ;
2. un `if let` pour vérifier le contenu.
Aucun `panic!()` ou `unreachable!()` explicite n'est introduit.
## Complément de tests préparatoires
Des tests unitaires sont ajoutés pour exercer les helpers privés désormais explicitement test-only qui n'étaient pas couverts par `pre.002` :
- sérialisation `SolanaLargestAccountsConfig` ;
- décodage `SolanaKeyedAccount` et `SolanaAccountBalance` ;
- décodage `SolanaTokenAccountBalance` ;
- décodage `SolanaEpochInfo`, `SolanaEpochSchedule`, `SolanaSnapshotSlotInfo` et `SolanaLeaderSchedule` ;
- sérialisation `SolanaVoteAccountsConfig` ;
- décodage `SolanaVoteAccountStatus` ;
- construction test-only de `SolanaRpcResponse<T>`.
Les fixtures et invariants `clientId`, `inflationRewardsCommissionBps`, `uiAmount: null`, `space: null` et Account data restent couverts.
## Fichiers ajoutés
```text
deltas/0.2.2/pre.002-fix.001.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_common.rs
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_common.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs
```
## Fichiers supprimés
Aucun.
## Fichiers volontairement inchangés
```text
deltas/0.2.2/pre.002.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
ROADMAP.md
CHANGELOG.md
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
crates/ksp-onchain-transport-lib/src/rpc_method.rs
crates/ksp-config-lib/**
config/**
```
Le delta `pre.002.md` reste une trace historique et n'est pas réécrit.
## Validation de `pre.002` ayant déclenché le correctif
Les commandes suivantes ont été exécutées par l'utilisateur sur `0.2.2-pre.002` avant création de ce fix :
```text
cargo fmt --all : exécuté
cargo check --workspace : OK, avec 42 warnings dead_code Transport
cargo clippy --workspace --all-targets : OK, avec les warnings dead_code + assertions_on_constants + wrong_self_convention
cargo test -p ksp-onchain-transport-lib : OK
```
Résultats de tests communiqués :
```text
81 tests unitaires : passés
9 tests public_api : passés
2 tests release_completeness : passés
0 échec
```
Ces résultats prouvent la fonctionnalité de la base `pre.002`, mais ne constituent pas une validation du présent fix.
## Contrôles statiques effectués pendant la préparation du fix
- aucune occurrence `#[allow(dead_code)]` ajoutée ;
- aucune occurrence `assert!(false` restante dans les fichiers modifiés ;
- les deux serializers de configs `Copy` prennent `self` par valeur ;
- les helpers privés préparatoires non consommés par la bibliothèque normale sont explicitement `#[cfg(test)]` ;
- les réexports crate-private de support wire sont eux aussi test-only tant qu'aucun wrapper ne les consomme ;
- les helpers test-only identifiés disposent d'un consommateur dans les tests unitaires ;
- aucune méthode RPC `0.2.2` n'est ajoutée à la surface typed-complete ;
- aucune dépendance/feature/configuration Transport n'est modifiée ;
- headers/version des fichiers modifiés incrémentés ;
- `pre.002.md` et le plan `009` restent inchangés.
## Validations du correctif non exécutées dans le sandbox
Le sandbox de préparation ne fournit pas `cargo`, `rustc` ni `rustfmt`. Les validations Rust du fix ne sont donc pas déclarées réussies.
Après application, exécuter au minimum :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Conformément aux règles Rust KSP, `cargo test --workspace` reste requis au checkpoint de clôture approprié. Aucun `cargo tree` supplémentaire n'est
requis pour ce fix puisqu'aucune dépendance ni feature n'a changé.
Résultat attendu immédiatement pour les commandes ci-dessus : aucune régression des canaris `0.2.1`, aucun warning ayant motivé ce fix et quatre
tests unitaires Transport supplémentaires par rapport à `pre.002`.
## Décisions prises
- corriger la cause des warnings plutôt que les masquer avec un lint allowance ;
- conserver les DTOs et fixtures publics de `pre.002` ;
- considérer les serializers/décodeurs privés non encore consommés comme support de test jusqu'à leur activation réelle ;
- ne pas rendre `serde_json::Value` public uniquement pour éviter `dead_code` ;
- ne pas commencer les cinq wrappers Accounts dans ce fix ;
- conserver `pre.003` comme prochaine tranche fonctionnelle après validation du correctif.
## Questions ouvertes
Aucune question bloquante.
## Suite
Après validation et commit de `0.2.2-pre.002-fix.001`, poursuivre avec `0.2.2-pre.003` pour les cinq wrappers Accounts. Les helpers Account
nécessaires seront alors sortis de `#[cfg(test)]` au fur et à mesure de leur consommation réelle.

View File

@@ -0,0 +1,146 @@
<!-- file: deltas/0.2.2/pre.002-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.002-fix.002` — restauration du décodeur de contexte utilisé par `getBalance`
## Base requise
```text
livraison : 0.2.2-pre.002-fix.001
workspace.package.version = "0.2.2-pre.2.fix.1"
```
Le correctif est ouvert après validation locale communiquée le 2026-08-18. `pre.002-fix.001` a correctement réduit les warnings préparatoires,
mais a classé à tort `SolanaRpcContext::decode_wire` comme helper exclusivement test alors que le canari de production `getBalance`, acquis en
`0.2.1`, l'utilise déjà dans `rpc_canary.rs`.
La conséquence est une régression de compilation `E0599` sur `cargo check`, `cargo clippy` et `cargo test`.
## Objectif
Restaurer uniquement le chemin de décodage de contexte déjà nécessaire au runtime du canari `getBalance`, sans annuler le nettoyage ciblé de
`pre.002-fix.001`, sans commencer les wrappers Accounts de `pre.003` et sans élargir l'API publique.
## Version Cargo
Le correctif modifie du Rust de production. Conformément à `VER-ID-007` et `VER-ID-010` :
```text
0.2.2-pre.2.fix.1 -> 0.2.2-pre.2.fix.2
```
Aucune dépendance ni feature Cargo n'est ajoutée ou retirée.
## Correction
Les trois éléments nécessaires au chemin production `rpc_canary::get_balance` redeviennent compilés hors `#[cfg(test)]` :
```text
SolanaRpcContext::decode_wire
rpc_common::decode_wire_json
WireRpcContext
```
`SolanaRpcContext::decode_wire` appelle désormais directement le helper privé de son module au lieu de dépendre du réexport crate-private
`crate::decode_wire_json`, qui reste volontairement réservé aux tests dans `lib.rs` tant qu'aucun autre module de production ne le consomme.
Tous les autres serializers/décodeurs préparatoires rendus test-only par `pre.002-fix.001` restent inchangés. Ce fix ne réintroduit donc pas les
warnings `dead_code` que `fix.001` cherchait à supprimer.
## Fichiers ajoutés
```text
deltas/0.2.2/pre.002-fix.002.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/rpc_common.rs
```
## Fichiers supprimés
Aucun.
## Fichiers volontairement inchangés
```text
deltas/0.2.2/pre.002.md
deltas/0.2.2/pre.002-fix.001.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
ROADMAP.md
CHANGELOG.md
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-onchain-transport-lib/unit_tests/**
crates/ksp-onchain-transport-lib/tests/**
crates/ksp-config-lib/**
config/**
```
Les deltas déjà publiés restent des traces historiques et ne sont pas réécrits.
## Validation ayant déclenché le correctif
Les commandes suivantes ont été exécutées par l'utilisateur sur `0.2.2-pre.002-fix.001` :
```text
cargo fmt --all : exécuté
cargo check --workspace : ÉCHEC E0599
cargo clippy --workspace --all-targets : ÉCHEC E0599
cargo test -p ksp-onchain-transport-lib : ÉCHEC E0599
```
Erreur commune :
```text
no function or associated item named `decode_wire` found for struct `SolanaRpcContext`
rpc_canary.rs:194:48
```
## Contrôles statiques effectués pendant la préparation du fix
- `SolanaRpcContext::decode_wire` n'est plus conditionné par `#[cfg(test)]` ;
- `decode_wire_json` et `WireRpcContext`, ses dépendances directes, sont également production-live ;
- l'appel interne n'utilise plus le réexport test-only `crate::decode_wire_json` ;
- aucun autre `#[cfg(test)]` introduit par `pre.002-fix.001` n'est retiré ;
- aucun `#[allow(dead_code)]` n'est ajouté ;
- aucune API publique, méthode RPC, dépendance, feature ou configuration n'est modifiée ;
- `workspace.package.version` vaut `0.2.2-pre.2.fix.2` ;
- les deltas `pre.002.md` et `pre.002-fix.001.md` restent inchangés.
## Validations du correctif non exécutées dans le sandbox
Le sandbox de préparation ne fournit pas `cargo`, `rustc` ni `rustfmt`. Les validations Rust du présent fix ne sont donc pas déclarées réussies.
Après application, exécuter :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Le résultat attendu est le retour à une compilation réussie du canari `getBalance`, sans réapparition des warnings ciblés par `pre.002-fix.001`.
## Décisions prises
- traiter `SolanaRpcContext::decode_wire` comme code de production parce qu'un consommateur runtime existe déjà ;
- conserver test-only uniquement les helpers qui n'ont réellement aucun consommateur production à cette étape ;
- ne pas modifier `rpc_canary.rs`, dont l'utilisation du décodeur est correcte ;
- ne pas élargir `lib.rs` avec un nouveau réexport crate-private production inutile ;
- ne pas commencer `pre.003` avant validation de ce second fix.
## Questions ouvertes
Aucune question bloquante.
## Suite
Après validation et commit de `0.2.2-pre.002-fix.002`, poursuivre avec `0.2.2-pre.003` pour les cinq wrappers Accounts.

227
deltas/0.2.2/pre.002.md Normal file
View File

@@ -0,0 +1,227 @@
<!-- file: deltas/0.2.2/pre.002.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.002` — primitives RPC Accounts/Tokens/Cluster et wire commun
## Base requise
Livraison précédente corrigée :
```text
0.2.2-pre.001-fix.001
workspace.package.version = "0.2.2-pre.1"
```
Le plan canonique attendu est `docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md` version 3, qui inclut le réaudit Agave `v4.2.1`.
## Objectif
Installer les primitives/configurations/résultats partagés nécessaires aux 22 wrappers `0.2.2`, sans encore implémenter les cinq wrappers
Accounts prévus pour `pre.003`. La tranche mutualise les contrats nés avec les canaris `0.2.1`, fixe les formes wire Account/Token/Cluster et
ajoute des fixtures déterministes pour les variantes nullable/versionnées identifiées pendant `pre.001` et son fix.
## Version Cargo
Conformément à `VER-ID-009`, la nouvelle prerelease synchronise le signal technique :
```text
0.2.2-pre.1 -> 0.2.2-pre.2
```
Aucune dépendance ou feature Cargo n'est ajoutée.
## Implémentation
### RPC commun
Un nouveau module commun possède désormais :
```text
SolanaCommitment
SolanaCommitmentConfig
SolanaContextConfig
SolanaRpcContext
SolanaRpcResponse<T>
```
`SolanaCommitment` et `SolanaRpcContext` ont été déplacés hors du module canari sans casser leurs réexports crate-root. `GetBalanceConfig`
conserve son nom, ses champs internes, son constructeur, ses getters et sa forme `Debug` `0.2.1`; seule sa sérialisation réutilise désormais
`SolanaContextConfig`.
Les helpers internes de décodage :
- convertissent les erreurs serde vers `ERROR_CODE_INVALID_RESPONSE` ;
- convertissent explicitement les chaînes de public key vers `ksp_core_lib::Pubkey` ;
- n'incluent pas la valeur de public key invalide dans les diagnostics.
### Accounts
Les primitives suivantes sont ajoutées :
```text
SolanaAccountEncoding
SolanaDataSliceConfig
SolanaAccountInfoConfig
SolanaLargestAccountsFilter
SolanaLargestAccountsConfig
SolanaMemcmpBytes
SolanaMemcmpFilter
SolanaProgramAccountFilter
SolanaProgramAccountsConfig
SolanaParsedAccountData
SolanaAccountData
SolanaAccount
SolanaKeyedAccount
SolanaAccountBalance
SolanaProgramAccountsResult
```
`SolanaAccountData` préserve les trois familles de wire : chaîne legacy, tuple encodé et `jsonParsed`. Le contenu `parsed` reste un
`serde_json::Value`; aucun décodage Program/SPL n'entre dans Transport. `space` reste `Option<u64>`.
`SolanaProgramAccountFilter` conserve `dataSize`, `memcmp` et `tokenAccountState`. Les bytes `memcmp` peuvent rester base58/base64 textuels ou
raw sans ajouter `bs58`/`base64`; les limites de cardinalité et de taille seront appliquées par les wrappers/validation `pre.003` conformément au
plan.
### Tokens
Les primitives suivantes sont ajoutées :
```text
SolanaTokenAccountSelector
SolanaTokenAmount
SolanaTokenAccountBalance
```
Le selector est un enum `Mint | ProgramId`, ce qui interdit par construction un objet contenant simultanément les deux clés. `uiAmount` reste
`Option<f64>` et `amount`/`uiAmountString` restent des chaînes exactes du wire.
### Cluster
Les DTOs/configs partagés suivants sont ajoutés :
```text
SolanaClusterNode
SolanaEpochInfo
SolanaEpochSchedule
SolanaSnapshotSlotInfo
SolanaLeaderScheduleConfig
SolanaLeaderScheduleRequest
SolanaLeaderSchedule
SolanaVoteAccountsConfig
SolanaEpochCredits
SolanaVoteAccountInfo
SolanaVoteAccountStatus
```
Le DTO node conserve tous les endpoints comme chaînes optionnelles et inclut `clientId: Option<String>` confirmé dans Agave `v4.2.1`.
`SolanaVoteAccountInfo` inclut `inflationRewardsCommissionBps: Option<u16>` sans le dériver de `commission`, ainsi que les triples epoch credits
sous un petit DTO nommé. `SolanaLeaderScheduleRequest` encode explicitement les overloads current-epoch/config et slot/config.
## Fixtures déterministes ajoutées
```text
crates/ksp-onchain-transport-lib/fixtures/http/account_data.variants.json
crates/ksp-onchain-transport-lib/fixtures/http/token_amount.null_ui.json
crates/ksp-onchain-transport-lib/fixtures/http/cluster_node.v4_2_1.json
crates/ksp-onchain-transport-lib/fixtures/http/vote_account.v4_2_1.json
```
Elles couvrent notamment legacy/encoded/jsonParsed Account data, `space: null`, `uiAmount: null`, `clientId`, champs Cluster absents et présence
de `inflationRewardsCommissionBps`. Un test supplémentaire vérifie l'absence de ce dernier champ pour une forme de noeud plus ancienne.
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/src/rpc_common.rs
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_common.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs
crates/ksp-onchain-transport-lib/fixtures/http/account_data.variants.json
crates/ksp-onchain-transport-lib/fixtures/http/token_amount.null_ui.json
crates/ksp-onchain-transport-lib/fixtures/http/cluster_node.v4_2_1.json
crates/ksp-onchain-transport-lib/fixtures/http/vote_account.v4_2_1.json
deltas/0.2.2/pre.002.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
```
## Fichiers supprimés
Aucun.
## Fichiers volontairement inchangés
```text
CHANGELOG.md
ROADMAP.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
crates/ksp-onchain-transport-lib/Cargo.toml
crates/ksp-onchain-transport-lib/src/executor.rs
crates/ksp-onchain-transport-lib/src/rpc_method.rs
crates/ksp-config-lib/**
config/**
```
Le plan ne change pas : `pre.003` reste consacré aux cinq wrappers Accounts.
## Validations exécutées
- reconstruction locale de l'état `v0.2.1 + pre.001 + pre.001-fix.001` à partir des archives fournies ;
- relecture de `RULES_RUST.md`, `FILE_CONTRACTS.md`, `VERSION_WORKFLOW.md` et du plan `009` version 3 ;
- recoupement des structures account/token Agave `v4.2.1` (`UiAccount`, `UiAccountData`, `UiAccountEncoding`, `UiTokenAmount`) ;
- recoupement des extensions Cluster/Vote Agave `v4.2.1` (`client_id`, `inflation_rewards_commission_bps`) ;
- contrôle statique des réexports crate-root et de l'absence de nouvelle dépendance Cargo ;
- contrôle statique de l'absence de wrapper `0.2.2` ajouté prématurément dans cette tranche ;
- contrôle des fixtures JSON avec un parseur JSON local ;
- contrôle du contenu de l'archive d'échange après génération.
## Validations non exécutées
Le sandbox ne fournit pas `cargo` ni `rustfmt`. Les commandes suivantes n'ont donc pas été déclarées comme réussies et doivent être exécuté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
```
Conformément à `RUST-*`, `cargo test --workspace` reste requis au point de contrôle de session/clôture approprié. Aucun `cargo tree` supplémentaire
n'est requis par ce delta, puisqu'aucune dépendance ni feature n'a changé.
## Décisions prises
- conserver les quatre canaris `0.2.1` et leurs noms publics ;
- centraliser les primitives réellement communes plutôt que créer un mega DTO ;
- utiliser `ksp_core_lib::Pubkey` dans les DTOs publics, avec conversion wire explicite ;
- ne pas activer `serde` sur `solana-pubkey` uniquement pour les réponses RPC ;
- ne pas ajouter `base64`, `bs58`, SPL ou client RPC haut niveau ;
- conserver les champs provider/versionnés optionnels au lieu de les rendre obligatoires ;
- ne pas ajouter de logs par DTO : l'observabilité reste au niveau du transport/executor.
## Questions ouvertes
Aucune question bloquante pour `pre.003`.
Le formatage final exact doit être produit par le `rustfmt` canonique du dépôt lors de l'application locale, puisque le binaire n'est pas disponible
dans le sandbox d'échange.
## Suite
`0.2.2-pre.003` : implémenter les cinq wrappers Accounts (`getAccountInfo`, `getLargestAccounts`, `getMinimumBalanceForRentExemption`,
`getMultipleAccounts`, `getProgramAccounts`) avec validation de cardinalité/filtres, décodage via les DTOs de cette tranche, serveur HTTP local et
fixtures déterministes.

View File

@@ -0,0 +1,154 @@
<!-- file: deltas/0.2.2/pre.003-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.003-fix.001` — activation production des helpers wire Accounts partagés
## Base requise
```text
livraison : 0.2.2-pre.003
workspace.package.version = "0.2.2-pre.3"
```
Le correctif est ouvert après validation locale communiquée le 2026-08-18. `pre.003` active en production les cinq wrappers Accounts, mais leurs
chemins de décodage utilisent `crate::decode_wire_json` et `crate::parse_wire_pubkey` alors que les deux réexports crate-private sont restés
conditionnés par `#[cfg(test)]` dans `lib.rs` depuis le nettoyage préparatoire de `pre.002-fix.001`.
La conséquence est une régression de compilation `E0425` sur `cargo check`, `cargo clippy` et `cargo test`.
## Objectif
Rendre production-live uniquement les deux réexports crate-private désormais réellement consommés par les wrappers Accounts, sans modifier leur
visibilité externe, sans changer les contrats publics `pre.003`, sans réécrire le delta historique `pre.003.md` et sans commencer la famille
Tokens de `pre.004`.
## Version Cargo
Le correctif modifie du Rust de production. Conformément à `VER-ID-007` et `VER-ID-010` :
```text
0.2.2-pre.3 -> 0.2.2-pre.3.fix.1
```
Aucune dépendance ni feature Cargo n'est ajoutée ou retirée.
## Correction
Dans `crates/ksp-onchain-transport-lib/src/lib.rs`, les deux réexports internes suivants ne sont plus conditionnés par `#[cfg(test)]` :
```text
pub(crate) use self::rpc_common::decode_wire_json;
pub(crate) use self::rpc_common::parse_wire_pubkey;
```
Leurs commentaires sont ajustés pour décrire leur rôle réel dans les adapters RPC typés, et non plus uniquement dans les tests préparatoires.
La visibilité reste strictement `pub(crate)` :
- aucune nouvelle API publique externe n'est créée ;
- `rpc_common` reste propriétaire des implémentations ;
- les wrappers Accounts peuvent continuer à utiliser la façade interne `crate::...` ;
- aucun callsite Accounts n'est modifié ;
- aucun `#[allow(...)]` n'est ajouté.
## Fichiers ajoutés
```text
deltas/0.2.2/pre.003-fix.001.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
```
## Fichiers supprimés
Aucun.
## Fichiers volontairement inchangés
```text
deltas/0.2.2/pre.003.md
deltas/0.2.2/pre.002-fix.002.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
ROADMAP.md
CHANGELOG.md
crates/ksp-onchain-transport-lib/src/rpc_common.rs
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-onchain-transport-lib/unit_tests/**
crates/ksp-onchain-transport-lib/tests/**
crates/ksp-config-lib/**
config/**
```
Les deltas déjà publiés restent des traces historiques et ne sont pas réécrits.
## Validation ayant déclenché le correctif
Les commandes suivantes ont été exécutées par l'utilisateur sur `0.2.2-pre.003` :
```text
cargo fmt --all : exécuté
cargo check --workspace : ÉCHEC E0425
cargo clippy --workspace --all-targets : ÉCHEC E0425
cargo test -p ksp-onchain-transport-lib : ÉCHEC E0425
```
Les erreurs concernent douze usages production dans `rpc_accounts.rs`. Les deux symboles absents sont :
```text
crate::decode_wire_json
crate::parse_wire_pubkey
```
Le compilateur confirme que leurs réexports existent dans `lib.rs`, mais sont configurés hors build normal par `#[cfg(test)]`.
## Contrôles statiques effectués pendant la préparation du fix
- `decode_wire_json` reste défini production-live dans `rpc_common.rs` ;
- `parse_wire_pubkey` reste défini production-live dans `rpc_common.rs` ;
- seuls leurs deux réexports crate-private perdent `#[cfg(test)]` ;
- leur visibilité reste `pub(crate)` ;
- les douze callsites `rpc_accounts.rs` restent inchangés ;
- aucun `#[allow(dead_code)]` ou autre suppression de lint n'est ajouté ;
- aucun wrapper Token/Cluster supplémentaire n'est activé ;
- aucune dépendance, feature ou configuration n'est modifiée ;
- `workspace.package.version` vaut `0.2.2-pre.3.fix.1` ;
- le delta `pre.003.md` reste inchangé.
## Validations du correctif non exécutées dans le sandbox
Le sandbox de préparation ne fournit pas la toolchain Cargo utilisée par le checkout opérateur. Les validations Rust du présent fix ne sont donc
pas déclarées réussies.
Après application, exécuter :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Le résultat attendu est une compilation normale des cinq wrappers Accounts, sans les `E0425` signalées et sans nouveau warning.
## Décisions prises
- promouvoir les deux réexports internes parce qu'ils ont maintenant des consommateurs production réels ;
- conserver `pub(crate)` plutôt que d'élargir l'API publique ;
- ne pas modifier les douze callsites Accounts alors que leur chemin via la façade interne crate est cohérent ;
- ne pas réintroduire de gating test sur des helpers consommés au runtime ;
- ne pas commencer `pre.004` avant validation de ce fix.
## Questions ouvertes
Aucune question bloquante.
## Suite
Après validation et commit de `0.2.2-pre.003-fix.001`, clôturer `pre.003` puis poursuivre avec `0.2.2-pre.004` pour les cinq wrappers Tokens.

335
deltas/0.2.2/pre.003.md Normal file
View File

@@ -0,0 +1,335 @@
<!-- file: deltas/0.2.2/pre.003.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.003` — cinq wrappers HTTP Accounts typés
## Base requise
Livraison précédente corrigée et validée localement par l'opérateur :
```text
0.2.2-pre.002-fix.002
workspace.package.version = "0.2.2-pre.2.fix.2"
```
La validation opérateur du 2026-08-18 a confirmé :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-onchain-transport-lib OK
```
Résultats Transport de cette base :
```text
85 unit tests
9 public API tests
2 release completeness tests
0 warning signalé par check/clippy
```
Le plan canonique reste `docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md` version 3.
## Objectif
Clôturer la famille Accounts attribuée à `0.2.2` avec cinq wrappers publics typés :
```text
getAccountInfo
getLargestAccounts
getMinimumBalanceForRentExemption
getMultipleAccounts
getProgramAccounts
```
Tous passent par la foundation HTTP `0.2.1` :
```text
wrapper typé
-> descriptor audité
-> execute_standard_rpc
-> pool/admission/retry/deadline
-> reqwest HTTP
-> JSON-RPC validation
-> décodage DTO KSP
```
Aucun wrapper ne contacte `reqwest` directement.
## Version Cargo
Conformément à `VER-ID-009` :
```text
0.2.2-pre.2.fix.2 -> 0.2.2-pre.3
```
Aucune dépendance ni feature Cargo n'est ajoutée.
## Surface typée Accounts
### `getAccountInfo`
Signature publique conceptuelle :
```text
role + Pubkey + Option<SolanaAccountInfoConfig>
-> SolanaRpcResponse<Option<SolanaAccount>>
```
Le wrapper :
- sérialise `encoding`, `dataSlice`, `commitment` et `minContextSlot` ;
- omet l'objet config lorsqu'il est vide ;
- conserve l'absence du compte sous `None` ;
- conserve les formes Account data legacy, tuple encodé et `jsonParsed` ;
- convertit l'owner en `ksp_core_lib::Pubkey` sans recopier une valeur invalide dans le diagnostic.
### `getLargestAccounts`
Signature publique conceptuelle :
```text
role + Option<SolanaLargestAccountsConfig>
-> SolanaRpcResponse<Vec<SolanaAccountBalance>>
```
Le wrapper conserve l'ordre du wire et sérialise les champs KSP déjà audités dans `pre.002`, y compris `filter` et `sortResults` lorsqu'ils sont
explicitement renseignés.
### `getMinimumBalanceForRentExemption`
Signature publique conceptuelle :
```text
role + usize data_len + Option<SolanaCommitmentConfig>
-> u64 lamports
```
Le wrapper encode la longueur de données sans valeur sentinelle et conserve les erreurs JSON-RPC applicatives comme telles.
### `getMultipleAccounts`
Signature publique conceptuelle :
```text
role + &[Pubkey] + Option<SolanaAccountInfoConfig>
-> SolanaRpcResponse<Vec<Option<SolanaAccount>>>
```
Le wrapper :
- refuse localement plus de 100 public keys ;
- sérialise les adresses dans l'ordre fourni ;
- conserve les `null` individuels ;
- vérifie que le nombre d'éléments retournés correspond au nombre d'adresses demandées, afin de ne pas fabriquer silencieusement un mapping
positionnel incohérent.
### `getProgramAccounts`
Signature publique conceptuelle :
```text
role + program Pubkey + Option<SolanaProgramAccountsConfig>
-> SolanaProgramAccountsResult
```
Le résultat préserve les deux formes wire :
```text
Accounts(Vec<SolanaKeyedAccount>)
Context(SolanaRpcResponse<Vec<SolanaKeyedAccount>>)
```
Le wrapper sérialise `dataSize`, `memcmp`, `tokenAccountState`, `withContext`, `sortResults` et la config Account commune.
## Validation locale des paramètres
Nouveau code d'erreur public Transport :
```text
ERROR_CODE_INVALID_RPC_PARAMETERS
onchain_transport.invalid_rpc_parameters
```
Il distingue une requête KSP localement invalide d'une erreur JSON-RPC distante ou d'une réponse invalide.
Les invariants appliqués avant I/O sont :
```text
getMultipleAccounts : <= 100 public keys
getProgramAccounts : <= 4 filters
memcmp raw bytes : <= 128 bytes
```
Pour `memcmp` encodé sous forme base58/base64, KSP ne décode toujours pas localement la chaîne : la limite des 128 octets décodés reste donc
contrôlée par le serveur. Cette décision évite d'ajouter `bs58` ou `base64` uniquement pour une validation anticipée.
## Wire Account activé en production
Les helpers privés préparés par `pre.002` et réellement nécessaires à ces wrappers deviennent production-live :
- sérialisation des configs Account/Largest/Program ;
- conversion des encodings Account ;
- décodage `SolanaAccountData` ;
- décodage Account/KeyedAccount/AccountBalance ;
- conversion `Pubkey` wire ;
- construction interne de `SolanaRpcResponse<T>`.
Les helpers Cluster/Token préparatoires restent inchangés et ne deviennent pas production-live avant leurs tranches respectives.
## Fixtures HTTP déterministes ajoutées
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.null.json
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.invalid_owner.json
crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.invalid_address.json
crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_multiple_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.bare.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.context.success.json
```
Les tests locaux utilisent un serveur HTTP loopback et n'accèdent pas à Internet.
## Couverture de tests ajoutée
Les tests Accounts couvrent :
- request exacte pour chaque méthode ;
- config et omission de config vide ;
- succès typé ;
- account absent ;
- `null` dans `getMultipleAccounts` ;
- ordre de `getMultipleAccounts` ;
- résultat bare et contextualisé de `getProgramAccounts` ;
- invalid owner/address ;
- erreur JSON-RPC applicative ;
- limite des 100 comptes ;
- limite des 4 filtres ;
- limite raw `memcmp` de 128 octets ;
- mismatch cardinalité request/result de `getMultipleAccounts`.
Le test public compile explicitement les cinq méthodes sur `HttpTransportPool` et vérifie le nouveau code d'erreur.
Un canari release supplémentaire fige l'ensemble Accounts `0.2.2` exact :
```text
getAccountInfo
getLargestAccounts
getMinimumBalanceForRentExemption
getMultipleAccounts
getProgramAccounts
```
Il vérifie aussi que ces descriptors restent `Read + RetrySafe`. Aucune famille Tokens/Cluster n'est déclarée typed-complete par cette tranche.
Après application, la cible Transport attendue devient :
```text
98 unit tests
10 public API tests
3 release completeness tests
```
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.null.json
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.invalid_owner.json
crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.invalid_address.json
crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_multiple_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.bare.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.context.success.json
deltas/0.2.2/pre.003.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/error.rs
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
crates/ksp-onchain-transport-lib/src/rpc_common.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
```
## Fichiers supprimés
Aucun.
## Fichiers volontairement inchangés
```text
CHANGELOG.md
ROADMAP.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
crates/ksp-onchain-transport-lib/Cargo.toml
crates/ksp-onchain-transport-lib/src/executor.rs
crates/ksp-onchain-transport-lib/src/rpc_method.rs
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-config-lib/**
config/**
```
La tranche suivante reste `pre.004 — cinq wrappers Tokens`.
## Validations exécutées
- prise en compte de la validation opérateur propre de `pre.002-fix.002` comme base ;
- relecture des règles `VERSION_WORKFLOW.md` et `RULES_RUST.md` applicables ;
- revérification de la documentation HTTP Solana actuelle pour les cinq méthodes Accounts ;
- recoupement des signatures/limites avec la source Agave `v4.2.1` auditée par le plan ;
- validation JSON syntaxique des dix nouvelles fixtures ;
- contrôle statique de l'absence de `unwrap`, `expect`, `panic!` et `#[allow(dead_code)]` dans le code de production modifié ;
- contrôle statique que les cinq wrappers utilisent `execute_standard_rpc` et aucun appel `reqwest` direct ;
- contrôle statique de l'absence de nouvelle dépendance/feature ;
- contrôle exact des fichiers inclus dans l'archive d'échange.
## Validations non exécutées
Le sandbox d'échange ne fournit pas `cargo`, `rustc` ou `rustfmt`. Les commandes suivantes doivent être exécutées sur le checkout opérateur avant
commit :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
`cargo test --workspace` reste réservé au checkpoint de clôture/session conformément au workflow KSP. Aucun `cargo tree` n'est requis par cette
tranche puisqu'aucune dépendance ni feature n'a changé.
## Décisions prises
- utiliser les DTOs `pre.002` sans créer de seconde couche RPC ;
- conserver `execute_standard_rpc()` comme unique entrée d'exécution HTTP standard ;
- introduire un code d'erreur dédié aux paramètres typed invalides plutôt que détourner `invalid_settings` ;
- vérifier localement les limites dont KSP peut connaître la violation sans dépendance de décodage supplémentaire ;
- préserver les `null`, l'ordre et la dualité bare/context au lieu de normaliser artificiellement les réponses ;
- ne pas modifier Config ;
- ne pas ajouter de logging spécifique par méthode.
## Questions ouvertes
Aucune question bloquante pour `pre.004`.
## Suite
`0.2.2-pre.004` : implémenter les cinq wrappers Tokens (`getTokenAccountBalance`, `getTokenAccountsByDelegate`, `getTokenAccountsByOwner`,
`getTokenLargestAccounts`, `getTokenSupply`) avec les DTOs préparés, selector `Mint | ProgramId`, fixtures HTTP déterministes et conservation de
`uiAmount: null`.

324
deltas/0.2.2/pre.004.md Normal file
View File

@@ -0,0 +1,324 @@
<!-- file: deltas/0.2.2/pre.004.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.004` — cinq wrappers HTTP Tokens typés
## Base requise
Livraison précédente corrigée et validée localement par l'opérateur :
```text
0.2.2-pre.003-fix.001
workspace.package.version = "0.2.2-pre.3.fix.1"
```
La validation opérateur du 2026-08-18 a confirmé :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-onchain-transport-lib OK
```
Résultats Transport de cette base :
```text
98 unit tests
10 public API tests
3 release completeness tests
0 warning signalé par check/clippy
```
Le plan canonique reste `docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md` version 3.
## Objectif
Clôturer la famille Tokens attribuée à `0.2.2` avec cinq wrappers publics typés :
```text
getTokenAccountBalance
getTokenAccountsByDelegate
getTokenAccountsByOwner
getTokenLargestAccounts
getTokenSupply
```
Tous passent par la foundation HTTP commune :
```text
wrapper typé
-> descriptor audité
-> execute_standard_rpc
-> pool/admission/retry/deadline
-> reqwest HTTP
-> JSON-RPC validation
-> décodage DTO KSP
```
Aucun wrapper Token ne contacte `reqwest` directement et aucune crate SPL n'est ajoutée.
## Version Cargo
Conformément à `VER-ID-009` :
```text
0.2.2-pre.3.fix.1 -> 0.2.2-pre.4
```
Aucune dépendance ni feature Cargo n'est ajoutée ou retirée.
## Réaudit ciblé du contrat courant
Les pages RPC Solana courantes ont été revérifiées le 2026-08-18 pour cette tranche :
```text
https://solana.com/docs/rpc/http/gettokenaccountbalance
https://solana.com/docs/rpc/http/gettokenaccountsbydelegate
https://solana.com/docs/rpc/http/gettokenaccountsbyowner
https://solana.com/docs/rpc/http/gettokenlargestaccounts
https://solana.com/docs/rpc/http/gettokensupply
```
Le contrat reste cohérent avec le plan `009` :
- `getTokenAccountBalance`, `getTokenLargestAccounts` et `getTokenSupply` acceptent un `commitment` optionnel ;
- `getTokenAccountsByDelegate` et `getTokenAccountsByOwner` prennent un selector exclusif `{mint}` ou `{programId}` puis une config Account optionnelle ;
- les cinq réponses sont contextualisées ;
- les deux méthodes de liste renvoient des `pubkey + account` et réutilisent donc le DTO Account générique ;
- `TokenAmount.uiAmount` reste nullable ; `uiAmountString` reste la représentation textuelle à préserver ;
- `getTokenLargestAccounts` renvoie les 20 plus gros token accounts côté RPC, sans que KSP invente un tri supplémentaire.
Le réaudit complémentaire Agave `v4.2.1` déjà fixé en `pre.001-fix.001` reste la référence d'implémentation ciblée ; aucune divergence
nouvelle nécessitant un changement de DTO n'a été identifiée pour cette tranche.
## Surface typée Tokens
### `getTokenAccountBalance`
Signature publique conceptuelle :
```text
role + token account Pubkey + Option<SolanaCommitmentConfig>
-> SolanaRpcResponse<SolanaTokenAmount>
```
Le wrapper préserve `amount`, `decimals`, `uiAmount` nullable et `uiAmountString`, et conserve une erreur JSON-RPC distante comme erreur
applicative RPC.
### `getTokenAccountsByDelegate`
Signature publique conceptuelle :
```text
role + delegate Pubkey + SolanaTokenAccountSelector + Option<SolanaAccountInfoConfig>
-> SolanaRpcResponse<Vec<SolanaKeyedAccount>>
```
Le selector KSP empêche par construction les objets contenant simultanément `mint` et `programId`. La config Account réutilise les encodings,
`dataSlice`, `commitment` et `minContextSlot` activés en `pre.003`.
### `getTokenAccountsByOwner`
Signature publique conceptuelle :
```text
role + owner Pubkey + SolanaTokenAccountSelector + Option<SolanaAccountInfoConfig>
-> SolanaRpcResponse<Vec<SolanaKeyedAccount>>
```
Le wrapper partage le même chemin privé de sérialisation/décodage que la variante Delegate afin d'éviter deux implémentations de wire parallèles.
Un config Account explicitement vide est omis du tableau `params`.
### `getTokenLargestAccounts`
Signature publique conceptuelle :
```text
role + mint Pubkey + Option<SolanaCommitmentConfig>
-> SolanaRpcResponse<Vec<SolanaTokenAccountBalance>>
```
L'ordre retourné par le serveur est conservé. Chaque `address` est validée et convertie en `ksp_core_lib::Pubkey` sans recopier une valeur wire
invalide dans le diagnostic.
### `getTokenSupply`
Signature publique conceptuelle :
```text
role + mint Pubkey + Option<SolanaCommitmentConfig>
-> SolanaRpcResponse<SolanaTokenAmount>
```
`amount` reste une chaîne exacte et KSP ne convertit pas la supply en type métier SPL ni en entier borné localement.
## Mutualisation activée en production
Les helpers Token préparés en `pre.002` deviennent production-live uniquement parce qu'ils ont maintenant des consommateurs runtime réels :
- sérialisation de `SolanaTokenAccountSelector` ;
- décodage `SolanaTokenAmount` ;
- décodage `SolanaTokenAccountBalance` ;
- wire structs Token privés.
`SolanaAccountInfoConfig::is_empty` devient `pub(crate)` pour permettre aux deux wrappers de liste Token de réutiliser la règle d'omission de
config vide déjà utilisée par Accounts. Cette modification reste interne à la crate et n'élargit pas l'API publique externe.
Les helpers Cluster préparatoires restent inchangés et ne deviennent pas production-live avant `pre.005`/`pre.006`.
## Validation locale des paramètres
Aucun nouveau code d'erreur local n'est nécessaire dans cette tranche :
- le selector `Mint | ProgramId` garantit exactement une variante par construction ;
- les owner/delegate/mint/token-account sont des `Pubkey` typées avant l'appel ;
- les configs Account/Commitment réutilisent les types déjà validés ;
- aucune cardinalité supplémentaire documentée ne doit être imposée côté client.
`ERROR_CODE_INVALID_RPC_PARAMETERS` introduit en `pre.003` reste inchangé.
## Fixtures HTTP déterministes ajoutées
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_token_account_balance.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_account_balance.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_delegate.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_owner.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_owner.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_largest_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_largest_accounts.invalid_address.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_supply.success.json
```
Les tests utilisent un serveur HTTP loopback et n'accèdent pas à Internet.
## Couverture de tests ajoutée
Les tests Token couvrent notamment :
- request exacte de chacune des cinq méthodes ;
- selector `programId` pour Delegate ;
- selector `mint` pour Owner ;
- config Account complète et omission d'un config explicitement vide ;
- commitment optionnel ;
- `uiAmount: null` ;
- montant/supply textuel exact ;
- conservation de l'ordre pour `getTokenLargestAccounts` ;
- décodage `jsonParsed` des accounts sous forme JSON opaque Transport ;
- erreur JSON-RPC applicative ;
- rejet d'une `pubkey` ou `address` invalide dans une réponse typée.
Le test public compile explicitement les cinq méthodes depuis `HttpTransportPool`.
Un canari release fige l'ensemble Tokens `0.2.2` exact :
```text
getTokenAccountBalance
getTokenAccountsByDelegate
getTokenAccountsByOwner
getTokenLargestAccounts
getTokenSupply
```
Il vérifie également `Read + RetrySafe` pour chacun. Les 12 wrappers Cluster restent hors de cette tranche.
Après application, la cible Transport attendue devient :
```text
106 unit tests
11 public API tests
4 release completeness tests
```
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_token_account_balance.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_account_balance.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_delegate.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_owner.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_owner.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_largest_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_largest_accounts.invalid_address.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_supply.success.json
deltas/0.2.2/pre.004.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
```
## Fichiers supprimés
Aucun.
## Fichiers volontairement inchangés
```text
deltas/0.2.2/pre.003.md
deltas/0.2.2/pre.003-fix.001.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
ROADMAP.md
CHANGELOG.md
crates/ksp-onchain-transport-lib/src/rpc_common.rs
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-onchain-transport-lib/src/rpc_method.rs
crates/ksp-config-lib/**
config/**
```
Les deltas déjà publiés restent des traces historiques et ne sont pas réécrits.
## Contrôles statiques effectués pendant la préparation
- `workspace.package.version` vaut `0.2.2-pre.4` ;
- les huit nouvelles fixtures JSON sont syntaxiquement valides ;
- les cinq wrappers utilisent `execute_standard_rpc` ;
- aucun `reqwest` direct n'est ajouté dans `rpc_tokens.rs` ;
- aucun `#[allow(dead_code)]` ou suppression de lint n'est ajouté ;
- aucun helper Cluster n'est activé en production ;
- aucune dépendance ou feature Cargo n'est ajoutée ;
- aucune crate SPL n'est importée ;
- la surface publique Accounts de `pre.003` reste présente ;
- les deltas historiques restent inchangés.
## Validations Rust non exécutées dans le sandbox
Le sandbox de préparation ne fournit pas la toolchain Cargo utilisée par le checkout opérateur. Les commandes suivantes doivent donc être rejouées
après application :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Les validations ne sont pas déclarées réussies tant qu'elles n'ont pas été exécutées sur le checkout opérateur.
## Questions ouvertes
Aucune question bloquante.
## Suite
Après validation et commit de `0.2.2-pre.004`, poursuivre avec `0.2.2-pre.005` pour les sept wrappers Cluster simples :
```text
getClusterNodes
getEpochInfo
getEpochSchedule
getHighestSnapshotSlot
getIdentity
getMaxRetransmitSlot
getMaxShredInsertSlot
```

269
deltas/0.2.2/pre.005.md Normal file
View File

@@ -0,0 +1,269 @@
<!-- file: deltas/0.2.2/pre.005.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.005` — sept wrappers HTTP Cluster simples typés
## Base requise
Livraison précédente validée localement par l'opérateur :
```text
0.2.2-pre.004
workspace.package.version = "0.2.2-pre.4"
```
La validation opérateur du 2026-08-18 a confirmé :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-onchain-transport-lib OK
```
Résultats Transport de cette base :
```text
106 unit tests
11 public API tests
4 release completeness tests
0 warning signalé par check/clippy
```
Le plan canonique reste `docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md` version 3.
## Contrôle documentaire avant nouvelle tranche
Le contrôle de cohérence demandé entre prereleases confirme :
- `ROADMAP.md` conserve correctement `0.2.2` en cours `[/]` ;
- le plan `009` attribue exactement à `pre.005` les sept méthodes Cluster simples implémentées ici ;
- `pre.006` conserve exactement cinq méthodes différées : `getLeaderSchedule`, `getSlot`, `getSlotLeader`, `getSlotLeaders`, `getVoteAccounts` ;
- les index `docs/000-README.md`, `docs/plans/000-README.md` et `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md` restent cohérents et ne nécessitent aucune modification.
## Objectif
Implémenter les sept wrappers publics typés Cluster simples prévus par le plan :
```text
getClusterNodes
getEpochInfo
getEpochSchedule
getHighestSnapshotSlot
getIdentity
getMaxRetransmitSlot
getMaxShredInsertSlot
```
Tous passent par la foundation HTTP commune :
```text
wrapper typé
-> descriptor audité
-> execute_standard_rpc
-> pool/admission/retry/deadline
-> reqwest HTTP
-> JSON-RPC validation
-> décodage DTO KSP
```
Aucun wrapper Cluster ne contacte `reqwest` directement.
## Version Cargo
Conformément au cycle prerelease KSP :
```text
0.2.2-pre.4 -> 0.2.2-pre.5
```
Aucune dépendance ni feature Cargo n'est ajoutée ou retirée.
## Réaudit ciblé du contrat courant
Les pages RPC Solana courantes ont été revérifiées le 2026-08-18 :
```text
https://solana.com/docs/rpc/http/getclusternodes
https://solana.com/docs/rpc/http/getepochinfo
https://solana.com/docs/rpc/http/getepochschedule
https://solana.com/docs/rpc/http/gethighestsnapshotslot
https://solana.com/docs/rpc/http/getidentity
https://solana.com/docs/rpc/http/getmaxretransmitslot
https://solana.com/docs/rpc/http/getmaxshredinsertslot
```
Le contrat reste cohérent avec le plan `009` :
- `getClusterNodes` ne prend aucun paramètre et renvoie une liste de contacts de noeuds ;
- `getEpochInfo` accepte uniquement la config commune `commitment/minContextSlot` et conserve `transactionCount` nullable ;
- `getEpochSchedule` ne prend aucun paramètre et renvoie la structure fixe d'epoch schedule ;
- `getHighestSnapshotSlot` ne prend aucun paramètre, conserve `incremental` nullable et laisse l'absence de snapshot comme erreur JSON-RPC distante ;
- `getIdentity` ne prend aucun paramètre et renvoie l'objet `{ identity }`, converti en `Pubkey` KSP ;
- `getMaxRetransmitSlot` et `getMaxShredInsertSlot` ne prennent aucun paramètre et renvoient chacun un `u64`.
Le champ Agave `v4.2.1` `clientId: Option<String>` identifié par `pre.001-fix.001` reste conservé par `SolanaClusterNode`, sans devenir obligatoire.
## Surface typée Cluster simple
### `getClusterNodes`
```text
role -> Vec<SolanaClusterNode>
```
Chaque `pubkey` est validée et convertie en `ksp_core_lib::Pubkey`. Les endpoints réseau restent des chaînes optionnelles. Les champs optionnels absents, y compris `clientId`, restent `None`.
### `getEpochInfo`
```text
role + Option<SolanaContextConfig> -> SolanaEpochInfo
```
Un config explicitement vide est omis de `params`. `transactionCount: null` reste `None`.
### `getEpochSchedule`
```text
role -> SolanaEpochSchedule
```
Aucune transformation métier n'est introduite.
### `getHighestSnapshotSlot`
```text
role -> SolanaSnapshotSlotInfo
```
`incremental` reste optionnel. Une erreur RPC « no snapshot » n'est pas convertie en valeur sentinelle.
### `getIdentity`
```text
role -> Pubkey
```
Le wrapper valide la chaîne `identity` avant de l'exposer comme `Pubkey`, sans recopier une valeur wire invalide dans le diagnostic.
### `getMaxRetransmitSlot` / `getMaxShredInsertSlot`
```text
role -> u64
```
Les deux wrappers partagent un chemin privé simple de décodage `u64` et ne créent aucun DTO artificiel.
## Activation ciblée des helpers préparés en `pre.002`
Les helpers Cluster nécessaires à cette tranche deviennent production-live uniquement maintenant qu'ils ont des consommateurs runtime :
- `SolanaClusterNode::decode_wire` + `WireClusterNode` ;
- `SolanaEpochInfo::decode_wire` + `WireEpochInfo` ;
- `SolanaEpochSchedule::decode_wire` + `WireEpochSchedule` ;
- `SolanaSnapshotSlotInfo::decode_wire` + `WireSnapshotSlotInfo`.
Les helpers spécifiques à `getLeaderSchedule` et `getVoteAccounts` restent sous `#[cfg(test)]` jusqu'à `pre.006`. Aucun `#[allow(dead_code)]` n'est ajouté.
## Fixtures HTTP déterministes ajoutées
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_cluster_nodes.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_cluster_nodes.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_epoch_info.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_epoch_schedule.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_highest_snapshot_slot.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_highest_snapshot_slot.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_identity.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_identity.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_max_retransmit_slot.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_max_shred_insert_slot.success.json
```
Les tests utilisent uniquement un serveur HTTP loopback local.
## Couverture de tests ajoutée
Les tests couvrent notamment :
- requête sans paramètres pour les six méthodes sans config ;
- sérialisation exacte `commitment + minContextSlot` pour `getEpochInfo` ;
- omission d'un `SolanaContextConfig` explicitement vide ;
- `transactionCount: null` ;
- champs ClusterNode optionnels présents et absents ;
- conservation de `clientId` Agave `v4.2.1` ;
- rejet d'une `pubkey` de noeud invalide ;
- `incremental: null` ;
- propagation d'une erreur RPC « no snapshot » ;
- validation de l'identity `Pubkey` ;
- décodage des deux slots max comme `u64`.
Le test public compile explicitement les sept nouvelles méthodes depuis `HttpTransportPool`.
Un canari release fige le sous-ensemble `pre.005` exact et vérifie `Read + RetrySafe` pour les sept descriptors, tout en laissant les cinq méthodes Cluster de `pre.006` enregistrées mais différées.
Après application, la cible Transport attendue devient :
```text
116 unit tests
12 public API tests
5 release completeness tests
```
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_cluster_nodes.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_cluster_nodes.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_epoch_info.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_epoch_schedule.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_highest_snapshot_slot.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_highest_snapshot_slot.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_identity.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_identity.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_max_retransmit_slot.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_max_shred_insert_slot.success.json
deltas/0.2.2/pre.005.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
```
## Fichiers supprimés
Aucun.
## Documentation durable
Aucune modification de ROADMAP/plan/index n'est requise par cette tranche après le contrôle documentaire : les documents existants décrivent déjà exactement ce découpage.
`CHANGELOG.md` reste réservé à la clôture stable.
## Validation à exécuter sur le checkout opérateur
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
La livraison n'affirme pas que ces commandes ont été exécutées dans l'environnement de génération.
## Critères de validation de `pre.005`
`pre.005` est validable lorsque :
- les quatre commandes ci-dessus passent sans warning nouveau ;
- les 116 unit tests passent ;
- les 12 tests public API passent ;
- les 5 tests release completeness passent ;
- aucun helper Cluster simple production-live n'est `dead_code` ;
- les cinq wrappers complexes de `pre.006` restent hors de cette tranche.

View File

@@ -0,0 +1,95 @@
<!-- file: deltas/0.2.2/pre.006-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.006-fix.001` — correction Clippy du test LeaderSchedule
## Base requise
Cette correction s'applique après :
```text
0.2.2-pre.006
workspace.package.version = "0.2.2-pre.6"
```
La validation opérateur du 2026-08-18 a confirmé :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo test -p ksp-onchain-transport-lib OK
```
Résultats Transport :
```text
127 unit tests
13 public API tests
6 release completeness tests
0 échec
```
Le contrôle Clippy a terminé avec un unique warning dans le test `rpc_cluster` :
```text
clippy::clone_on_copy
```
Le `Pubkey` `identity` était cloné alors que ce type implémente `Copy`.
## Nature du fix
Le correctif retire uniquement le `.clone()` inutile :
```text
Some(identity.clone()) -> Some(identity)
```
Aucun comportement runtime n'est modifié.
Aucun wrapper RPC, DTO, fixture, contrat public, dépendance ou configuration n'est modifié.
Le delta historique `pre.006.md` reste inchangé.
## Version Cargo
Ce fix modifie une source Rust de test ; le signal technique devient donc :
```text
0.2.2-pre.6 -> 0.2.2-pre.6.fix.1
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs
```
## Fichier ajouté
```text
deltas/0.2.2/pre.006-fix.001.md
```
## Validation à rejouer
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Le résultat attendu est :
```text
0 warning Clippy
127 unit tests OK
13 public API tests OK
6 release completeness tests OK
```
## Suite
Après validation locale de ce fix, `0.2.2-pre.007` peut reprendre la clôture de prerelease prévue : complétude globale, smoke déterministe pertinent, documentation finale et préparation de la release stable `0.2.2`.

298
deltas/0.2.2/pre.006.md Normal file
View File

@@ -0,0 +1,298 @@
<!-- file: deltas/0.2.2/pre.006.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.006` — cinq wrappers HTTP Cluster restants typés
## Base requise
Livraison précédente validée localement par l'opérateur :
```text
0.2.2-pre.005
workspace.package.version = "0.2.2-pre.5"
```
La validation opérateur du 2026-08-18 a confirmé :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-onchain-transport-lib OK
```
Résultats Transport de cette base :
```text
116 unit tests
12 public API tests
5 release completeness tests
0 warning signalé par check/clippy
```
Le plan canonique reste `docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md` version 3.
## Contrôle documentaire avant nouvelle tranche
Le découpage durable reste cohérent :
- `ROADMAP.md` conserve `0.2.2` en cours `[/]` ;
- le plan `009` attribue exactement à `pre.006` les cinq méthodes Cluster restantes ;
- les 5 Accounts de `pre.003`, les 5 Tokens de `pre.004` et les 7 Cluster simples de `pre.005` restent inchangés ;
- `pre.007` reste la tranche de complétude/smoke/docs/préparation stable prévue ;
- aucun index documentaire durable ne nécessite de modification à cette étape.
## Objectif
Implémenter les cinq wrappers publics typés Cluster restants :
```text
getLeaderSchedule
getSlot
getSlotLeader
getSlotLeaders
getVoteAccounts
```
Après cette tranche, les 22 méthodes HTTP Accounts + Tokens + Cluster attribuées à `0.2.2` possèdent toutes leur wrapper typé KSP.
Tous les wrappers passent par la foundation HTTP commune :
```text
wrapper typé
-> descriptor audité
-> execute_standard_rpc
-> pool/admission/retry/deadline
-> reqwest HTTP
-> JSON-RPC validation
-> décodage DTO KSP
```
Aucun wrapper Cluster ne contacte `reqwest` directement.
## Version Cargo
Conformément au cycle prerelease KSP :
```text
0.2.2-pre.5 -> 0.2.2-pre.6
```
Aucune dépendance ni feature Cargo n'est ajoutée ou retirée.
## Réaudit ciblé du contrat courant
Le contrat préparé dans le plan `009` a été recroisé avec la source primaire Agave `v4.2.1`, notamment :
```text
rpc-client-types/src/config.rs
rpc/src/rpc.rs
```
Les invariants utiles à cette tranche restent :
- `getLeaderSchedule` accepte l'overload slot/config déjà modélisé par `SolanaLeaderScheduleRequest` ;
- `RpcLeaderScheduleConfig` contient `identity` et `commitment` ;
- le résultat de `getLeaderSchedule` reste nullable ;
- `getSlot` et `getSlotLeader` utilisent la config commune `commitment/minContextSlot` ;
- `getSlotLeaders` prend `startSlot + limit`, avec la limite `1..=5000` retenue par le plan ;
- `RpcGetVoteAccountsConfig` contient `votePubkey`, `commitment`, `keepUnstakedDelinquents`, `delinquentSlotDistance` ;
- `getVoteAccounts` renvoie les groupes `current` et `delinquent` ;
- `inflationRewardsCommissionBps` reste optionnel pour compatibilité avec les noeuds antérieurs ;
- l'historique `epochCredits` reste exposé comme historique RPC borné, sans être interprété comme historique complet du vote account.
## Surface typée Cluster finale
### `getLeaderSchedule`
```text
role + SolanaLeaderScheduleRequest -> Option<SolanaLeaderSchedule>
```
`SolanaLeaderScheduleRequest` conserve les deux formes cohérentes :
```text
CurrentEpoch(config?)
Slot { slot, config? }
```
Un config vide est omis. Un résultat JSON `null` devient `None`. Une map non nulle valide chaque identité et la convertit en `ksp_core_lib::Pubkey`.
### `getSlot`
```text
role + Option<SolanaContextConfig> -> u64
```
La config commune est sérialisée uniquement lorsqu'elle contient `commitment` et/ou `minContextSlot`. Une erreur distante `MinContextSlotNotReached` reste une erreur RPC applicative.
### `getSlotLeader`
```text
role + Option<SolanaContextConfig> -> Pubkey
```
La valeur texte du leader est validée avant exposition comme `Pubkey`, sans recopier une valeur wire invalide dans le diagnostic.
### `getSlotLeaders`
```text
role + startSlot u64 + limit u64 -> Vec<Pubkey>
```
KSP rejette localement avant I/O :
```text
limit == 0
limit > 5000
```
L'ordre du tableau retourné est conservé, car chaque position correspond aux slots successifs demandés.
### `getVoteAccounts`
```text
role + Option<SolanaVoteAccountsConfig> -> SolanaVoteAccountStatus
```
Un config vide est omis. Les deux groupes `current` et `delinquent` sont conservés, ainsi que :
```text
votePubkey
nodePubkey
activatedStake
commission
inflationRewardsCommissionBps Option<u16>
epochVoteAccount
epochCredits Vec<SolanaEpochCredits>
lastVote
rootSlot
```
Aucune conversion métier staking/rewards n'est introduite dans Transport.
## Activation finale des helpers préparés en `pre.002`
Les helpers spécifiques aux cinq méthodes de cette tranche deviennent production-live maintenant qu'ils ont des consommateurs runtime :
- `SolanaLeaderScheduleConfig::{is_empty,to_json_value}` ;
- `SolanaLeaderScheduleRequest::to_json_params` ;
- `SolanaLeaderSchedule::decode_wire` ;
- `SolanaVoteAccountsConfig::{is_empty,to_json_value}` ;
- `SolanaVoteAccountInfo::decode_wire` ;
- `SolanaVoteAccountStatus::decode_wire` ;
- `WireVoteAccountInfo` et `WireVoteAccountStatus`.
Aucun `#[allow(dead_code)]` n'est ajouté.
Le helper privé d'exécution Cluster est renommé de `execute_cluster_simple_rpc` vers `execute_cluster_rpc`, car il dessert désormais les douze wrappers Cluster.
## Fixtures HTTP déterministes ajoutées
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.null.json
crates/ksp-onchain-transport-lib/fixtures/http/get_slot.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.invalid_pubkey.json
```
Les tests utilisent uniquement un serveur HTTP loopback local.
## Couverture de tests ajoutée
Les tests couvrent notamment :
- sérialisation slot + config de `getLeaderSchedule` ;
- résultat nullable de `getLeaderSchedule` ;
- sérialisation `commitment/minContextSlot` de `getSlot` et `getSlotLeader` ;
- omission d'un `SolanaContextConfig` vide ;
- validation de la `Pubkey` de `getSlotLeader` ;
- conservation de l'ordre de `getSlotLeaders` ;
- rejet local des limites `0` et `5001` avant I/O ;
- rejet d'une identité invalide dans la liste `getSlotLeaders` ;
- sérialisation complète de `SolanaVoteAccountsConfig` ;
- omission d'un config vote accounts vide ;
- conservation de `inflationRewardsCommissionBps` présent et absent ;
- conservation des triples `epochCredits` ;
- rejet d'une `votePubkey` invalide.
Le test public compile explicitement les cinq nouvelles méthodes depuis `HttpTransportPool`.
Un canari release fige désormais les douze méthodes Cluster exactes de `0.2.2` et vérifie `Read + RetrySafe` sur tout le sous-ensemble.
Après application, la cible Transport attendue devient :
```text
127 unit tests
13 public API tests
6 release completeness tests
```
## Rustdoc de crate
La documentation de tête de `ksp-onchain-transport-lib` est ajustée pour ne plus présenter Cluster comme une famille encore staged : après `pre.006`, les 22 wrappers `0.2.2` sont implémentés. Les familles `0.2.3` et `0.2.4` restent staged.
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.null.json
crates/ksp-onchain-transport-lib/fixtures/http/get_slot.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.invalid_pubkey.json
deltas/0.2.2/pre.006.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
```
## Fichiers supprimés
Aucun.
## Documentation durable
Aucune modification de ROADMAP/plan/index n'est requise dans cette tranche : le plan existant décrit déjà ce découpage et `pre.007` reste la tranche de clôture prerelease/documentation.
`CHANGELOG.md` reste réservé à la clôture stable.
## Validation à exécuter sur le checkout opérateur
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
La livraison n'affirme pas que ces commandes ont été exécutées dans l'environnement de génération.
## Critères de validation de `pre.006`
`pre.006` est validable lorsque :
- les quatre commandes ci-dessus passent sans warning nouveau ;
- les 127 unit tests passent ;
- les 13 tests public API passent ;
- les 6 tests release completeness passent ;
- aucun helper Cluster production-live n'est `dead_code` ;
- les douze wrappers Cluster `0.2.2` sont accessibles depuis `HttpTransportPool` ;
- le total `0.2.2` reste exactement 5 Accounts + 5 Tokens + 12 Cluster = 22 wrappers ;
- aucune méthode `0.2.3`/`0.2.4` n'est activée prématurément.

View File

@@ -0,0 +1,162 @@
<!-- file: deltas/0.2.2/pre.007-fix.001.md -->
<!-- version: 1 -->
# Delta `v0.2.2-pre.007-fix.001`
## Base
Base attendue :
```text
v0.2.2-pre.007
```
Version Cargo de la base :
```text
0.2.2-pre.7
```
Ce fix modifie du Rust de test et synchronise donc le signal technique Cargo conformément à `VER-ID-007` / `VER-ID-010` :
```text
0.2.2-pre.7.fix.1
```
Le delta historique `deltas/0.2.2/pre.007.md` reste inchangé conformément à `KSP-REL-003` et `VER-DELTA-007`.
## Motif du fix
La validation opérateur du 2026-08-18 a révélé deux défauts du nouveau smoke Transport de `pre.007`.
### 1. Clippy `expect_used` hors corps de test
`cargo clippy --workspace --all-targets` échoue sur deux `expect()` placés dans le helper `devnet_pool()` :
```text
HttpEndpointUrl::parse(...).expect(...)
HttpTransportPool::new(...).expect(...)
```
Le workspace autorise les assertions/`expect` dans les corps de tests via sa configuration Clippy, mais le helper non annoté comme test reste soumis à `expect_used = deny`.
Correction : `devnet_pool()` retourne désormais `ksp_core_lib::Result<HttpTransportPool>` et propage explicitement l'erreur par `match`, sans `?`, `unwrap` ni `expect` dans le helper.
### 2. Fixture live Token trop fragile
L'exécution opt-in du smoke Transport a atteint Devnet mais `getTokenSupply` a renvoyé :
```text
onchain_transport.rpc_application_error
rpc_code = -32602
```
Le mint utilisé reste affiché par la documentation officielle courante, mais son état réel n'est donc pas une dépendance live suffisamment durable pour une gate de candidate KSP.
Correction : la branche Token du smoke utilise désormais :
```text
getTokenAccountsByOwner
owner = System Program (Pubkey valide et stable)
selector = programId
programId = SPL Token canonique (Tokenkeg...)
config = absent
```
Une liste vide est acceptable. Le test vérifie le contexte RPC et la traversée du wrapper Token sans dépendre de l'existence d'un mint ou d'un token account Devnet particulier.
La documentation officielle `getTokenAccountsByOwner` confirme que le selector accepte `programId` et que la méthode cible Devnet.
## Résultats opérateur de la base
Avant ce fix, l'opérateur a communiqué :
- `cargo fmt --all` : OK ;
- `cargo check --workspace` : OK ;
- `cargo clippy --workspace --all-targets` : **FAIL**, uniquement les deux `expect_used` ci-dessus ;
- `cargo test -p ksp-onchain-transport-lib` : 127 unit + 13 public API + 7 release completeness, OK ; smoke ignoré par défaut ;
- `cargo test -p ksp-config-lib` : OK ;
- `cargo test -p ksp-core-lib` : OK ;
- `cargo test -p ksp-app-config-desk` : OK ;
- `cargo test --workspace` : OK ;
- inspections `cargo tree` demandées : exécutées ;
- smoke Config -> Transport live : OK ;
- smoke Transport live : **FAIL** uniquement sur `getTokenSupply` avec RPC `-32602`.
Ces échecs empêchent `pre.007` de devenir la base de `rel.001` sans fix.
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/tests/transport_devnet_smoke.rs
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md
```
## Fichier ajouté
```text
deltas/0.2.2/pre.007-fix.001.md
```
Aucun fichier supprimé.
## Inchangé
Ce fix ne modifie pas :
- les 22 wrappers `0.2.2` ;
- les DTOs publics ;
- les descriptors ou politiques retry ;
- Config ;
- les fixtures HTTP déterministes ;
- les canaries de complétude ;
- les dépendances/features Cargo ;
- `ROADMAP.md` ;
- `CHANGELOG.md` ;
- le prompt `0.2.3` ;
- le delta historique `pre.007.md`.
Le périmètre reste strictement la correction de la candidate `pre.007`.
## Validations exécutées pendant la préparation
Contrôles statiques exécutés :
- version Cargo `0.2.2-pre.7.fix.1` ;
- versions de tous les fichiers modifiés incrémentées ;
- aucun `expect()` dans `devnet_pool()` ;
- absence du mint d'exemple `3wyAj7...` dans le smoke corrigé ;
- présence de `getTokenAccountsByOwner` + selector `ProgramId` ;
- aucune dépendance/feature ajoutée ;
- payload du fix limité aux sept fichiers listés ;
- archive réextraite et comparée au payload préparé.
Cargo n'est pas disponible dans l'environnement de préparation.
## Validations opérateur à rejouer
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
```
Résultat attendu :
```text
clippy propre
127 unit + 13 public API + 7 release completeness OK
smoke Transport live OK
smoke Config -> Transport live OK
```
Si ces gates passent, `0.2.2-rel.001` peut rester strictement publicationnel comme prévu.

View File

@@ -0,0 +1,88 @@
<!-- file: deltas/0.2.2/pre.007-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.007-fix.002`
## Base requise
- `0.2.2-pre.007-fix.001` appliqué et commité ;
- `workspace.package.version = "0.2.2-pre.7.fix.1"` avant application de ce delta.
## Objectif
Corriger le deuxième échec opérateur du smoke Devnet Transport introduit en `pre.007`, sans modifier les wrappers HTTP typés ni la surface publique `0.2.2`.
La passe `pre.007-fix.001` a validé `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, les tests Transport et `cargo test --workspace`. Le smoke historique `Config -> Transport` a également réussi. En revanche, le smoke Transport pur a reçu RPC `-32600` sur `getTokenAccountsByOwner` lorsqu'il utilisait `System Program` comme owner, le selector `{ programId: SPL Token }` et omettait le troisième paramètre de config.
## Décision
Le wrapper conserve son contrat existant : la config Account reste optionnelle, conformément à la surface RPC auditée et aux tests déterministes.
Le scénario live adopte la forme la plus conservatrice de l'exemple RPC public Devnet :
- owner Pubkey ordinaire de l'exemple officiel ;
- selector `{ programId: SPL Token }` ;
- config explicite `{ commitment: "finalized", encoding: "jsonParsed" }`.
Le smoke ne dépend toujours pas de l'existence d'un token account particulier : une liste vide reste un succès valide.
## Fichiers modifiés
- `Cargo.toml`
- version de fichier `117 -> 118` ;
- `workspace.package.version`: `0.2.2-pre.7.fix.1 -> 0.2.2-pre.7.fix.2`.
- `crates/ksp-onchain-transport-lib/tests/transport_devnet_smoke.rs`
- version `2 -> 3` ;
- remplace le scénario Token `System Program + selector seul` par la forme documentée `owner ordinaire + selector + finalized/jsonParsed`.
- `crates/ksp-onchain-transport-lib/README.md`
- version `3 -> 4` ;
- précise la forme du smoke Token.
- `crates/ksp-onchain-transport-lib/USAGE.md`
- version `3 -> 4` ;
- synchronise la commande et la sémantique du smoke Token.
- `docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`
- version `5 -> 6` ;
- consigne l'échec live `-32600` et la correction du scénario.
- `docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`
- version `2 -> 3` ;
- synchronise la matrice de validation avec le scénario corrigé.
## Fichier ajouté
- `deltas/0.2.2/pre.007-fix.002.md`.
## Fichiers supprimés
Aucun.
## Validations observées avant ce fix
Réussies sur le checkout opérateur `0.2.2-pre.7.fix.1` :
- `cargo fmt --all` ;
- `cargo check --workspace` ;
- `cargo clippy --workspace --all-targets` ;
- `cargo test -p ksp-onchain-transport-lib` : `127 + 13 + 7`, smoke live ignoré par défaut ;
- `cargo test --workspace` : succès ;
- `cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture` : succès.
Échec observé :
- `cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture` ;
- `getTokenAccountsByOwner` a reçu `onchain_transport.rpc_application_error`, `rpc_code = -32600`.
## Validations à exécuter après application
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
```
## Questions ouvertes
Aucune question d'architecture nouvelle. Si la forme RPC publique documentée échoue encore sur Devnet, le prochain diagnostic devra capturer/Comparer le wire live ou considérer explicitement une limitation du RPC public, plutôt que remplacer une troisième fois arbitrairement le scénario Token.

248
deltas/0.2.2/pre.007.md Normal file
View File

@@ -0,0 +1,248 @@
<!-- file: deltas/0.2.2/pre.007.md -->
<!-- version: 1 -->
# Delta `v0.2.2-pre.007`
## Base
Base attendue :
```text
v0.2.2-pre.006-fix.001
```
Cette base a été validée localement par l'opérateur le 2026-08-18 avec :
- `cargo fmt --all` ;
- `cargo check --workspace` ;
- `cargo clippy --workspace --all-targets` ;
- `cargo test -p ksp-onchain-transport-lib`.
Résultats Transport communiqués :
```text
127 unit tests passed
13 public API tests passed
6 release completeness tests passed
0 warning clippy signalé
```
Version Cargo cible :
```text
0.2.2-pre.7
```
## Objectif
Fermer la dernière prerelease fonctionnelle de `0.2.2` sans ajouter de nouvelle méthode HTTP :
- revérifier l'inventaire officiel Solana ;
- figer la complétude exacte des 22 wrappers `0.2.2` ;
- ajouter un smoke Devnet opt-in **Transport pur** avec settings programmatiques ;
- conserver séparément le smoke historique de composition Config -> Transport ;
- synchroniser README/USAGE et la documentation d'architecture/planification ;
- produire la matrice finale `0.2.2` ;
- produire le prompt de démarrage `0.2.3 — HTTP Transactions` ;
- préparer un `rel.001` strictement publicationnel.
Aucun 23e wrapper `0.2.2` et aucune méthode Transactions n'est introduit dans cette tranche.
## Réaudit officiel final
La documentation officielle Solana a été revérifiée le 2026-08-18 :
```text
https://solana.com/docs/rpc/http
https://solana.com/docs/rpc/deprecated/confirmtransaction
```
L'index courant contient toujours :
```text
52 méthodes HTTP courantes
14 méthodes historiques Deprecated
```
Les familles restent :
```text
Accounts : 6 current
Tokens : 5 current
Cluster : 15 current
```
En retranchant les quatre canaris `0.2.1` (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`), la partition `0.2.2` reste exactement `5 + 5 + 12 = 22`. Aucun recalibrage n'est nécessaire.
## Canaries de complétude
`tests/release_completeness.rs` gagne un canary final qui fige depuis l'API publique :
```text
V0_2_2 exact set == 22 méthodes du plan 009
22 => Stable + Supported + Read + RetrySafe
V0_2_3 count == 11
V0_2_4 count == 15
```
Les canaries antérieures `52 + 14`, `4 / 22 / 11 / 15`, foundation exacte, Accounts, Tokens et Cluster restent inchangées.
La candidate attend donc :
```text
127 tests unitaires Transport
13 tests public API
7 tests release completeness
1 smoke Devnet Transport ignored par défaut
```
## Smoke Devnet Transport pur
Nouveau test :
```text
crates/ksp-onchain-transport-lib/tests/transport_devnet_smoke.rs
```
Contrairement au smoke historique de `0.2.1`, il ne dépend pas de Config :
```text
settings programmatiques
-> https://api.devnet.solana.com
-> HttpTransportPool
-> getAccountInfo(System Program)
-> getTokenSupply(mint de l'exemple Devnet officiel courant)
-> getEpochInfo
-> getVoteAccounts
```
Exécution opt-in :
```bash
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
```
Ce smoke appartient durablement à Transport parce qu'il ne valide que Transport. Il reste `ignored` car il dépend d'un réseau externe et d'un état Devnet.
Le smoke `crates/ksp-config-lib/tests/transport_devnet_smoke.rs` reste séparé et valide uniquement la composition historique :
```text
Config -> Transport -> quatre canaris foundation
```
Il reste transitoire et devra migrer vers une future surface d'intégration/orchestration/demo ; Config ne devient pas la destination générale des smokes cross-crates.
## Documentation durable
Mises à jour :
- README Transport : 26 wrappers typed candidats, répartition 4 + 5 + 5 + 12 et ownership des deux smokes ;
- USAGE Transport : exemples typed `0.2.2` et commandes des deux smokes ;
- inventaire composant : état candidate `0.2.2` ;
- séquence fonctionnelle : `pre.002``pre.006` exécutées et `pre.007` candidate ;
- plan `009` : état candidate, réaudit final, smoke et préparation stable ;
- ROADMAP : `0.2.2` passe à `[/]`, pas à `[X]` avant stable ;
- index docs/plans/validation/prompts synchronisés.
Nouveaux documents :
```text
docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md
prompts/008-V0_2_3_START_PROMPT.md
```
`CHANGELOG.md` n'est volontairement pas modifié dans cette prerelease.
## Prompt `0.2.3`
Le prompt suivant cible exactement les 11 méthodes Transactions actuellement affectées à `V0_2_3` :
```text
8 Read / RetrySafe
2 WriteSubmission / NeverAfterDispatch
1 Simulation / RetrySafe
```
Il impose un nouveau `pre.001` d'audit/sizing et met au premier plan la règle no-resend de `requestAirdrop` et `sendTransaction`.
## Préparation de `rel.001`
Si la candidate passe les validations opérateur, `rel.001` doit rester publicationnel :
```text
workspace.package.version -> 0.2.2
ROADMAP : 0.2.2 -> [X]
CHANGELOG : synthèse stable 0.2.2
plan 009 / validation 004 : clôture + preuves opérateur
deltas/0.2.2/rel.001.md
commit v0.2.2-rel.001
tag v0.2.2 après validation du commit
```
Aucun développement fonctionnel nouveau ne doit entrer dans `rel.001`.
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/tests/transport_devnet_smoke.rs
docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md
prompts/008-V0_2_3_START_PROMPT.md
deltas/0.2.2/pre.007.md
```
## Fichiers modifiés
```text
Cargo.toml
ROADMAP.md
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
docs/000-README.md
docs/architecture/004-COMPONENT_INVENTORY.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
docs/validation/000-README.md
prompts/000-README.md
```
Aucun ancien delta et aucun `CHANGELOG.md` ne sont modifiés.
## Validations exécutées dans la préparation de livraison
Contrôles statiques exécutés :
- version Cargo `0.2.2-pre.7` ;
- présence exacte des 22 noms dans le canary final ;
- fixture live absente des suites par défaut via `#[ignore]` ;
- smoke Transport sans dépendance Config ni lecture d'environnement ;
- index docs/plans/validation/prompts synchronisés ;
- versions de fichiers modifiés incrémentées ;
- aucun ajout de dépendance/feature Cargo ;
- archive réextraite et comparée au payload préparé.
Cargo n'est pas disponible dans l'environnement de préparation. Les validations suivantes restent donc à exécuter par l'opérateur sur le dépôt canonique :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test -p ksp-config-lib
cargo test -p ksp-core-lib
cargo test -p ksp-app-config-desk
cargo test --workspace
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib -d
cargo tree -p ksp-onchain-transport-lib -e features
cargo tree -p ksp-onchain-transport-lib -e normal
cargo tree -p ksp-config-lib
cargo tree -p ksp-config-lib -d
cargo tree -p ksp-config-lib -e features
cargo tree -p ksp-config-lib -e normal
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
```

154
deltas/0.2.2/rel.001.md Normal file
View File

@@ -0,0 +1,154 @@
<!-- file: deltas/0.2.2/rel.001.md -->
<!-- version: 1 -->
# Delta `v0.2.2-rel.001`
## Base
Base attendue :
```text
v0.2.2-pre.007-fix.002
```
Version Cargo cible :
```text
0.2.2
```
La candidate corrigée a été validée localement par l'opérateur le 2026-08-18 avec `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-onchain-transport-lib`, `cargo test --workspace` et les deux smokes Devnet opt-in.
## Objectif
Publier `0.2.2 — HTTP Accounts + Tokens + Cluster` sans ajouter de capacité fonctionnelle après `pre.007-fix.002`.
`rel.001` est strictement publicationnelle :
- passage du workspace à la version stable `0.2.2` ;
- clôture `[X]` de `0.2.2` dans le ROADMAP ;
- ajout de l'entrée stable `0.2.2` au CHANGELOG ;
- clôture du plan `009` et de la matrice `validation/004` avec les preuves opérateur ;
- synchronisation des index, de l'inventaire composant et des README/USAGE Transport vers le statut stable ;
- conservation du prompt `prompts/008-V0_2_3_START_PROMPT.md` comme point d'ouverture de `0.2.3`.
Aucun fichier Rust de production, aucune API publique, aucune configuration runtime, aucune dépendance et aucune feature Cargo ne changent dans cette livraison.
## Surface stable publiée
`0.2.2` ajoute exactement 22 wrappers typés :
```text
Accounts : 5
Tokens : 5
Cluster : 12
```
Avec les quatre wrappers foundation de `0.2.1`, la surface typée HTTP atteint :
```text
26 wrappers typed
52 méthodes HTTP courantes enregistrées
14 méthodes historiques Deprecated / Removed
partition restante : 11 Transactions + 15 Blocks/Economics
```
Les 22 descriptors `0.2.2` restent `Read / RetrySafe` et utilisent tous le flux central `descriptor -> execute_standard_rpc -> pool/admission -> executor HTTP`.
Contrats stabilisés notamment : Account legacy/encoded/`jsonParsed`, comptes absents `null`, Program Accounts bare/contextualisés, Token selector `Mint | ProgramId`, `TokenAmount.uiAmount` nullable, `ClusterNode.clientId` optionnel, epoch/snapshot nullable, leader schedule nullable et Vote Accounts avec `inflationRewardsCommissionBps` optionnel.
Limites locales publiées :
```text
getMultipleAccounts <= 100
getProgramAccounts <= 4 filtres
memcmp raw <= 128 octets
getSlotLeaders 1..=5000
```
## Validation de la candidate
La candidate `0.2.2-pre.007-fix.002` a confirmé :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK, sans warning
cargo test -p ksp-onchain-transport-lib OK
cargo test --workspace OK
```
Transport :
```text
127 unit
13 public API
7 release completeness
1 smoke live ignored par défaut
```
Les deux smokes ont ensuite été exécutés explicitement :
```text
Transport pur : settings programmatiques -> Accounts/Tokens/Cluster 1 passed
Config -> Transport -> foundation 1 passed
```
Le smoke Transport utilise `getAccountInfo`, `getTokenAccountsByOwner` sous sa forme Devnet documentée complète, `getEpochInfo` et `getVoteAccounts`. Il appartient à Transport parce qu'il construit ses settings programmatiquement. Le smoke Config -> Transport reste une exception transitoire cross-crates et doit migrer vers une future surface d'intégration/orchestration/demo lorsqu'elle existera.
Les graphes Cargo Transport/Config ont été inspectés pendant la candidate. Les fixes du smoke n'ont ajouté ni dépendance ni feature. La frontière reste :
```text
ksp-config-lib -> ksp-onchain-transport-lib
ksp-onchain-transport-lib -X-> ksp-config-lib
Transport -X-> Store/Program/tracing direct
```
Les doublons observés restent limités à `syn` 2.x/3.x dans les chaînes transitive/proc-macro ; aucune seconde stack HTTP/Tokio KSP n'est introduite.
## Documentation de clôture
Mises à jour :
```text
CHANGELOG.md
ROADMAP.md
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
docs/000-README.md
docs/architecture/004-COMPONENT_INVENTORY.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
docs/validation/000-README.md
docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md
```
Le prompt `prompts/008-V0_2_3_START_PROMPT.md` était déjà livré en `pre.007` et reste inchangé ; il attend comme base le tag stable `v0.2.2`.
## Commit et tag
Identifiant de commit attendu :
```text
v0.2.2-rel.001
```
Le tag stable ne doit être créé qu'après application et validation de ce delta :
```text
v0.2.2
```
## Validation finale après application
Comme `rel.001` ne modifie aucun code de production et ne change que le signal de version et la documentation, la gate finale reste :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
```
Après succès : commit `v0.2.2-rel.001`, puis création du tag `v0.2.2`.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/000-README.md --> <!-- file: docs/000-README.md -->
<!-- version: 24 --> <!-- version: 27 -->
# Documentation KSP # Documentation KSP
@@ -40,7 +40,8 @@ docs/
│ ├── 005-V0_1_3_CONFIG_FOUNDATION_PLAN.md │ ├── 005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
│ ├── 006-V0_1_4_CONFIG_DESKTOP_PLAN.md │ ├── 006-V0_1_4_CONFIG_DESKTOP_PLAN.md
│ ├── 007-V0_2_0_SERIES_PLANNING.md │ ├── 007-V0_2_0_SERIES_PLANNING.md
── 008-V0_2_1_ONCHAIN_HTTP_PLAN.md ── 008-V0_2_1_ONCHAIN_HTTP_PLAN.md
│ └── 009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
├── validation/ ├── validation/
│ ├── 000-README.md │ ├── 000-README.md
│ ├── 001-V0_1_4_CONFIG_DESKTOP.md │ ├── 001-V0_1_4_CONFIG_DESKTOP.md
@@ -62,7 +63,7 @@ D'autres sous-répertoires seront ajoutés uniquement lorsque leur rôle aura é
## Documents de planification ## Documents de planification
Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La release stable `0.2.0` clôt l'audit de bot3 et le découpage de la série. Son plan directeur est conservé comme historique clôturé dans [`plans/007-V0_2_0_SERIES_PLANNING.md`](plans/007-V0_2_0_SERIES_PLANNING.md), avec sa matrice finale [`validation/002-V0_2_0_SERIES_PLANNING.md`](validation/002-V0_2_0_SERIES_PLANNING.md). La release stable `0.2.1 — HTTP Solana foundation` a été ouverte par [`../prompts/006-V0_2_1_START_PROMPT.md`](../prompts/006-V0_2_1_START_PROMPT.md). Son gate de sizing et sa matrice exhaustive sont conservés dans [`plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md), avec la validation finale [`validation/003-V0_2_1_ONCHAIN_HTTP.md`](validation/003-V0_2_1_ONCHAIN_HTTP.md), README/USAGE Transport et le smoke Devnet opt-in de composition Config -> Transport. Le prompt [`../prompts/007-V0_2_2_START_PROMPT.md`](../prompts/007-V0_2_2_START_PROMPT.md) devient le point d'entrée de `0.2.2`. Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La release stable `0.2.0` clôt l'audit de bot3 et le découpage de la série. Son plan directeur est conservé comme historique clôturé dans [`plans/007-V0_2_0_SERIES_PLANNING.md`](plans/007-V0_2_0_SERIES_PLANNING.md), avec sa matrice finale [`validation/002-V0_2_0_SERIES_PLANNING.md`](validation/002-V0_2_0_SERIES_PLANNING.md). La release stable `0.2.1 — HTTP Solana foundation` a été ouverte par [`../prompts/006-V0_2_1_START_PROMPT.md`](../prompts/006-V0_2_1_START_PROMPT.md). Son gate de sizing et sa matrice exhaustive sont conservés dans [`plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md), avec la validation finale [`validation/003-V0_2_1_ONCHAIN_HTTP.md`](validation/003-V0_2_1_ONCHAIN_HTTP.md), README/USAGE Transport et le smoke Devnet opt-in de composition Config -> Transport. Le prompt [`../prompts/007-V0_2_2_START_PROMPT.md`](../prompts/007-V0_2_2_START_PROMPT.md) a ouvert la release stable `0.2.2 — HTTP Accounts + Tokens + Cluster`. Son plan clôturé [`plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) conserve l'audit et l'implémentation des 22 wrappers typés, tandis que [`validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) enregistre les validations déterministes, les graphes Cargo et les deux smokes Devnet passés avant publication. Le prompt [`../prompts/008-V0_2_3_START_PROMPT.md`](../prompts/008-V0_2_3_START_PROMPT.md) ouvre la release suivante `0.2.3 — HTTP Transactions`.
`IDEAS.md` conserve les pistes et questions qui ne sont pas encore des engagements du roadmap ni des décisions architecturales. `IDEAS.md` conserve les pistes et questions qui ne sont pas encore des engagements du roadmap ni des décisions architecturales.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/architecture/004-COMPONENT_INVENTORY.md --> <!-- file: docs/architecture/004-COMPONENT_INVENTORY.md -->
<!-- version: 8 --> <!-- version: 10 -->
# Inventaire initial des composants KSP # Inventaire initial des composants KSP
@@ -23,7 +23,7 @@ Ce document maintient l'inventaire synthétique des composants retenus ou presse
| Logging | `ksp-logging-lib` | lib | Stable | `0.1.2` | façade unique tracing KSP | | Logging | `ksp-logging-lib` | lib | Stable | `0.1.2` | façade unique tracing KSP |
| Config | `ksp-config-lib` | lib | Stable | `0.1.3` | documents, profils, env et persistence Config | | Config | `ksp-config-lib` | lib | Stable | `0.1.3` | documents, profils, env et persistence Config |
| Config Desk | `ksp-app-config-desk` | app | Stable | `0.1.4` | validation/management Config | | Config Desk | `ksp-app-config-desk` | app | Stable | `0.1.4` | validation/management Config |
| On-chain HTTP | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.1` | foundation HTTP, registry 52+14, pools/rôles, 4 canaris | | On-chain HTTP | `ksp-onchain-transport-lib` | lib | Stable | `0.2.1` | foundation HTTP + 26 wrappers typés stables après 0.2.2 |
| Wallet | `ksp-wallet-lib` | lib | Retenu | `0.2.5` | `.kspwallet`, secrets, signature, import/export | | Wallet | `ksp-wallet-lib` | lib | Retenu | `0.2.5` | `.kspwallet`, secrets, signature, import/export |
| Wallet Desk | `ksp-app-wallet-desk` | app | Retenu | `0.2.6` | Wallet + Config composite + HTTP/balance | | Wallet Desk | `ksp-app-wallet-desk` | app | Retenu | `0.2.6` | Wallet + Config composite + HTTP/balance |
| Standard WS | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.7` | WebSocket Solana complet, sessions/subscriptions | | Standard WS | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.7` | WebSocket Solana complet, sessions/subscriptions |
@@ -78,7 +78,7 @@ ksp-data-api
## Transport ## Transport
`ksp-onchain-transport-lib` doit couvrir l'intégralité des opérations documentées de la surface ciblée par chaque release. `0.2.1` stabilise la foundation HTTP et quatre wrappers typés canari ; la couverture typée des 48 autres méthodes courantes reste explicitement répartie sur `0.2.2``0.2.4`. Les statuts deprecated/obsolete encore fonctionnels et unstable/experimental restent exposés avec warning runtime KSP. `ksp-onchain-transport-lib` doit couvrir l'intégralité des opérations documentées de la surface ciblée par chaque release. `0.2.1` stabilise la foundation HTTP et quatre wrappers typés canari. `0.2.2` stabilise 22 wrappers Accounts/Tokens/Cluster supplémentaires, soit 26 wrappers typés au total ; les 26 méthodes Transactions/Blocks/Economics restantes restent affectées à `0.2.3``0.2.4`. Les statuts deprecated/obsolete encore fonctionnels et unstable/experimental restent exposés avec warning runtime KSP.
La Config standard Transport appartient à `ksp-config-lib`, qui adapte vers les settings publics du transport ; le transport ne dépend jamais de Config. La Config standard Transport appartient à `ksp-config-lib`, qui adapte vers les settings publics du transport ; le transport ne dépend jamais de Config.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/000-README.md --> <!-- file: docs/plans/000-README.md -->
<!-- version: 31 --> <!-- version: 34 -->
# Plans KSP # Plans KSP
@@ -17,6 +17,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou
- [`006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](006-V0_1_4_CONFIG_DESKTOP_PLAN.md) — plan historique clôturé de la release stable `0.1.4 — ksp-app-config-desk`, établi par `0.1.4-pre.001` puis consolidé jusqu'à `0.1.4-rel.001`. - [`006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](006-V0_1_4_CONFIG_DESKTOP_PLAN.md) — plan historique clôturé de la release stable `0.1.4 — ksp-app-config-desk`, établi par `0.1.4-pre.001` puis consolidé jusqu'à `0.1.4-rel.001`.
- [`007-V0_2_0_SERIES_PLANNING.md`](007-V0_2_0_SERIES_PLANNING.md) — plan historique clôturé de la release stable `0.2.0`, ouvert par `pre.001`, consolidé par `pre.002`, audité par `pre.003` puis publié par `rel.001`; il fixe l'ordre `0.2.1+`, la stratégie RAW/CORE/DECODE/SPECIALIZED, les vertical slices Program et le prompt `0.2.1`. - [`007-V0_2_0_SERIES_PLANNING.md`](007-V0_2_0_SERIES_PLANNING.md) — plan historique clôturé de la release stable `0.2.0`, ouvert par `pre.001`, consolidé par `pre.002`, audité par `pre.003` puis publié par `rel.001`; il fixe l'ordre `0.2.1+`, la stratégie RAW/CORE/DECODE/SPECIALIZED, les vertical slices Program et le prompt `0.2.1`.
- [`008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — plan de `0.2.1`, établi par `0.2.1-pre.001`, recalibré par `pre.001-fix.001` et amené en clôture candidate par `pre.007`; il conserve l'inventaire 52 méthodes HTTP courantes + 14 Deprecated historiques, le design Transport/Config et le split de couverture typée sur `0.2.1``0.2.4`. - [`008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — plan de `0.2.1`, établi par `0.2.1-pre.001`, recalibré par `pre.001-fix.001` et amené en clôture candidate par `pre.007`; il conserve l'inventaire 52 méthodes HTTP courantes + 14 Deprecated historiques, le design Transport/Config et le split de couverture typée sur `0.2.1``0.2.4`.
- [`009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — plan clôturé de la release stable `0.2.2`, établi par `pre.001`, corrigé après réaudit Agave v4.2.1 puis exécuté jusqu'à `pre.007-fix.002`; il couvre les 22 wrappers Accounts/Tokens/Cluster, le smoke Transport opt-in et la préparation de `0.2.3`.
Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre. Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md --> <!-- file: docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md -->
<!-- version: 33 --> <!-- version: 36 -->
# Séquence des releases fonctionnelles KSP # Séquence des releases fonctionnelles KSP
@@ -390,6 +390,8 @@ Ces trois releases constituent le découpage nominal, pas une obligation de troi
Chaque `pre.001` réaudite la documentation officielle actuelle. Les méthodes Deprecated réellement retirées restent tracées comme historiques/runtime removed au lieu d'être simulées. Chaque `pre.001` réaudite la documentation officielle actuelle. Les méthodes Deprecated réellement retirées restent tracées comme historiques/runtime removed au lieu d'être simulées.
`0.2.2-pre.001` a confirmé la partition de 22 méthodes et son fix documentaire a recoupé les formes wire avec Agave v4.2.1. Les tranches `pre.002``pre.006` ont livré les DTOs puis les 5 Accounts, 5 Tokens et 12 Cluster. `pre.007`, puis `pre.007-fix.001` et `pre.007-fix.002`, ont fermé les canaries exactes 22/22, le smoke Devnet Transport pur, README/USAGE, la matrice de validation `004` et le prompt `0.2.3`. `0.2.2-rel.001` publie cette surface stable après validation du workspace et des deux smokes Devnet. Le smoke cross-crates Config -> Transport de `0.2.1` reste séparé et transitoire.
## `0.2.5` — Wallet foundation ## `0.2.5` — Wallet foundation
Mission : créer `ksp-wallet-lib` et le format `.kspwallet`. Mission : créer `ksp-wallet-lib` et le format `.kspwallet`.

View File

@@ -0,0 +1,576 @@
<!-- file: docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md -->
<!-- version: 7 -->
# Plan `0.2.2` — HTTP Accounts + Tokens + Cluster
## Statut
Ce plan, ouvert par `0.2.2-pre.001` sur la base stable `v0.2.1`, est clôturé par `0.2.2-rel.001` après validation de la candidate `0.2.2-pre.007-fix.002`.
`0.2.1` a déjà stabilisé la foundation HTTP commune : settings, endpoints/pool/rôles, admission et limites, retry, JSON-RPC 2.0,
registry des méthodes, exécution HTTP générique, adapter Config -> Transport et quatre wrappers typés canari.
Cette release ne reconstruit aucun transport par famille. Elle complète uniquement la surface typée des 22 méthodes déjà attribuées à
`HttpRpcCoverageRelease::V0_2_2`.
## Sources normatives réauditées les 2026-08-17 et 2026-08-18
Source documentaire principale :
```text
https://solana.com/docs/rpc/http
```
Références complémentaires officielles utilisées pour les formes wire communes :
```text
https://solana.com/docs/rpc/json-structures
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
```
Les liens `Source` actuellement exposés par les pages RPC Solana consultées pointent encore vers Agave `v3.1.8`. Ce constat documentaire ne
signifie pas que `v3.1.8` soit la source Agave la plus récente à auditer. Le 2026-08-18, KSP recoupe donc ces pages avec le tag Agave `v4.2.1`,
dont le workspace déclare lui-même la version `4.2.1`, afin de détecter les évolutions wire plus récentes que les liens du site Solana.
KSP ne prend pas de dépendance sur ces crates : leurs sources servent seulement à lever les ambiguïtés de la documentation HTTP et à préserver
les champs optionnels/versionnés réellement exposés par Agave `v4.2.1`, plus récent que le snapshot `v3.1.8` encore lié par le site Solana.
## Résultat de l'audit global
L'index HTTP officiel courant contient toujours **52 méthodes**. Pour les familles concernées par cette release :
```text
Accounts : 6 current = 1 acquis en 0.2.1 + 5 ciblés en 0.2.2
Tokens : 5 current = 5 ciblés en 0.2.2
Cluster : 15 current = 3 acquis en 0.2.1 + 12 ciblés en 0.2.2
```
La navigation officielle Deprecated contient toujours exactement les **14 méthodes historiques** déjà enregistrées dans KSP :
```text
confirmTransaction
getConfirmedBlock
getConfirmedBlocks
getConfirmedBlocksWithLimit
getConfirmedSignaturesForAddress2
getConfirmedTransaction
getFeeCalculatorForBlockhash
getFeeRateGovernor
getFees
getRecentBlockhash
getSignatureConfirmation
getSignatureStatus
getSnapshotSlot
getStakeActivation
```
Aucune méthode ajoutée, supprimée ou déplacée n'a été détectée par rapport à l'audit `0.2.1` du 2026-08-17. Les 22 méthodes de cette release
restent donc la partition exacte prévue. Les pages courantes ciblées ne portent pas de marqueur Deprecated ou Unstable ; les descriptors KSP
restent `Stable / Supported / Stable`, `Read`, `RetrySafe`.
## Gate de sizing obligatoire
Question :
```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.
```
Le périmètre ne nécessite ni nouvelle foundation de transport, ni nouvelle crate, ni nouvelle dépendance externe identifiée à `pre.001`.
Les difficultés sont concentrées dans quelques contrats wire, principalement les données de compte, `getProgramAccounts`, `getLeaderSchedule`
et `getVoteAccounts`. Elles peuvent être isolées dans les prereleases prévues sans compresser les tests.
Le sizing reste conditionnel à la règle KSP habituelle : si une tranche dépasse réellement le budget de 1520 minutes, une prerelease
supplémentaire est ajoutée dans `0.2.2`; la release n'est pas artificiellement comprimée et aucune méthode n'est déplacée silencieusement.
## Matrice exacte — Accounts
| Méthode | Paramètres/config confirmés | Résultat typé à préserver | Limites / points significatifs |
|-------------------------------------|-----------------------------------------------------------------|-------------------------------------|---------------------------------------------------------------------------------------------------------|
| `getAccountInfo` | `Pubkey`, config `commitment/encoding/dataSlice/minContextSlot` | `RpcResponse<Option<Account>>` | compte absent = `null`; erreur min-context distincte |
| `getLargestAccounts` | config `commitment/filter/sortResults` | `RpcResponse<Vec<AccountBalance>>` | 20 résultats; filtre `circulating/nonCirculating`; cache provider possible |
| `getMinimumBalanceForRentExemption` | longueur de données, `commitment?` | `u64` lamports | aucune valeur sentinelle inventée |
| `getMultipleAccounts` | `Vec<Pubkey>`, config Account | `RpcResponse<Vec<Option<Account>>>` | maximum documenté 100; ordre des résultats = ordre demandé |
| `getProgramAccounts` | `Pubkey`, config Account + `filters/withContext/sortResults` | résultat bare ou contextualisé | HTTP documente `dataSize`/`memcmp`; Agave accepte aussi `tokenAccountState`; 4 filtres max côté serveur |
### Wire Account commun
Le type `Account` KSP doit rester un DTO de transport, sans décodage Program. Les formes documentées imposent de conserver :
```text
lamports u64
owner Pubkey
executable bool
rentEpoch u64
space Option<u64>
data forme wire discriminée
```
`data` n'est pas modélisable correctement par un simple `String`. Les formes à conserver sont :
- chaîne legacy `binary` lorsque le serveur l'émet ;
- couple `[data, encoding]` pour `base58`, `base64` et `base64+zstd` ;
- objet `jsonParsed` lorsque le parser RPC existe ;
- fallback `[data, "base64"]` même lorsqu'un appel demande `jsonParsed` mais qu'aucun parser n'est disponible.
La valeur `jsonParsed.parsed` reste un `serde_json::Value` au niveau Transport. Aucun modèle SPL/Program n'est introduit ici.
`dataSlice` est un petit DTO `{ offset, length }`. Il n'impose aucun décodage local des données et ne justifie donc pas l'ajout de `base64`
ou `bs58` dans cette release.
### `getProgramAccounts`
Le résultat dépend de `withContext` :
```text
false/absent -> Vec<KeyedAccount>
true -> RpcResponse<Vec<KeyedAccount>>
```
Le contrat public doit refléter cette union au lieu de supprimer le contexte ou d'inventer un contexte lorsque le serveur n'en renvoie pas.
Les filtres KSP doivent distinguer les trois variantes acceptées par la source primaire Agave `v4.2.1` :
```text
DataSize(u64)
Memcmp { offset, bytes, encoding }
TokenAccountState
```
Pour `Memcmp`, `base58` est l'encodage implicite lorsque `encoding` est absent; `base58`, `base64` et les octets bruts sont acceptés par le wire
courant, tandis que l'ancien libellé `binary` n'est plus accepté. La donnée comparée est limitée à 128 octets après décodage. Agave expose en
outre `MAX_GET_PROGRAM_ACCOUNT_FILTERS = 4`, même si la page HTTP actuelle ne publie pas cette cardinalité. Le plan retient donc **4 filtres max
comme limite runtime/source primaire à préserver**, en la distinguant explicitement d'une limite publiée sur la page HTTP.
Cette connaissance n'impose pas l'ajout immédiat de `base64` ou `bs58` à KSP : le DTO peut préserver les chaînes encodées sans les décoder.
Une validation locale de la taille décodée ne sera ajoutée que si elle apporte une valeur claire et justifie la dépendance; le wrapper doit en
revanche empêcher plus de quatre filtres, car cette requête serait refusée par le serveur actuel.
## Matrice exacte — Tokens
| Méthode | Paramètres/config confirmés | Résultat typé à préserver | Limites / points significatifs |
|------------------------------|-----------------------------------------------------------------------|-----------------------------------------|-------------------------------------------------------------------|
| `getTokenAccountBalance` | token account `Pubkey`, `commitment?` | `RpcResponse<TokenAmount>` | erreur RPC si le compte n'est pas exploitable comme token account |
| `getTokenAccountsByDelegate` | delegate `Pubkey`, selector `{mint}` ou `{programId}`, config Account | `RpcResponse<Vec<KeyedAccount>>` | selector exclusif par construction |
| `getTokenAccountsByOwner` | owner `Pubkey`, selector `{mint}` ou `{programId}`, config Account | `RpcResponse<Vec<KeyedAccount>>` | selector exclusif par construction |
| `getTokenLargestAccounts` | mint `Pubkey`, `commitment?` | `RpcResponse<Vec<TokenAccountBalance>>` | 20 plus gros comptes du mint |
| `getTokenSupply` | mint `Pubkey`, `commitment?` | `RpcResponse<TokenAmount>` | aucune conversion métier SPL |
Le selector commun doit rendre impossible la production accidentelle d'un objet contenant simultanément `mint` et `programId`, par exemple avec
un enum KSP `Mint(Pubkey) | ProgramId(Pubkey)` sérialisé vers la forme RPC attendue.
Le `TokenAmount` wire commun conserve :
```text
amount String
decimals u8
uiAmount Option<f64>
uiAmountString String
```
`uiAmount` est nullable dans les structures JSON officielles ; KSP ne doit pas le remplacer par `0.0`.
Les deux méthodes de liste réutilisent le DTO Account générique. `jsonParsed` peut contenir des structures SPL, mais Transport les conserve en
JSON sans les convertir en modèle Token métier.
## Matrice exacte — Cluster
| Méthode | Paramètres/config confirmés | Résultat typé à préserver | Limites / points significatifs |
|--------------------------|------------------------------------|---------------------------|---------------------------------------------------------------------------------------------|
| `getClusterNodes` | aucun | `Vec<ClusterNode>` | nombreux endpoints/champs optionnels |
| `getEpochInfo` | config `commitment/minContextSlot` | `EpochInfo` | `transactionCount` nullable |
| `getEpochSchedule` | aucun | `EpochSchedule` | structure fixe d'epoch schedule |
| `getHighestSnapshotSlot` | aucun | `SnapshotSlotInfo` | `incremental` nullable; absence de snapshot = erreur RPC |
| `getIdentity` | aucun | identity `Pubkey` | objet `{identity}` sur le wire |
| `getLeaderSchedule` | slot/config overload | `Option<LeaderSchedule>` | forme des paramètres particulière; résultat nullable |
| `getMaxRetransmitSlot` | aucun | `u64` | lecture simple |
| `getMaxShredInsertSlot` | aucun | `u64` | lecture simple |
| `getSlot` | config `commitment/minContextSlot` | `u64` | erreur min-context possible |
| `getSlotLeader` | config `commitment/minContextSlot` | leader `Pubkey` | erreur min-context possible |
| `getSlotLeaders` | `startSlot`, `limit` | `Vec<Pubkey>` | limite documentée `1..=5000`; ordre par slot |
| `getVoteAccounts` | config vote accounts | `VoteAccountStatus` | `current` + `delinquent`; Agave borne actuellement `epochCredits` à 5 entrées par validator |
### `ClusterNode`
La surface courante documente les champs suivants :
```text
pubkey Pubkey
featureSet Option<u32>
gossip Option<String>
pubsub Option<String>
rpc Option<String>
serveRepair Option<String>
shredVersion Option<u16>
tpu Option<String>
tpuForwards Option<String>
tpuForwardsQuic Option<String>
tpuQuic Option<String>
tpuVote Option<String>
tvu Option<String>
version Option<String>
clientId Option<String>
```
Même si Agave `v4.2.1` utilise `SocketAddr` en interne pour plusieurs endpoints, le DTO public Transport ne doit pas imposer davantage que
le contrat JSON. Les adresses réseau restent donc des chaînes wire optionnelles à moins qu'un invariant officiel plus strict soit nécessaire.
La page Solana courante ne liste pas encore `clientId`, mais Agave `v4.2.1` expose `client_id: Option<String>` dans `RpcContactInfo`. KSP doit donc
accepter et préserver ce champ optionnel sans le rendre obligatoire, afin de rester compatible avec les noeuds/providers qui l'émettent comme
avec ceux qui suivent encore la forme documentée sans ce champ.
### `EpochInfo` et `EpochSchedule`
`EpochInfo` conserve :
```text
absoluteSlot u64
blockHeight u64
epoch u64
slotIndex u64
slotsInEpoch u64
transactionCount Option<u64>
```
`EpochSchedule` conserve :
```text
firstNormalEpoch u64
firstNormalSlot u64
leaderScheduleSlotOffset u64
slotsPerEpoch u64
warmup bool
```
### `getHighestSnapshotSlot`
La page officielle possède les états « Snapshot / No Snapshot ». La source Agave reliée par cette page confirme qu'un noeud sans configuration
snapshot ou sans full snapshot renvoie `RpcCustomError::NoSnapshot`. KSP conserve cette situation comme erreur JSON-RPC applicative ; il ne la
convertit pas en `{ full: 0, incremental: null }`.
### `getLeaderSchedule`
La forme actuelle doit être respectée exactement :
```text
paramètre 1 optionnel : slot u64 | config object | null
paramètre 2 optionnel : config object lorsque le premier paramètre est slot/null
config : commitment + identity
résultat : map identity -> indices de slots relatifs au début de l'epoch, ou null
```
Une API Rust typée doit empêcher les combinaisons incohérentes plutôt que demander à l'appelant de construire manuellement ce tableau JSON.
### `getVoteAccounts`
La config actuelle contient :
```text
commitment
votePubkey
keepUnstakedDelinquents
delinquentSlotDistance
```
Chaque record conserve au minimum :
```text
votePubkey Pubkey
nodePubkey Pubkey
activatedStake u64
commission u8
inflationRewardsCommissionBps Option<u16>
epochVoteAccount bool
epochCredits Vec<(epoch u64, credits u64, previousCredits u64)>
lastVote u64
rootSlot u64
```
Un petit DTO nommé pour une entrée `epochCredits` est préférable à exposer un tuple public opaque, tout en conservant la forme tableau du wire
au décodage. La source Agave `v4.2.1` limite la réponse à cinq entrées d'historique `epochCredits` par validator; KSP ne doit pas supposer que
l'historique complet d'un vote account est exposé par cette méthode RPC.
Agave `v4.2.1` ajoute également `inflationRewardsCommissionBps`, sérialisé comme champ optionnel. La source précise que ce champ vaut `None` pour
un noeud antérieur à son introduction. Le DTO KSP doit donc le conserver en `Option<u16>` et ne jamais le déduire artificiellement de
`commission`.
## DTOs communs et organisation prévue
`pre.002` doit introduire des types partagés ciblés, pas un « mega DTO ». Le découpage prévu est conceptuellement :
```text
RPC context / commitment
account encoding + data slice + account wire
keyed account
program-account filters
token selector + token amount
cluster epoch/schedule/node/snapshot/leader/vote structures
```
`SolanaCommitment` et `SolanaRpcContext`, actuellement nés avec les canaris `0.2.1`, doivent être mutualisés sans casser leur réexport public.
Les wrappers continueront à utiliser des structs wire privés `serde` puis une conversion explicite vers les DTOs publics KSP, notamment pour
valider et convertir les chaînes de pubkey vers `ksp_core_lib::Pubkey`.
Aucune activation anticipée d'une feature `serde` sur `solana-pubkey` n'est nécessaire : la foundation actuelle sait déjà sérialiser une Pubkey
par sa représentation texte, et les réponses peuvent être décodées par wire structs privés avant conversion.
## Architecture d'exécution imposée
Tous les wrappers suivent :
```text
wrapper typé
-> descriptor central
-> execute_standard_rpc
-> pool/admission
-> reqwest HTTP
-> validation JSON-RPC
-> decode typé
```
Sont explicitement interdits :
- client HTTP parallèle par famille ;
- appel `reqwest` direct dans un wrapper ;
- bypass deadline/admission/retry ;
- dépendance Transport -> Config/Store/Program ;
- import direct de `tracing` ;
- conversion des accounts en modèles Program/SPL métier.
## Erreurs, retry et sécurité
Les 22 descriptors sont réaudités comme lectures et restent `RetrySafe`. Aucun `WriteSubmission` n'entre dans `0.2.2`.
Les règles `0.2.1` sont inchangées :
- erreur JSON-RPC applicative distincte des erreurs transport ;
- `reqwest::Error::without_url()` avant exposition comme source ;
- aucune URL/provider credential/body complet dans diagnostics ou logs ordinaires ;
- retry central uniquement selon descriptor/policy ;
- unique hiérarchie d'erreur via `ksp_core_lib::Error`.
Les validations de paramètres KSP sont ajoutées seulement lorsqu'elles sont normatives et utiles avant I/O, notamment :
```text
getMultipleAccounts : <= 100 pubkeys
getProgramAccounts : <= 4 filtres (limite runtime Agave courante)
getSlotLeaders : 1..=5000
Token selector : exactement mint OU programId
```
`minContextSlot` peut produire l'erreur RPC dédiée `MinContextSlotNotReached`; le wrapper ne doit pas la transformer en absence de données.
## Logging
Aucun événement par méthode n'est ajouté par défaut. Les événements génériques de Transport restent la source d'observabilité : méthode,
rôle, endpoint logique, tentative et statut technique, avec metadata sûre.
Toute instrumentation temporaire `debug` ajoutée pendant les prereleases d'implémentation doit revenir à la baseline `info`/`warn` lors de la
tranche finale.
## Config
L'audit de `std.transport.json`, de son schema et de `ksp-config-lib/src/transport.rs` ne révèle aucune capacité manquante nécessaire aux 22
wrappers. Aucun changement Config n'est prévu dans `pre.001`.
La direction reste strictement :
```text
ksp-config-lib -> ksp-onchain-transport-lib
```
Transport ne lit ni `.env`, ni `std::env`.
## Dépendances
Aucune nouvelle dépendance externe n'est requise par le design retenu à `pre.001`.
En particulier, `base64` et `bs58` ne sont pas nécessaires pour représenter les chaînes encodées du wire. Elles ne seront ajoutées que si une
capacité publique réelle de décodage/validation binaire est décidée ultérieurement, ce qui n'est pas un objectif de `0.2.2`.
Aucune crate SPL, `solana-client`, SDK RPC haut niveau ou `ksp-interface-lib` n'est introduit pour cette surface.
## Stratégie de tests
Chaque wrapper doit disposer de fixtures déterministes et d'un serveur HTTP local couvrant au minimum :
- sérialisation exacte de la request ;
- réponse success typée ;
- `null`/option pertinent ;
- config/overload pertinent ;
- erreur JSON-RPC significative ;
- cardinalité/selector lorsqu'un invariant est documenté.
Cas transversaux prioritaires :
- toutes les variantes Account data réellement supportées ;
- fallback `jsonParsed -> base64` ;
- `space: null` ;
- `getAccountInfo` et `getMultipleAccounts` avec comptes absents ;
- `getProgramAccounts` bare vs `withContext` ;
- `TokenAmount.uiAmount: null` ;
- fields optionnels de `getClusterNodes`, dont présence/absence de `clientId` ;
- `EpochInfo.transactionCount: null` ;
- `SnapshotSlotInfo.incremental: null` et erreur NoSnapshot ;
- `getLeaderSchedule: null` + overloads ;
- `getVoteAccounts` current/delinquent, epoch credits et présence/absence de `inflationRewardsCommissionBps` ;
- limites 100, 4 filtres Program Accounts et 5000; variantes/encodages `memcmp`.
Les canaries de release doivent maintenir :
```text
current methods == 52 exactes
historical methods == 14 exactes
coverage partition == 4 / 22 / 11 / 15
0.2.1 typed canaries == getBalance/getGenesisHash/getHealth/getVersion, inchangés
0.2.2 exact set == les 22 méthodes de ce plan
0.2.3/0.2.4 != typed-complete prématurément
```
Un smoke Devnet opt-in pourra couvrir un sous-ensemble représentatif, sans jamais remplacer les fixtures locales.
## Prévision souple des prereleases
| Tranche | Objectif |
|-----------|--------------------------------------------------------------------------------------------------------------------|
| `pre.001` | audit officiel actuel, matrice exacte, architecture DTO, dépendances et sizing |
| `pre.002` | primitives/configs/results communs Account/Token/Cluster + fixtures de base; mutualiser Context/Commitment |
| `pre.003` | 5 wrappers Accounts + tests déterministes |
| `pre.004` | 5 wrappers Tokens + tests déterministes |
| `pre.005` | Cluster simple : nodes, epoch info/schedule, snapshot, identity, max retransmit, max shred + tests |
| `pre.006` | Cluster schedule/slot/vote : leader schedule, slot, slot leader(s), vote accounts + tests |
| `pre.007` | canaries de complétude, smoke opt-in si utile, README/USAGE, validation finale, prompt `0.2.3`, préparation stable |
Le découpage est volontairement asymétrique : `pre.006` contient moins de méthodes mais les formes les plus complexes. Toute tranche qui dépasse
le budget est scindée en une prerelease supplémentaire plutôt que compressée.
## État final publié
Les tranches fonctionnelles ont livré la surface prévue sans nouvelle dépendance externe :
```text
pre.002 : primitives/configs/results communs
pre.003 : 5 Accounts
pre.004 : 5 Tokens
pre.005 : 7 Cluster simples
pre.006 : 5 Cluster restants
pre.007 : clôture candidate
```
La surface typed stable est donc :
```text
0.2.1 : 4 wrappers foundation
0.2.2 : 22 wrappers Accounts/Tokens/Cluster
total : 26 wrappers typed
future : 11 Transactions + 15 Blocks/Economics
```
Le réaudit final du 2026-08-18 de `https://solana.com/docs/rpc/http` confirme toujours les 52 méthodes HTTP courantes et la même répartition des familles. La navigation Deprecated conserve les 14 méthodes historiques déjà enregistrées. Aucune méthode de `0.2.2` n'est déplacée ou reclassée.
Un smoke Devnet opt-in **propre à Transport** est ajouté sous `crates/ksp-onchain-transport-lib/tests/transport_devnet_smoke.rs`. Il construit ses settings programmatiquement et exerce `getAccountInfo`, `getTokenAccountsByOwner`, `getEpochInfo` et `getVoteAccounts`. La branche Token suit la forme Devnet documentée avec un owner Pubkey ordinaire, le programme SPL Token canonique comme selector `programId`, et une config explicite `finalized/jsonParsed`; elle n'exige aucun mint/token account Devnet persistant. Le smoke historique `Config -> Transport` reste distinct et transitoirement hébergé dans Config. Les fixtures HTTP locales restent les preuves reproductibles par défaut.
`pre.007-fix.001` corrige la première exécution opérateur de ce smoke : le mint repris de l'exemple officiel `getTokenSupply` a renvoyé RPC `-32602` sur Devnet, et deux `expect()` dans le helper de construction violaient le gate Clippy. Le nouveau chemin Token évite cette dépendance d'état externe et le helper retourne explicitement le `Result` de construction.
`pre.007-fix.002` corrige la deuxième exécution opérateur : le scénario `getTokenAccountsByOwner(System Program, { programId })` sans troisième paramètre a renvoyé RPC `-32600` sur le RPC public Devnet, alors que les validations déterministes et le smoke Config -> Transport restaient verts. Le smoke adopte donc la forme complète de l'exemple RPC public (`owner` ordinaire + `{ programId }` + config `finalized/jsonParsed`) sans modifier le contrat optionnel du wrapper.
La matrice durable de clôture est `docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md` et le prompt suivant est `prompts/008-V0_2_3_START_PROMPT.md`. `0.2.2-rel.001` publie la release stable et ajoute son entrée au `CHANGELOG.md`.
## Validation de clôture observée
La candidate finale `0.2.2-pre.007-fix.002` a été validée par l'opérateur le 2026-08-18 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK, sans warning
cargo test -p ksp-onchain-transport-lib OK
cargo test --workspace OK
smoke Transport pur Devnet 1 passed
smoke Config -> Transport -> Devnet 1 passed
```
La suite Transport confirme :
```text
127 unit tests
13 public API tests
7 release completeness tests
1 smoke Transport ignored par défaut
```
Les graphes Cargo Transport et Config avaient été inspectés pendant la candidate avant les fixes du smoke. `pre.007-fix.001` et `pre.007-fix.002` ne modifient aucune dépendance ni feature ; ils changent uniquement le scénario live, sa documentation et le signal de version. La frontière reste donc :
```text
ksp-config-lib -> ksp-onchain-transport-lib
ksp-onchain-transport-lib -X-> ksp-config-lib
Transport -X-> Store/Program/tracing direct
```
Les doublons visibles sur le graphe inspecté restent limités aux générations `syn` 2.x/3.x de chaînes transitive/proc-macro ; aucune seconde stack HTTP/Tokio KSP n'est introduite.
## Hors périmètre
Sont exclus de `0.2.2` :
- les 11 méthodes Transactions de `0.2.3` ;
- les 15 méthodes Blocks + Economics de `0.2.4` ;
- WebSocket, Yellowstone, provider-specific streams ;
- décodage SPL/Program ;
- Store/materialization ;
- `ksp-interface-lib` anticipé ;
- modification de Config sans besoin concret ;
- ajout de dépendances de décodage pour simple représentation wire.
## Validations de référence
Pendant le développement :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
À la clôture :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test -p ksp-config-lib
cargo test -p ksp-core-lib
cargo test --workspace
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib -d
cargo tree -p ksp-onchain-transport-lib -e features
cargo tree -p ksp-onchain-transport-lib -e normal
```
## Critères de clôture `0.2.2`
Les critères suivants sont satisfaits par `0.2.2-rel.001` :
- la matrice officielle est réauditée une dernière fois ;
- les 22 méthodes de ce plan ont chacune un wrapper public typé et leurs tests ;
- les quatre canaris `0.2.1` restent inchangés ;
- la partition globale ne perd aucune méthode future ;
- les DTOs conservent les `null`/options documentés ;
- les frontières de dépendances, redaction, retry et logging restent conformes ;
- README/USAGE et validation durable sont synchronisés ;
- les commandes Cargo finales réellement exécutables passent ;
- le prompt `0.2.3 — HTTP Transactions` est prêt ;
- `rel.001` ne contient aucun développement fonctionnel nouveau.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/000-README.md --> <!-- file: docs/validation/000-README.md -->
<!-- version: 4 --> <!-- version: 6 -->
# Validations KSP # Validations KSP
@@ -12,3 +12,4 @@ Documents :
- [`001-V0_1_4_CONFIG_DESKTOP.md`](001-V0_1_4_CONFIG_DESKTOP.md) — matrice finale de `0.1.4 — ksp-app-config-desk`. - [`001-V0_1_4_CONFIG_DESKTOP.md`](001-V0_1_4_CONFIG_DESKTOP.md) — matrice finale de `0.1.4 — ksp-app-config-desk`.
- [`002-V0_2_0_SERIES_PLANNING.md`](002-V0_2_0_SERIES_PLANNING.md) — matrice finale de la release stable `0.2.0`, avec audit de cohérence et preuves opérateur de `pre.003`. - [`002-V0_2_0_SERIES_PLANNING.md`](002-V0_2_0_SERIES_PLANNING.md) — matrice finale de la release stable `0.2.0`, avec audit de cohérence et preuves opérateur de `pre.003`.
- [`003-V0_2_1_ONCHAIN_HTTP.md`](003-V0_2_1_ONCHAIN_HTTP.md) — matrice de clôture de `0.2.1 — HTTP Solana foundation`, registry 52+14, résilience, Config -> Transport, quatre canaris et smoke Devnet opt-in. - [`003-V0_2_1_ONCHAIN_HTTP.md`](003-V0_2_1_ONCHAIN_HTTP.md) — matrice de clôture de `0.2.1 — HTTP Solana foundation`, registry 52+14, résilience, Config -> Transport, quatre canaris et smoke Devnet opt-in.
- [`004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) — matrice finale validée de `0.2.2`, 22 wrappers Accounts/Tokens/Cluster, canaries 52+14, graphes Cargo, smoke Devnet Transport pur, smoke historique Config -> Transport et préparation de `0.2.3`.

View File

@@ -0,0 +1,263 @@
<!-- file: docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md -->
<!-- version: 4 -->
# Validation finale `0.2.2` — HTTP Accounts + Tokens + Cluster
## Statut
Cette matrice clôt la release stable `0.2.2`. Les preuves opérateur finales ont été enregistrées sur la candidate corrigée `0.2.2-pre.007-fix.002` avant `0.2.2-rel.001`.
Base fonctionnelle validée avant `pre.007` :
```text
v0.2.2-pre.006-fix.001
```
L'opérateur a validé cette base le 2026-08-18 avec :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Résultats Transport communiqués :
```text
127 unit tests passed
13 public API tests passed
6 release completeness passed
0 warning clippy signalé
```
## Réaudit officiel final
Le 2026-08-18, l'index officiel courant a été revérifié :
```text
https://solana.com/docs/rpc/http
https://solana.com/docs/rpc/deprecated/confirmtransaction
```
La matrice reste :
```text
52 méthodes HTTP courantes
14 méthodes historiques Deprecated
```
Répartition courante utile à cette release :
```text
Accounts : 6 = getBalance acquis en 0.2.1 + 5 en 0.2.2
Tokens : 5 = 5 en 0.2.2
Cluster : 15 = getGenesisHash/getHealth/getVersion acquis en 0.2.1 + 12 en 0.2.2
```
Aucune méthode `0.2.2` n'est reclassée Deprecated/Unstable. Les 22 descriptors restent `Stable / Supported / Read / RetrySafe`.
## Surface typée stable
`0.2.2` ajoute exactement :
```text
Accounts : 5
Tokens : 5
Cluster : 12
Total : 22
```
Avec les quatre canaris de `0.2.1`, Transport expose donc 26 wrappers typed dans la release stable.
Les partitions futures restent intactes :
```text
0.2.3 Transactions : 11
0.2.4 Blocks + Economics : 15
```
L'API raw `execute_standard_rpc()` reste disponible mais ne compte pas comme couverture typed d'une méthode future.
## Contrats wire à préserver
La release stable conserve notamment :
- `Account.data` legacy / encoded / `jsonParsed` sans décodage Program ;
- `Account.space: Option<u64>` ;
- comptes absents sous `null` ;
- `getProgramAccounts` bare vs contextualisé ;
- `TokenAmount.amount` et `uiAmountString` comme chaînes exactes ;
- `TokenAmount.uiAmount: Option<f64>` ;
- selector Token exclusif `Mint | ProgramId` ;
- `ClusterNode.clientId: Option<String>` ;
- `EpochInfo.transactionCount: Option<u64>` ;
- `SnapshotSlotInfo.incremental: Option<u64>` ;
- `getLeaderSchedule -> Option<schedule>` ;
- `VoteAccountInfo.inflationRewardsCommissionBps: Option<u16>` ;
- triplets `epochCredits` sans interprétation métier.
Limites locales figées :
```text
getMultipleAccounts <= 100
getProgramAccounts <= 4 filtres
memcmp raw <= 128 octets
getSlotLeaders 1..=5000
```
## Canaries de complétude
`tests/release_completeness.rs` protège :
```text
current == 52
historical == 14
partition == 4 / 22 / 11 / 15
0.2.1 exact == 4 canaris foundation
0.2.2 exact == 22 Accounts/Tokens/Cluster
0.2.3 count == 11 sans avancement typed prématuré
0.2.4 count == 15 sans avancement typed prématuré
historical => Deprecated + Removed + Historical + NotApplicable
```
La suite stable confirmée est :
```text
127 unit tests
13 public API tests
7 release completeness tests
1 smoke Transport live ignored par défaut
```
## Smoke Devnet Transport pur
Nouveau test :
```text
crates/ksp-onchain-transport-lib/tests/transport_devnet_smoke.rs
```
Il construit `HttpTransportSettings` programmatiquement, sans Config ni environnement :
```text
https://api.devnet.solana.com
-> getAccountInfo(System Program)
-> getTokenAccountsByOwner(owner ordinaire documenté, selector programId = SPL Token, finalized/jsonParsed)
-> getEpochInfo
-> getVoteAccounts
```
Exécution explicite :
```bash
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
```
Le test est `ignored` par défaut. Sa branche Token ne dépend plus d'un mint d'exemple : elle utilise un owner Pubkey ordinaire de l'exemple RPC public, le selector `programId` canonique et une config explicite `finalized/jsonParsed`. Elle accepte naturellement une liste vide et vérifie seulement qu'un appel Token structurellement documenté atteint Devnet. Un incident réseau/rate-limit doit être analysé séparément ; le smoke ne remplace jamais les fixtures HTTP locales.
Correction `pre.007-fix.001` : lors de la première passe opérateur de `pre.007`, `getTokenSupply` sur le mint d'exemple de la documentation a échoué avec le code RPC `-32602`. Le smoke Config -> Transport, lui, a réussi. La branche Token est donc volontairement passée à `getTokenAccountsByOwner` avec selector `programId`, qui ne requiert aucun mint mutable pour prouver la traversée live du wrapper Token.
Correction `pre.007-fix.002` : lors de la passe suivante, `getTokenAccountsByOwner(System Program, { programId })` sans config explicite a échoué avec RPC `-32600`, tandis que `fmt`, `check`, `clippy`, les tests Transport, le workspace et le smoke Config -> Transport étaient verts. Le smoke Transport utilise désormais la forme complète documentée `owner ordinaire + { programId } + { commitment: finalized, encoding: jsonParsed }`. Ce changement reste limité au scénario live et ne redéfinit pas l'optionalité de la config dans le wrapper.
## Smoke de composition Config -> Transport
Le smoke historique `0.2.1` reste disponible :
```bash
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
```
Il valide `Config -> profil devnet_public -> Transport -> quatre canaris foundation`.
Ownership : ce smoke cross-crates est transitoire. `ksp-config-lib` ne doit pas devenir la destination générale des smokes `Config + autre crate`; il migrera vers une future surface d'intégration/orchestration/demo appropriée.
## Dépendances, sécurité et logging
La release stable n'introduit :
```text
aucune dépendance externe nouvelle
aucune dépendance Transport -> Config/Store/Program
aucun tracing direct hors ksp-logging-lib
aucun client HTTP parallèle
```
Les invariants de sécurité `0.2.1` restent obligatoires : URL/provider credentials absents des diagnostics ordinaires, `reqwest::Error::without_url()` avant exposition comme source, aucune réponse/body massif journalisé par défaut.
La baseline Logging Transport reste `info` dans la publication stable.
## Documentation stable
Sont synchronisés :
```text
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
docs/architecture/004-COMPONENT_INVENTORY.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
ROADMAP.md
prompts/008-V0_2_3_START_PROMPT.md
```
`0.2.2-rel.001` ajoute l'entrée stable au `CHANGELOG.md` sans modifier la surface fonctionnelle.
## Validations finales opérateur observées
Sur `0.2.2-pre.007-fix.002`, l'opérateur a exécuté le 2026-08-18 :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
```
Résultats :
```text
fmt/check/clippy : succès, aucun warning Clippy
Transport unit : 127 passed
Transport public : 13 passed
release canaries : 7 passed
workspace : succès
Transport smoke : 1 passed; 0 failed
Config smoke : 1 passed; 0 failed
```
Les suites normales conservent les smokes réseau sous `#[ignore]`; leur exécution explicite ci-dessus constitue la preuve live de clôture.
Les graphes suivants ont été inspectés pendant la candidate :
```bash
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib -d
cargo tree -p ksp-onchain-transport-lib -e features
cargo tree -p ksp-onchain-transport-lib -e normal
cargo tree -p ksp-config-lib
cargo tree -p ksp-config-lib -d
cargo tree -p ksp-config-lib -e features
cargo tree -p ksp-config-lib -e normal
```
Les fixes `pre.007-fix.001` et `pre.007-fix.002` n'ajoutent ni dépendance ni feature. Le graphe reste conforme au firewall : Config peut dépendre de Transport, Transport ne dépend pas de Config/Store/Program et n'importe pas `tracing` directement. Les doublons constatés sur les graphes inspectés restent ceux de `syn` 2.x/3.x dans les chaînes transitive/proc-macro ; aucune stack HTTP/Tokio KSP concurrente n'est introduite.
## Publication `rel.001`
`0.2.2-rel.001` reste strictement publicationnelle :
```text
workspace.package.version -> 0.2.2
ROADMAP : 0.2.2 -> [X]
CHANGELOG : synthèse stable 0.2.2
plan 009 / validation 004 : clôturés avec preuves opérateur
deltas/0.2.2/rel.001.md
commit v0.2.2-rel.001
tag stable v0.2.2 après validation du commit
```
Aucune nouvelle méthode HTTP, aucun nouveau DTO et aucun nouveau comportement runtime n'apparaissent dans `rel.001`.

View File

@@ -1,5 +1,5 @@
<!-- file: prompts/000-README.md --> <!-- file: prompts/000-README.md -->
<!-- version: 11 --> <!-- version: 12 -->
# Prompts KSP # Prompts KSP
@@ -28,3 +28,4 @@ Le prompt générique `0.1.x` a été affiné pendant `0.0.3` puis remplacé par
- [`005-V0_2_0_START_PROMPT.md`](005-V0_2_0_START_PROMPT.md) — prompt de reprise préparé à la clôture de `0.1.4`; il ouvre `0.2.0-pre.001`, release intermédiaire d'audit de `khadhroony-bot3`, de comparaison avec KSP et de planification/découpage du reste de `0.2.x` ; - [`005-V0_2_0_START_PROMPT.md`](005-V0_2_0_START_PROMPT.md) — prompt de reprise préparé à la clôture de `0.1.4`; il ouvre `0.2.0-pre.001`, release intermédiaire d'audit de `khadhroony-bot3`, de comparaison avec KSP et de planification/découpage du reste de `0.2.x` ;
- [`006-V0_2_1_START_PROMPT.md`](006-V0_2_1_START_PROMPT.md) — prompt finalisé par `0.2.0-pre.003`, destiné à ouvrir `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation` après publication stable de `0.2.0`; il impose l'audit exhaustif des surfaces HTTP courantes et deprecated/unstable officiellement documentées, la séparation Config/Transport, les pools/rôles et le gate de sizing « une release = une session » ; - [`006-V0_2_1_START_PROMPT.md`](006-V0_2_1_START_PROMPT.md) — prompt finalisé par `0.2.0-pre.003`, destiné à ouvrir `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation` après publication stable de `0.2.0`; il impose l'audit exhaustif des surfaces HTTP courantes et deprecated/unstable officiellement documentées, la séparation Config/Transport, les pools/rôles et le gate de sizing « une release = une session » ;
- [`007-V0_2_2_START_PROMPT.md`](007-V0_2_2_START_PROMPT.md) — prompt préparé par la dernière prerelease de `0.2.1`, destiné à ouvrir `0.2.2 — HTTP Accounts + Tokens + Cluster` après publication stable de `0.2.1`; il cible les 22 wrappers typés restants de ces familles et impose un nouvel audit officiel/gate de sizing à `pre.001`. - [`007-V0_2_2_START_PROMPT.md`](007-V0_2_2_START_PROMPT.md) — prompt préparé par la dernière prerelease de `0.2.1`, destiné à ouvrir `0.2.2 — HTTP Accounts + Tokens + Cluster` après publication stable de `0.2.1`; il cible les 22 wrappers typés restants de ces familles et impose un nouvel audit officiel/gate de sizing à `pre.001`.
- [`008-V0_2_3_START_PROMPT.md`](008-V0_2_3_START_PROMPT.md) — prompt préparé par la dernière prerelease de `0.2.2`, destiné à ouvrir `0.2.3 — HTTP Transactions` après publication stable de `0.2.2`; il cible les 11 méthodes Transactions et impose un audit actuel ainsi que la politique no-resend des write submissions.

View File

@@ -0,0 +1,268 @@
<!-- file: prompts/008-V0_2_3_START_PROMPT.md -->
<!-- version: 1 -->
# Prompt de démarrage `0.2.3` — HTTP Transactions
## 1. Contexte de reprise
La base attendue est la release stable :
```text
v0.2.2
```
`0.2.1` a stabilisé la foundation HTTP Solana et quatre canaris typed. `0.2.2` complète Accounts/Tokens/Cluster avec 22 wrappers supplémentaires, un smoke Devnet Transport pur, des DTOs wire communs et les canaries de clôture.
La surface acquise au démarrage de `0.2.3` doit donc être :
```text
52 méthodes HTTP courantes enregistrées
14 méthodes historiques Deprecated / runtime Removed
26 wrappers typed : 4 foundation + 22 Accounts/Tokens/Cluster
partition restante : 11 Transactions + 15 Blocks/Economics
Transport -X-> Config/Store/Program/tracing direct
Config -> Transport autorisé
```
La release à ouvrir est :
```text
0.2.3 — HTTP Transactions
```
La première tranche est `0.2.3-pre.001` et commence par **audit officiel actuel + brainstorming + sizing** avant toute implémentation lourde.
## 2. Mission
Compléter les 11 méthodes Transactions affectées à `HttpRpcCoverageRelease::V0_2_3` :
```text
getFeeForMessage
getLatestBlockhash
getRecentPrioritizationFees
getSignaturesForAddress
getSignatureStatuses
getTransaction
getTransactionCount
isBlockhashValid
requestAirdrop
sendTransaction
simulateTransaction
```
Cette liste est la partition KSP actuelle. `pre.001` doit la revérifier contre la documentation Solana du jour avant de confirmer le périmètre.
## 3. Classification de sécurité à préserver
La metadata KSP actuelle distingue :
```text
8 Read / RetrySafe
2 WriteSubmission / NeverAfterDispatch : requestAirdrop, sendTransaction
1 Simulation / RetrySafe : simulateTransaction
```
`pre.001` doit vérifier que cette classification reste correcte. La règle la plus importante est :
> aucune resoumission automatique d'une WriteSubmission après un dispatch ambigu.
Un timeout après dispatch, une rupture de connexion après envoi ou toute autre issue où KSP ne peut pas prouver l'absence de dispatch doit arrêter le retry automatique.
## 4. Sources internes obligatoires
Relire avant modification :
```text
ROADMAP.md
CHANGELOG.md
RULES.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md
docs/architecture/002-LAYERS_AND_DEPENDENCIES.md
docs/architecture/003-COMPONENT_CONTRACTS.md
docs/architecture/004-COMPONENT_INVENTORY.md
docs/architecture/005-DEPENDENCY_GRAPH.md
docs/rules/RULES_DEPENDENCIES.md
docs/rules/RULES_RUST.md
docs/rules/RULES_KSP.md
docs/rules/FILE_CONTRACTS.md
docs/rules/VERSION_WORKFLOW.md
docs/rules/PROMPT_STRUCTURE.md
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
crates/ksp-onchain-transport-lib/src/
crates/ksp-onchain-transport-lib/unit_tests/
crates/ksp-onchain-transport-lib/tests/
```
Les anciens deltas sont historiques et ne doivent pas être réécrits.
## 5. Sources externes normatives
Au début de `pre.001`, réauditer :
```text
https://solana.com/docs/rpc/http
https://solana.com/docs/rpc/json-structures
```
Et les sources primaires Agave actuelles lorsque la documentation HTTP laisse une ambiguïté sur les configs, limites ou champs wire.
Pour chaque méthode Transaction vérifier :
- paramètres et ordre exacts ;
- commitments/minContextSlot ;
- encodings actuels et formes legacy ;
- cardinalités ;
- null/optional ;
- structures de signature/status/transaction/meta ;
- `maxSupportedTransactionVersion` ;
- erreurs significatives ;
- statut stable/deprecated/unstable ;
- limites provider/runtime utiles avant I/O.
`getTransaction` doit notamment conserver explicitement sa forme moderne et la compatibilité legacy encore auditée, sans présenter la forme legacy comme recommandée.
## 6. Gate de sizing obligatoire
Répondre explicitement :
```text
Les 11 wrappers Transactions, leurs DTOs/wires, les règles write/simulation, les tests et la documentation peuvent-ils être clôturés proprement dans cette session ?
```
Si NON, scinder `0.2.3` avant implémentation lourde. Une prerelease vise environ 1520 minutes de travail effectif.
## 7. Architecture Transport
Conserver le flux unique :
```text
wrapper typed
-> descriptor central
-> execute_standard_rpc
-> pool/admission
-> executor HTTP
-> validation JSON-RPC
-> decode typed
```
Interdits : client HTTP parallèle, `reqwest` direct par wrapper, bypass retry/deadline/admission, Transport -> Config, modèle Store/Program, `tracing` direct.
Réutiliser les primitives `SolanaCommitment`, `SolanaContextConfig`, `SolanaRpcContext` et les erreurs KSP lorsque pertinentes.
## 8. Transaction wire et dépendances
Ne pas ajouter une crate Solana RPC/client haut niveau pour recopier les types du wire.
`pre.001` doit décider explicitement si la représentation/validation des messages et transactions sérialisés justifie enfin une dépendance `base64`, `bs58` ou autre. Ne pas ajouter une dépendance seulement parce que les exemples RPC l'utilisent.
Les apps/workers ne doivent pas recevoir de dépendance protocolaire Solana supplémentaire : la surface publique appartient à Transport/Core.
## 9. Read methods
Auditer particulièrement :
- fee/message et blockhash contextuels ;
- recent prioritization fees ;
- signatures pagination `before/until/limit` ;
- signature statuses et recherche historique ;
- transaction nullable, encoding et version ;
- transaction count ;
- validité d'un blockhash.
Conserver l'ordre des listes et les `null` documentés.
## 10. Write submissions
### `requestAirdrop`
Considérer l'appel comme une soumission : une signature retournée n'autorise pas un resend automatique aveugle après résultat ambigu.
### `sendTransaction`
La règle no-resend est critique. Le wrapper doit utiliser la policy centrale existante et ne jamais introduire sa propre boucle de retry.
Le contrat Transport ne devient pas un executor métier : il transporte une transaction déjà construite/signée et préserve les options RPC.
## 11. `simulateTransaction`
Simulation n'est pas une WriteSubmission et reste retry-safe selon la metadata actuelle, sous réserve du réaudit `pre.001`.
Préserver les options et résultats utiles sans introduire de décodage Program métier.
## 12. Tests
Fixtures HTTP locales obligatoires pour chaque méthode :
- request exacte ;
- success typed ;
- null/optional ;
- config/legacy pertinent ;
- erreurs RPC ;
- cardinalités ;
- write dispatch/no-resend ;
- simulation.
Conserver les canaries :
```text
current == 52 confirmées
historical == 14 confirmées
0.2.1 exact == 4
0.2.2 exact == 22
0.2.3 exact == 11
0.2.4 reste 15 sans avancement prématuré
```
Les smokes live restent opt-in et ne remplacent jamais les fixtures.
## 13. Documentation et fin de release
La dernière prerelease doit, comme `0.2.2-pre.007` :
- réauditer l'inventaire officiel ;
- figer les canaries de complétude ;
- décider/ajouter un smoke opt-in pertinent sans créer une mauvaise ownership boundary ;
- synchroniser README/USAGE, plan et matrice de validation ;
- préparer le prompt `0.2.4` ;
- laisser `CHANGELOG.md` à `rel.001` ;
- préparer une release stable strictement publicationnelle.
## 14. Validations
Pendant le développement :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
À la clôture :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test -p ksp-config-lib
cargo test -p ksp-core-lib
cargo test -p ksp-app-config-desk
cargo test --workspace
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib -d
cargo tree -p ksp-onchain-transport-lib -e features
cargo tree -p ksp-onchain-transport-lib -e normal
```
Ne jamais déclarer une commande réussie sans preuve opérateur ou exécution réelle.